22 changed files with 4552 additions and 190 deletions
@ -0,0 +1,9 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/mobile_elf_controller.dart'; |
||||
|
|
||||
|
class MobileElfBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<MobileElfController>(() => MobileElfController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,459 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:agent_service/agent_service.dart'; |
||||
|
import 'package:chat_storage/chat_storage.dart'; |
||||
|
import 'package:flutter/widgets.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
import 'package:permission_handler/permission_handler.dart'; |
||||
|
import '../../../data/models/appconfig.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import '../../../core/utils/permission_util.dart'; |
||||
|
|
||||
|
enum MobileElfStatus { idle, connecting, recording, thinking, speaking, error } |
||||
|
|
||||
|
class MobileElfMessage { |
||||
|
final bool isUser; |
||||
|
final String text; |
||||
|
final DateTime time; |
||||
|
MobileElfMessage( |
||||
|
{required this.isUser, required this.text, required this.time}); |
||||
|
} |
||||
|
|
||||
|
class MobileElfController extends GetxController { |
||||
|
static const _tag = 'MobileElfCtrl'; |
||||
|
|
||||
|
// ── 可观察状态 ───────────────────────────────────────────────────────────── |
||||
|
final status = MobileElfStatus.idle.obs; |
||||
|
final messages = <MobileElfMessage>[].obs; |
||||
|
final realtimeText = ''.obs; |
||||
|
final errorMsg = ''.obs; |
||||
|
final inCall = false.obs; |
||||
|
final callSeconds = 0.obs; |
||||
|
final recordingSeconds = 0.obs; |
||||
|
final micRms = 0.0.obs; |
||||
|
final micAlive = false.obs; |
||||
|
final pttRecording = false.obs; |
||||
|
final pttSeconds = 0.obs; |
||||
|
final ttsEnabled = true.obs; // 是否播报 AI 回复 |
||||
|
final ScrollController scrollController = ScrollController(); |
||||
|
|
||||
|
// 凭证状态(原生层负责,Dart 层始终无错) |
||||
|
final credentialReady = true.obs; |
||||
|
final credentialError = ''.obs; |
||||
|
// 设备认证失败(STS credential_failed 事件触发) |
||||
|
final credentialFailed = false.obs; |
||||
|
final credentialFailedReason = ''.obs; |
||||
|
|
||||
|
// ── 内部 ─────────────────────────────────────────────────────────────────── |
||||
|
StreamSubscription<AgentServiceEvent>? _eventSub; |
||||
|
Timer? _callTimer; |
||||
|
Timer? _pttTimer; |
||||
|
Timer? _pttSafetyTimer; |
||||
|
bool _inCallMode = false; |
||||
|
bool _callStarting = false; // 防止并发 startCall |
||||
|
// PTT 松开后仍有异步事件待处理(recognition_result / assistant_response / tts) |
||||
|
bool _pttResultPending = false; |
||||
|
// PTT 正在异步启动(await 权限期间用户已松手) |
||||
|
bool _pttStarting = false; |
||||
|
// BLE/AI 唤醒触发的会话(非通话、非PTT模式下的唤醒) |
||||
|
bool _bleWakeupActive = false; |
||||
|
// PTT 被中止(轻点松手),用于忽略原生层的残留事件 |
||||
|
bool _pttAborted = false; |
||||
|
final _storage = GetStorage(); |
||||
|
// 滚动:layout 变化前是否已在底部 |
||||
|
bool _atBottom = true; |
||||
|
|
||||
|
// ═══════════════════════════════════════════════════════════════════════════ |
||||
|
// 生命周期 |
||||
|
// ═══════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
String get _agentId => AppConfig.env('MOBILE_ELF_AGENT_ID') ?? 'mobile_elf'; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
_subscribeEvents(); |
||||
|
_loadChatHistory(); |
||||
|
// 同步播报开关到原生层 |
||||
|
ever(ttsEnabled, (bool enabled) { |
||||
|
AgentService.setTtsMuted(!enabled); |
||||
|
}); |
||||
|
// 主动触发认证检查(页面打开时,确保 credential_failed 事件不被错过) |
||||
|
AgentService.fetchCredentials(); |
||||
|
} |
||||
|
|
||||
|
Future<void> _loadChatHistory() async { |
||||
|
try { |
||||
|
final result = await ChatStorage.getMessages( |
||||
|
agentId: _agentId, |
||||
|
page: 1, |
||||
|
pageSize: 50, |
||||
|
); |
||||
|
if (result.messages.isNotEmpty) { |
||||
|
final history = result.messages.map((m) => MobileElfMessage( |
||||
|
isUser: m.sender == 'user', |
||||
|
text: m.message, |
||||
|
time: DateTime.tryParse(m.timestamp) ?? DateTime.now(), |
||||
|
)).toList(); |
||||
|
// 按时间正序排列 |
||||
|
history.sort((a, b) => a.time.compareTo(b.time)); |
||||
|
messages.addAll(history); |
||||
|
_scrollToBottom(); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('$_tag 加载聊天记录失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
_eventSub?.cancel(); |
||||
|
_callTimer?.cancel(); |
||||
|
_pttTimer?.cancel(); |
||||
|
_pttSafetyTimer?.cancel(); |
||||
|
AgentService.stopConversation(); |
||||
|
scrollController.dispose(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
// ═══════════════════════════════════════════════════════════════════════════ |
||||
|
// 事件订阅(来自原生 StsAgent) |
||||
|
// ═══════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
void _subscribeEvents() { |
||||
|
_eventSub = AgentService.events.listen(_onAgentEvent); |
||||
|
} |
||||
|
|
||||
|
void _onAgentEvent(AgentServiceEvent event) { |
||||
|
Logger.info('$_tag event: ${event.type} data: ${event.data}'); |
||||
|
// PTT 松开后 pttRecording=false,但 fin_result/assistant_response/tts 仍异步到达 |
||||
|
// 用 _pttResultPending 保持会话激活,直到 ttsCompleted/error 才关闭 |
||||
|
final sessionActive = _inCallMode || pttRecording.value || _pttResultPending || _bleWakeupActive; |
||||
|
switch (event.type) { |
||||
|
case AgentServiceEventType.recognitionStarted: |
||||
|
// 忽略刚刚中止的 PTT 会话的残留事件,避免误触发 BLE 唤醒 |
||||
|
if (_pttAborted && !sessionActive) { |
||||
|
_pttAborted = false; |
||||
|
break; |
||||
|
} |
||||
|
// BLE/AI 唤醒:即使没有主动会话也激活 |
||||
|
if (!sessionActive) _bleWakeupActive = true; |
||||
|
status.value = MobileElfStatus.recording; |
||||
|
realtimeText.value = ''; |
||||
|
micAlive.value = true; |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.recognizing: |
||||
|
if (!sessionActive) break; |
||||
|
realtimeText.value = event.data['text']?.toString() ?? ''; |
||||
|
status.value = MobileElfStatus.recording; |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.recognitionResult: |
||||
|
if (!sessionActive) break; |
||||
|
final text = event.data['text']?.toString() ?? ''; |
||||
|
realtimeText.value = ''; |
||||
|
micAlive.value = false; |
||||
|
if (text.isNotEmpty) _addMessage(isUser: true, text: text); |
||||
|
status.value = MobileElfStatus.thinking; |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.recognitionStopped: |
||||
|
case AgentServiceEventType.recognitionCanceled: |
||||
|
if (!sessionActive) break; |
||||
|
realtimeText.value = ''; |
||||
|
micAlive.value = false; |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.assistantResponse: |
||||
|
if (!sessionActive) break; |
||||
|
final text = event.data['response']?.toString() ?? ''; |
||||
|
if (text.isNotEmpty) _updateOrAddAssistantMessage(text); |
||||
|
if (!ttsEnabled.value) { |
||||
|
status.value = |
||||
|
_inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle; |
||||
|
micAlive.value = _inCallMode; |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.ttsStarted: |
||||
|
if (!sessionActive) break; |
||||
|
status.value = MobileElfStatus.speaking; |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.ttsCompleted: |
||||
|
if (!sessionActive) break; |
||||
|
_pttResultPending = false; |
||||
|
_pttSafetyTimer?.cancel(); |
||||
|
_endBleWakeup(); // BLE 唤醒一轮结束,清理 native PTT 状态 |
||||
|
if (_inCallMode) { |
||||
|
status.value = MobileElfStatus.recording; |
||||
|
micAlive.value = true; |
||||
|
} else { |
||||
|
status.value = MobileElfStatus.idle; |
||||
|
micAlive.value = false; |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.ttsCanceled: |
||||
|
case AgentServiceEventType.ttsStopped: |
||||
|
if (!sessionActive) break; |
||||
|
_pttResultPending = false; |
||||
|
_pttSafetyTimer?.cancel(); |
||||
|
_endBleWakeup(); |
||||
|
status.value = _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle; |
||||
|
micAlive.value = _inCallMode; |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.error: |
||||
|
if (!sessionActive) break; |
||||
|
_pttResultPending = false; |
||||
|
_pttSafetyTimer?.cancel(); |
||||
|
_endBleWakeup(); |
||||
|
final msg = event.data['message']?.toString() ?? '未知错误'; |
||||
|
status.value = MobileElfStatus.error; |
||||
|
errorMsg.value = msg; |
||||
|
Logger.error('$_tag error: $msg'); |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.micRms: |
||||
|
final rms = (event.data['rms'] as num?)?.toDouble() ?? 0.0; |
||||
|
micRms.value = rms; |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.heartbeatReported: |
||||
|
// 记录心跳上报时间 |
||||
|
final t = (event.data['time'] as num?)?.toInt() ?? DateTime.now().millisecondsSinceEpoch; |
||||
|
_storage.write('sts_heartbeat_time', t); |
||||
|
Logger.info('$_tag 心跳上报成功,时间: $t'); |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.credentialFailed: |
||||
|
final reason = event.data['reason']?.toString() ?? '设备未认证'; |
||||
|
Logger.error('$_tag 设备认证失败: $reason'); |
||||
|
credentialFailed.value = true; |
||||
|
credentialFailedReason.value = reason; |
||||
|
// 确保停止任何进行中的会话 |
||||
|
if (_inCallMode || pttRecording.value) { |
||||
|
disconnect(); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
_scrollToBottom(); |
||||
|
} |
||||
|
|
||||
|
// ═══════════════════════════════════════════════════════════════════════════ |
||||
|
// 公共接口(View 调用) |
||||
|
// ═══════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
Future<void> startCall() async { |
||||
|
if (inCall.value || _callStarting) return; |
||||
|
_callStarting = true; |
||||
|
_pttAborted = false; |
||||
|
_checkHeartbeat(); |
||||
|
try { |
||||
|
// 若有正在播报的 PTT/BLE 唤醒会话,先强制中断 TTS/AI 流, |
||||
|
// 使原生层立刻进入可停止状态,避免 stopConversation 长时间等待 |
||||
|
if (_pttResultPending || _bleWakeupActive) { |
||||
|
_pttResultPending = false; |
||||
|
_bleWakeupActive = false; |
||||
|
await AgentService.interruptCurrentResponse(); |
||||
|
} |
||||
|
// 再确保原生层完全停止 |
||||
|
await AgentService.stopConversation(); |
||||
|
|
||||
|
final granted = await PermissionUtil.instance.requestPermission( |
||||
|
permissionType: Permission.microphone, |
||||
|
permissionName: '麦克风', |
||||
|
explanationText: '移动精灵需要麦克风权限才能进行语音对话', |
||||
|
permanentDenialText: '请在设置中开启麦克风权限以使用语音功能', |
||||
|
); |
||||
|
if (!granted || inCall.value) return; // 权限等待期间可能已被挂断或重复调用 |
||||
|
_inCallMode = true; |
||||
|
inCall.value = true; |
||||
|
callSeconds.value = 0; |
||||
|
micAlive.value = true; |
||||
|
status.value = MobileElfStatus.connecting; |
||||
|
_callTimer?.cancel(); |
||||
|
_callTimer = Timer.periodic( |
||||
|
const Duration(seconds: 1), (_) => callSeconds.value++); |
||||
|
await AgentService.startConversation(mode: 'phone_call'); |
||||
|
// startCall 返回即表示麦克风已打开、VAD 已在监听,直接显示聆听状态 |
||||
|
// (call 模式下 recognition_started 只在 VAD 检测到语音时才发出,不能等它来转状态) |
||||
|
if (_inCallMode) status.value = MobileElfStatus.recording; |
||||
|
} finally { |
||||
|
_callStarting = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> disconnect() async { |
||||
|
_callStarting = false; |
||||
|
_inCallMode = false; |
||||
|
_pttStarting = false; |
||||
|
_pttResultPending = false; // 强制结束所有等待 |
||||
|
_pttSafetyTimer?.cancel(); |
||||
|
_bleWakeupActive = false; |
||||
|
inCall.value = false; |
||||
|
_callTimer?.cancel(); |
||||
|
_callTimer = null; |
||||
|
callSeconds.value = 0; |
||||
|
pttRecording.value = false; |
||||
|
_pttTimer?.cancel(); |
||||
|
_pttTimer = null; |
||||
|
status.value = MobileElfStatus.idle; |
||||
|
micAlive.value = false; |
||||
|
realtimeText.value = ''; |
||||
|
await AgentService.stopConversation(); |
||||
|
} |
||||
|
|
||||
|
/// 超过1小时未上报心跳则补报一次 |
||||
|
void _checkHeartbeat() { |
||||
|
final last = _storage.read<int>('sts_heartbeat_time') ?? 0; |
||||
|
if (DateTime.now().millisecondsSinceEpoch - last > 3600000) { |
||||
|
AgentService.reportHeartbeat().catchError((_) {}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> startPtt() async { |
||||
|
if (pttRecording.value || _pttStarting) return; |
||||
|
_pttStarting = true; |
||||
|
_pttAborted = false; |
||||
|
_checkHeartbeat(); |
||||
|
// BLE 唤醒/PTT 等待结果期间用户按下 PTT,先中断当前会话(同 startCall 逻辑) |
||||
|
if (_bleWakeupActive || _pttResultPending) { |
||||
|
_pttResultPending = false; |
||||
|
_bleWakeupActive = false; |
||||
|
await AgentService.interruptCurrentResponse(); |
||||
|
await AgentService.stopConversation(); |
||||
|
} |
||||
|
final granted = await PermissionUtil.instance.requestPermission( |
||||
|
permissionType: Permission.microphone, |
||||
|
permissionName: '麦克风', |
||||
|
explanationText: '移动精灵需要麦克风权限才能进行语音对话', |
||||
|
permanentDenialText: '请在设置中开启麦克风权限以使用语音功能', |
||||
|
); |
||||
|
// 权限等待期间用户已松手(stopPtt 将 _pttStarting 置 false),直接放弃 |
||||
|
if (!granted || !_pttStarting) { |
||||
|
_pttStarting = false; |
||||
|
return; |
||||
|
} |
||||
|
_pttStarting = false; |
||||
|
pttRecording.value = true; |
||||
|
pttSeconds.value = 0; |
||||
|
status.value = MobileElfStatus.connecting; |
||||
|
_pttTimer?.cancel(); |
||||
|
_pttTimer = Timer.periodic( |
||||
|
const Duration(seconds: 1), (_) => pttSeconds.value++); |
||||
|
AgentService.startConversation(mode: 'push_to_talk'); |
||||
|
} |
||||
|
|
||||
|
void stopPtt() { |
||||
|
// 若仍在等待权限授予,取消本次启动 |
||||
|
if (_pttStarting) { |
||||
|
_pttStarting = false; |
||||
|
return; |
||||
|
} |
||||
|
if (!pttRecording.value) return; |
||||
|
pttRecording.value = false; |
||||
|
_pttTimer?.cancel(); |
||||
|
_pttTimer = null; |
||||
|
|
||||
|
// 如果还在连接中(还未开始识别),说明用户轻点了一下就松手, |
||||
|
// 直接恢复空闲,不需要等待异步事件回来 |
||||
|
if (status.value == MobileElfStatus.connecting) { |
||||
|
_pttAborted = true; // 标记中止,忽略原生层的残留事件 |
||||
|
_pttResultPending = false; |
||||
|
status.value = MobileElfStatus.idle; |
||||
|
micAlive.value = false; |
||||
|
AgentService.stopConversation(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
_pttResultPending = true; // 等待 fin_result/assistant_response/tts 异步回来 |
||||
|
AgentService.stopConversation(); |
||||
|
|
||||
|
// 安全超时:防止原生层未返回事件导致状态永久卡住 |
||||
|
_pttSafetyTimer?.cancel(); |
||||
|
_pttSafetyTimer = Timer(const Duration(seconds: 8), () { |
||||
|
if (_pttResultPending && !_inCallMode) { |
||||
|
Logger.info('$_tag PTT safety timeout — force reset to idle'); |
||||
|
_pttResultPending = false; |
||||
|
status.value = MobileElfStatus.idle; |
||||
|
micAlive.value = false; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
void interruptSpeaking() { |
||||
|
AgentService.interruptCurrentResponse(); |
||||
|
status.value = |
||||
|
_inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle; |
||||
|
micAlive.value = _inCallMode; |
||||
|
} |
||||
|
|
||||
|
/// 原生层已自动获取凭证,此处保留方法供 View 调用不报错 |
||||
|
Future<void> retryCredentials() async { |
||||
|
credentialError.value = ''; |
||||
|
} |
||||
|
|
||||
|
/// 清空聊天记录 |
||||
|
Future<void> clearMessages() async { |
||||
|
messages.clear(); |
||||
|
try { |
||||
|
await AgentService.clearChatHistory(); |
||||
|
} catch (e) { |
||||
|
Logger.error('$_tag 清空聊天记录失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ═══════════════════════════════════════════════════════════════════════════ |
||||
|
// 工具 |
||||
|
// ═══════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
void _addMessage({required bool isUser, required String text}) { |
||||
|
messages.add(MobileElfMessage( |
||||
|
isUser: isUser, text: text, time: DateTime.now())); |
||||
|
_scrollToBottom(); |
||||
|
} |
||||
|
|
||||
|
/// 更新最后一条 AI 消息(如果存在且更短),否则添加新消息 |
||||
|
void _updateOrAddAssistantMessage(String text) { |
||||
|
final lastIdx = messages.length - 1; |
||||
|
if (lastIdx >= 0 && !messages[lastIdx].isUser) { |
||||
|
// 用更完整的文本替换 |
||||
|
messages[lastIdx] = MobileElfMessage( |
||||
|
isUser: false, text: text, time: messages[lastIdx].time); |
||||
|
messages.refresh(); |
||||
|
} else { |
||||
|
_addMessage(isUser: false, text: text); |
||||
|
} |
||||
|
_scrollToBottom(); |
||||
|
} |
||||
|
|
||||
|
/// BLE 唤醒会话标志清除 |
||||
|
/// Native 状态(pttRecording 等)由 StsAgent.startPtt() 的重入逻辑懒清理, |
||||
|
/// 不在此调用 stopConversation(),避免与新会话产生竞态。 |
||||
|
void _endBleWakeup() { |
||||
|
_bleWakeupActive = false; |
||||
|
} |
||||
|
|
||||
|
void _scrollToBottom() { |
||||
|
// layout 变化前捕获是否在底部,避免新内容撑开后差值变大误判为"已滚离底部" |
||||
|
if (scrollController.hasClients) { |
||||
|
final pos = scrollController.position; |
||||
|
_atBottom = pos.maxScrollExtent - pos.pixels <= 80; |
||||
|
} |
||||
|
WidgetsBinding.instance.addPostFrameCallback((_) { |
||||
|
if (scrollController.hasClients && _atBottom) { |
||||
|
scrollController.animateTo( |
||||
|
scrollController.position.maxScrollExtent, |
||||
|
duration: const Duration(milliseconds: 300), |
||||
|
curve: Curves.easeOut, |
||||
|
); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,988 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:intl/intl.dart'; // 用于 DateFormat |
||||
|
import '../controllers/mobile_elf_controller.dart'; |
||||
|
|
||||
|
class MobileElfView extends GetView<MobileElfController> { |
||||
|
const MobileElfView({Key? key}) : super(key: key); |
||||
|
|
||||
|
// ── 颜色常量 ────────────────────────────────────────────────────────────── |
||||
|
static const _blue = Color(0xFF2E7CF6); |
||||
|
static const _green = Color(0xFF34C759); |
||||
|
static const _greenDark = Color(0xFF16A34A); |
||||
|
static const _red = Color(0xFFEF4444); |
||||
|
static const _orange = Color(0xFFF79009); |
||||
|
static const _bgLight = Color(0xFFF4F6F9); |
||||
|
static const _surfaceLight = Colors.white; |
||||
|
static const _textPrimary = Color(0xFF101828); |
||||
|
static const _textSecondary = Color(0xFF667085); |
||||
|
static const _border = Color(0xFFEAECF0); |
||||
|
|
||||
|
// 渐变复用 |
||||
|
static const _avatarGradient = LinearGradient( |
||||
|
colors: [Color(0xFF667EEA), Color(0xFF764BA2)], |
||||
|
begin: Alignment.topLeft, |
||||
|
end: Alignment.bottomRight, |
||||
|
); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDark = Theme.of(context).brightness == Brightness.dark; |
||||
|
return Scaffold( |
||||
|
backgroundColor: isDark ? const Color(0xFF0D0D0D) : _bgLight, |
||||
|
appBar: _buildAppBar(isDark), |
||||
|
// 顶层 Listener 捕获 PTT 松开事件(确保 widget 树重建后仍能接收 pointerUp) |
||||
|
body: Listener( |
||||
|
behavior: HitTestBehavior.translucent, |
||||
|
onPointerUp: (_) { |
||||
|
if (controller.pttRecording.value) controller.stopPtt(); |
||||
|
}, |
||||
|
child: Obx(() { |
||||
|
if (controller.credentialError.value.isNotEmpty) { |
||||
|
return _buildCredentialError(); |
||||
|
} |
||||
|
return Column( |
||||
|
children: [ |
||||
|
Expanded(child: _buildMessageList(isDark)), |
||||
|
_buildBottomBar(isDark), |
||||
|
], |
||||
|
); |
||||
|
}), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// ══════════════════════════════════════════════════════════════════════════ |
||||
|
// AppBar |
||||
|
// ══════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
PreferredSizeWidget _buildAppBar(bool isDark) { |
||||
|
return AppBar( |
||||
|
backgroundColor: isDark ? const Color(0xFF1A1A1A) : _surfaceLight, |
||||
|
elevation: 0, |
||||
|
surfaceTintColor: Colors.transparent, |
||||
|
bottom: PreferredSize( |
||||
|
preferredSize: const Size.fromHeight(1), |
||||
|
child: Container( |
||||
|
color: isDark ? const Color(0xFF222222) : _border, height: 1), |
||||
|
), |
||||
|
leading: IconButton( |
||||
|
icon: Icon(Icons.arrow_back_ios, |
||||
|
size: 18.sp, color: isDark ? Colors.white : _textSecondary), |
||||
|
onPressed: () => Get.back(), |
||||
|
), |
||||
|
titleSpacing: 0, |
||||
|
title: Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
_gradientCircle(32.w, '晓', 13.sp), |
||||
|
SizedBox(width: 10.w), |
||||
|
Text('移动精灵', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isDark ? Colors.white : _textPrimary)), |
||||
|
SizedBox(width: 8.w), |
||||
|
Obx(() => _buildStatusPill()), |
||||
|
], |
||||
|
), |
||||
|
actions: [ |
||||
|
// 播报开关 |
||||
|
Obx(() => IconButton( |
||||
|
icon: Icon( |
||||
|
controller.ttsEnabled.value |
||||
|
? Icons.volume_up_rounded |
||||
|
: Icons.volume_off_rounded, |
||||
|
size: 20.sp, |
||||
|
color: controller.ttsEnabled.value |
||||
|
? _blue |
||||
|
: (isDark ? Colors.grey[500] : _textSecondary), |
||||
|
), |
||||
|
onPressed: () => controller.ttsEnabled.toggle(), |
||||
|
tooltip: controller.ttsEnabled.value ? '关闭播报' : '开启播报', |
||||
|
)), |
||||
|
PopupMenuButton<String>( |
||||
|
icon: Icon(Icons.more_vert, |
||||
|
size: 20.sp, color: isDark ? Colors.white : _textSecondary), |
||||
|
onSelected: (value) { |
||||
|
if (value == 'clear') { |
||||
|
Get.dialog( |
||||
|
AlertDialog( |
||||
|
title: Text('clearChatHistory'.tr.isNotEmpty ? 'clearChatHistory'.tr : '清空记录'), |
||||
|
content: Text('confirmClearChat'.tr.isNotEmpty ? 'confirmClearChat'.tr : '确定清空所有聊天记录吗?'), |
||||
|
actions: [ |
||||
|
TextButton(onPressed: () => Get.back(), child: Text('cancel'.tr.isNotEmpty ? 'cancel'.tr : '取消')), |
||||
|
TextButton( |
||||
|
onPressed: () { |
||||
|
controller.clearMessages(); |
||||
|
Get.back(); |
||||
|
}, |
||||
|
child: Text('confirm'.tr.isNotEmpty ? 'confirm'.tr : '确定', style: const TextStyle(color: Colors.red)), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
}, |
||||
|
itemBuilder: (_) => [ |
||||
|
PopupMenuItem( |
||||
|
value: 'clear', |
||||
|
child: Row(children: [ |
||||
|
Icon(Icons.delete_outline, size: 18.sp, color: Colors.red), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text('清空记录', style: TextStyle(fontSize: 14.sp, color: Colors.red)), |
||||
|
]), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 右上角状态胶囊 + 通话计时 |
||||
|
Widget _buildStatusPill() { |
||||
|
if (controller.credentialFailed.value) { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 3.h), |
||||
|
decoration: BoxDecoration(color: const Color(0xFFFEF2F2), borderRadius: BorderRadius.circular(20.r)), |
||||
|
child: Row(mainAxisSize: MainAxisSize.min, children: [ |
||||
|
Icon(Icons.lock_outline_rounded, size: 11.sp, color: _red), |
||||
|
SizedBox(width: 4.w), |
||||
|
Text('认证失败', style: TextStyle(fontSize: 11.sp, color: _red, fontWeight: FontWeight.w500)), |
||||
|
]), |
||||
|
); |
||||
|
} |
||||
|
final s = controller.status.value; |
||||
|
final inCall = controller.inCall.value; |
||||
|
final cfg = _pillConfig(s, inCall); |
||||
|
return Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 3.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: cfg.bg, |
||||
|
borderRadius: BorderRadius.circular(20.r), |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Text(cfg.label, |
||||
|
style: TextStyle( |
||||
|
fontSize: 11.sp, |
||||
|
color: cfg.fg, |
||||
|
fontWeight: FontWeight.w500)), |
||||
|
if (inCall) ...[ |
||||
|
SizedBox(width: 4.w), |
||||
|
Obx(() { |
||||
|
final sec = controller.callSeconds.value; |
||||
|
return Text(_fmtDuration(sec), |
||||
|
style: TextStyle( |
||||
|
fontSize: 11.sp, |
||||
|
color: cfg.fg, |
||||
|
fontWeight: FontWeight.w400)); |
||||
|
}), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
_PillCfg _pillConfig(MobileElfStatus s, bool inCall) { |
||||
|
switch (s) { |
||||
|
case MobileElfStatus.connecting: |
||||
|
return const _PillCfg(Color(0xFFFFFAEB), Color(0xFFB54708), '连接中…'); |
||||
|
case MobileElfStatus.recording: |
||||
|
return const _PillCfg(Color(0xFFECFDF3), _greenDark, '聆听中'); |
||||
|
case MobileElfStatus.thinking: |
||||
|
return const _PillCfg(Color(0xFFEFF8FF), Color(0xFF175CD3), '处理中'); |
||||
|
case MobileElfStatus.speaking: |
||||
|
return const _PillCfg(Color(0xFFECFDF3), _greenDark, '朗读中'); |
||||
|
case MobileElfStatus.error: |
||||
|
return const _PillCfg(Color(0xFFFEF2F2), _red, '错误'); |
||||
|
case MobileElfStatus.idle: |
||||
|
return inCall |
||||
|
? const _PillCfg(Color(0xFFECFDF3), _greenDark, '通话中') |
||||
|
: const _PillCfg(Color(0xFFF2F4F7), _textSecondary, '空闲'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ══════════════════════════════════════════════════════════════════════════ |
||||
|
// 凭证错误 |
||||
|
// ══════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
Widget _buildCredentialError() { |
||||
|
return Center( |
||||
|
child: Padding( |
||||
|
padding: EdgeInsets.all(32.w), |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Icon(Icons.wifi_off_rounded, size: 56.sp, color: Colors.red[300]), |
||||
|
SizedBox(height: 16.h), |
||||
|
Obx(() => Text(controller.credentialError.value, |
||||
|
textAlign: TextAlign.center, |
||||
|
style: TextStyle(fontSize: 13.sp, color: Colors.red[400]))), |
||||
|
SizedBox(height: 20.h), |
||||
|
ElevatedButton.icon( |
||||
|
onPressed: controller.retryCredentials, |
||||
|
icon: const Icon(Icons.refresh), |
||||
|
label: const Text('重试'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// ══════════════════════════════════════════════════════════════════════════ |
||||
|
// 消息列表 |
||||
|
// ══════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
Widget _buildMessageList(bool isDark) { |
||||
|
return Obx(() { |
||||
|
final msgs = controller.messages; |
||||
|
final rt = controller.realtimeText.value; |
||||
|
if (msgs.isEmpty && rt.isEmpty) return _buildEmptyState(isDark); |
||||
|
return ListView.builder( |
||||
|
controller: controller.scrollController, |
||||
|
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 8.h), |
||||
|
itemCount: msgs.length + (rt.isNotEmpty ? 1 : 0), |
||||
|
itemBuilder: (_, i) { |
||||
|
if (i < msgs.length) return _buildBubble(msgs[i], isDark); |
||||
|
return _buildRealtimeBubble(rt); |
||||
|
}, |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
Widget _buildEmptyState(bool isDark) { |
||||
|
return Center( |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
_gradientCircle(80.w, '晓', 32.sp), |
||||
|
SizedBox(height: 20.h), |
||||
|
Text('移动精灵', |
||||
|
style: TextStyle( |
||||
|
fontSize: 20.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: isDark ? Colors.white : _textPrimary)), |
||||
|
SizedBox(height: 8.h), |
||||
|
Text('点击右下角按钮,即可与晓晓对话', |
||||
|
style: TextStyle( |
||||
|
fontSize: 13.sp, |
||||
|
color: isDark ? Colors.grey[500] : _textSecondary)), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// ── 消息气泡 ────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
Widget _buildBubble(MobileElfMessage msg, bool isDark) { |
||||
|
final isUser = msg.isUser; |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.only(bottom: 14.h), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: |
||||
|
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
// AI 头像 + 名字 |
||||
|
if (!isUser) ...[ |
||||
|
Row(children: [ |
||||
|
_gradientCircle(28.w, '晓', 11.sp), |
||||
|
SizedBox(width: 6.w), |
||||
|
Text('晓晓', |
||||
|
style: TextStyle(fontSize: 11.sp, color: _textSecondary)), |
||||
|
]), |
||||
|
SizedBox(height: 4.h), |
||||
|
], |
||||
|
// 气泡体(长按弹出菜单) |
||||
|
Row( |
||||
|
mainAxisAlignment: |
||||
|
isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
||||
|
children: [ |
||||
|
if (!isUser) SizedBox(width: 34.w), |
||||
|
Flexible( |
||||
|
child: GestureDetector( |
||||
|
onLongPressStart: (details) => _showMessageMenu(details, msg, isDark), |
||||
|
child: Container( |
||||
|
padding: |
||||
|
EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isUser |
||||
|
? _blue |
||||
|
: (isDark ? const Color(0xFF2A2A2A) : _surfaceLight), |
||||
|
borderRadius: BorderRadius.only( |
||||
|
topLeft: Radius.circular(18.r), |
||||
|
topRight: Radius.circular(18.r), |
||||
|
bottomLeft: Radius.circular(isUser ? 18.r : 4.r), |
||||
|
bottomRight: Radius.circular(isUser ? 4.r : 18.r), |
||||
|
), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withValues(alpha: 0.05), |
||||
|
blurRadius: 6, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Text(msg.text, |
||||
|
style: TextStyle( |
||||
|
fontSize: 15.sp, |
||||
|
height: 1.55, |
||||
|
color: isUser |
||||
|
? Colors.white |
||||
|
: (isDark ? Colors.white : _textPrimary))), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
// 时间 + AI 标签 |
||||
|
SizedBox(height: 3.h), |
||||
|
Padding( |
||||
|
padding: EdgeInsets.only(left: isUser ? 0 : 34.w), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: |
||||
|
isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text(_formatTimestamp(msg.time), |
||||
|
style: TextStyle( |
||||
|
fontSize: 10.sp, color: isDark ? Colors.grey[500] : const Color(0xFFD0D5DD))), |
||||
|
if (!isUser) ...[ |
||||
|
SizedBox(width: 8.w), |
||||
|
Text('generatedByAi'.tr.isNotEmpty ? 'generatedByAi'.tr : '由AI生成', |
||||
|
style: TextStyle( |
||||
|
fontSize: 10.sp, |
||||
|
color: Colors.blue[600], |
||||
|
fontWeight: FontWeight.bold, |
||||
|
fontStyle: FontStyle.italic)), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 长按消息弹出菜单 |
||||
|
void _showMessageMenu(LongPressStartDetails details, MobileElfMessage msg, bool isDark) { |
||||
|
HapticFeedback.mediumImpact(); |
||||
|
final overlay = Overlay.of(Get.context!).context.findRenderObject() as RenderBox?; |
||||
|
if (overlay == null) return; |
||||
|
final position = RelativeRect.fromRect( |
||||
|
Rect.fromLTWH(details.globalPosition.dx, details.globalPosition.dy, 0, 0), |
||||
|
Offset.zero & overlay.size, |
||||
|
); |
||||
|
showMenu( |
||||
|
context: Get.context!, |
||||
|
position: position, |
||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.r)), |
||||
|
color: isDark ? const Color(0xFF2A2A2A) : Colors.white, |
||||
|
items: [ |
||||
|
PopupMenuItem( |
||||
|
onTap: () { |
||||
|
Clipboard.setData(ClipboardData(text: msg.text)); |
||||
|
Get.snackbar('success'.tr.isNotEmpty ? 'success'.tr : '成功', |
||||
|
'copySuccess'.tr.isNotEmpty ? 'copySuccess'.tr : '已复制', |
||||
|
backgroundColor: Colors.blue.withValues(alpha: 0.8), colorText: Colors.white); |
||||
|
}, |
||||
|
child: Row(children: [ |
||||
|
Icon(Icons.copy, size: 18.sp, color: isDark ? Colors.white : Colors.black87), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text('copy'.tr.isNotEmpty ? 'copy'.tr : '复制', |
||||
|
style: TextStyle(fontSize: 14.sp, color: isDark ? Colors.white : Colors.black87)), |
||||
|
]), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 智能时间格式化(与 agent 模块一致) |
||||
|
String _formatTimestamp(DateTime t) { |
||||
|
final now = DateTime.now(); |
||||
|
final diff = now.difference(t); |
||||
|
final today = DateTime(now.year, now.month, now.day); |
||||
|
final msgDate = DateTime(t.year, t.month, t.day); |
||||
|
|
||||
|
if (diff.inMinutes < 1) return 'justNow'.tr.isNotEmpty ? 'justNow'.tr : '刚刚'; |
||||
|
if (diff.inHours < 1) { |
||||
|
final m = diff.inMinutes.toString(); |
||||
|
final text = 'minutesAgo'.trParams({'minutes': m}); |
||||
|
return text.isNotEmpty && text != 'minutesAgo' ? text : '$m分钟前'; |
||||
|
} |
||||
|
if (msgDate == today) return DateFormat('HH:mm').format(t); |
||||
|
if (msgDate == today.subtract(const Duration(days: 1))) { |
||||
|
return '${('yesterday'.tr.isNotEmpty ? 'yesterday'.tr : '昨天')} ${DateFormat('HH:mm').format(t)}'; |
||||
|
} |
||||
|
if (diff.inDays < 7) { |
||||
|
return '${_weekdayName(t.weekday)} ${DateFormat('HH:mm').format(t)}'; |
||||
|
} |
||||
|
if (t.year == now.year) return DateFormat('MM-dd HH:mm').format(t); |
||||
|
return DateFormat('yyyy-MM-dd HH:mm').format(t); |
||||
|
} |
||||
|
|
||||
|
String _weekdayName(int w) { |
||||
|
const names = ['', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']; |
||||
|
return names[w]; |
||||
|
} |
||||
|
|
||||
|
Widget _buildRealtimeBubble(String text) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.only(bottom: 14.h), |
||||
|
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ |
||||
|
Flexible( |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: _blue.withValues(alpha: 0.6), |
||||
|
borderRadius: BorderRadius.circular(18.r), |
||||
|
), |
||||
|
child: Text(text, |
||||
|
style: TextStyle( |
||||
|
fontSize: 15.sp, color: Colors.white, height: 1.55)), |
||||
|
), |
||||
|
), |
||||
|
]), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// ══════════════════════════════════════════════════════════════════════════ |
||||
|
// 底部控制栏 |
||||
|
// ══════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
Widget _buildBottomBar(bool isDark) { |
||||
|
return Obx(() { |
||||
|
|
||||
|
final inCall = controller.inCall.value; |
||||
|
final s = controller.status.value; |
||||
|
final ptt = controller.pttRecording.value; |
||||
|
|
||||
|
// 状态栏显示条件: |
||||
|
// - 通话模式:recording/thinking/speaking/connecting 都显示 |
||||
|
// - 非通话非PTT:thinking/speaking 显示 |
||||
|
// - PTT 中:不显示(状态在中间按钮体现) |
||||
|
final showStatusBar = !ptt && ( |
||||
|
(inCall && s != MobileElfStatus.idle && s != MobileElfStatus.error) || |
||||
|
(!inCall && (s == MobileElfStatus.speaking || s == MobileElfStatus.thinking)) |
||||
|
); |
||||
|
|
||||
|
return Container( |
||||
|
width: double.infinity, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDark ? const Color(0xFF1A1A1A) : _surfaceLight, |
||||
|
border: Border(top: BorderSide(color: isDark ? const Color(0xFF222222) : _border)), |
||||
|
boxShadow: [ |
||||
|
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 12, offset: const Offset(0, -2)), |
||||
|
], |
||||
|
), |
||||
|
child: SafeArea( |
||||
|
top: false, |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
if (showStatusBar) _buildStatusBar(s, inCall, isDark), |
||||
|
if (inCall) |
||||
|
_buildInCallInputBar(isDark) |
||||
|
else |
||||
|
_buildNormalInputBar(isDark), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// ── 状态提示栏 ──────────────────────────────────────────────────────────── |
||||
|
|
||||
|
Widget _buildStatusBar(MobileElfStatus s, bool inCall, bool isDark) { |
||||
|
Widget? content; |
||||
|
|
||||
|
switch (s) { |
||||
|
case MobileElfStatus.speaking: |
||||
|
content = GestureDetector( |
||||
|
onTap: controller.interruptSpeaking, |
||||
|
behavior: HitTestBehavior.opaque, |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
||||
|
color: const Color(0xFFECFDF3), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
_MiniWave(color: _greenDark), |
||||
|
SizedBox(width: 8.w), |
||||
|
Expanded(child: Text('晓晓正在朗读...', style: TextStyle(fontSize: 13.sp, color: _greenDark, fontWeight: FontWeight.w500))), |
||||
|
Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h), |
||||
|
decoration: BoxDecoration( |
||||
|
border: Border.all(color: _red), |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
), |
||||
|
child: Text('× 打断', style: TextStyle(fontSize: 12.sp, color: _red, fontWeight: FontWeight.w500)), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
break; |
||||
|
case MobileElfStatus.recording: |
||||
|
content = Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
||||
|
color: const Color(0xFFECFDF3), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
_MiniWave(color: _greenDark), |
||||
|
SizedBox(width: 8.w), |
||||
|
Expanded(child: Obx(() { |
||||
|
final rt = controller.realtimeText.value; |
||||
|
return Text(rt.isNotEmpty ? rt : '聆听中...', style: TextStyle(fontSize: 13.sp, color: _greenDark), maxLines: 1, overflow: TextOverflow.ellipsis); |
||||
|
})), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
break; |
||||
|
case MobileElfStatus.connecting: |
||||
|
content = Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
||||
|
color: const Color(0xFFFFFAEB), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
SizedBox(width: 14.w, height: 14.w, child: CircularProgressIndicator(strokeWidth: 2, color: _orange)), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text('正在连接...', style: TextStyle(fontSize: 13.sp, color: const Color(0xFFB54708))), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
break; |
||||
|
case MobileElfStatus.thinking: |
||||
|
content = Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
||||
|
color: const Color(0xFFEFF8FF), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
SizedBox(width: 14.w, height: 14.w, child: CircularProgressIndicator(strokeWidth: 2, color: const Color(0xFF175CD3))), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text('AI 思考中...', style: TextStyle(fontSize: 13.sp, color: const Color(0xFF175CD3))), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
break; |
||||
|
default: |
||||
|
return const SizedBox.shrink(); |
||||
|
} |
||||
|
return content; |
||||
|
} |
||||
|
|
||||
|
// ── 通话模式底栏 ────────────────────────────────────────────────────────── |
||||
|
|
||||
|
Widget _buildInCallInputBar(bool isDark) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 14.h), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
// 通话状态 + 计时 |
||||
|
Expanded( |
||||
|
child: Obx(() { |
||||
|
final s = controller.status.value; |
||||
|
final idle = s == MobileElfStatus.idle; |
||||
|
return Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
width: 8.w, height: 8.w, |
||||
|
decoration: BoxDecoration(shape: BoxShape.circle, color: idle ? _green : _orange), |
||||
|
), |
||||
|
SizedBox(width: 6.w), |
||||
|
Text('通话中', style: TextStyle(fontSize: 13.sp, color: isDark ? Colors.white70 : _textSecondary)), |
||||
|
SizedBox(width: 4.w), |
||||
|
Obx(() => Text(_fmtDuration(controller.callSeconds.value), |
||||
|
style: TextStyle(fontSize: 13.sp, color: isDark ? Colors.white70 : _textSecondary))), |
||||
|
], |
||||
|
); |
||||
|
}), |
||||
|
), |
||||
|
// 音量指示器 |
||||
|
Obx(() => _VolumeIndicator( |
||||
|
rms: controller.micRms.value, |
||||
|
alive: controller.micAlive.value, |
||||
|
isDark: isDark, |
||||
|
)), |
||||
|
SizedBox(width: 12.w), |
||||
|
// 挂断按钮 |
||||
|
GestureDetector( |
||||
|
onTap: controller.disconnect, |
||||
|
child: Container( |
||||
|
width: 52.w, height: 52.w, |
||||
|
decoration: BoxDecoration( |
||||
|
shape: BoxShape.circle, |
||||
|
color: _red, |
||||
|
boxShadow: [BoxShadow(color: _red.withValues(alpha: 0.3), blurRadius: 14, offset: const Offset(0, 4))], |
||||
|
), |
||||
|
child: Icon(Icons.call_end_rounded, color: Colors.white, size: 26.sp), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// ── 普通输入栏 (按住说话) ────────────────────────────────────────────────── |
||||
|
|
||||
|
Widget _buildNormalInputBar(bool isDark) { |
||||
|
return Obx(() { |
||||
|
final s = controller.status.value; |
||||
|
final ptt = controller.pttRecording.value; |
||||
|
|
||||
|
final locked = controller.credentialFailed.value; |
||||
|
final disabled = locked || (!ptt && s != MobileElfStatus.idle && s != MobileElfStatus.error); |
||||
|
final pttClickable = !locked && s == MobileElfStatus.speaking; |
||||
|
final sideDisabled = locked || ptt || (disabled && !pttClickable); |
||||
|
|
||||
|
return Padding( |
||||
|
padding: EdgeInsets.fromLTRB(12.w, 6.h, 12.w, 14.h), |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
// ── PTT 实时识别文字(居中 + 渐变背景) ── |
||||
|
if (ptt) |
||||
|
Obx(() { |
||||
|
final rt = controller.realtimeText.value; |
||||
|
if (rt.isEmpty) return const SizedBox.shrink(); |
||||
|
return Container( |
||||
|
width: double.infinity, |
||||
|
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), |
||||
|
margin: EdgeInsets.only(bottom: 8.h), |
||||
|
decoration: BoxDecoration( |
||||
|
gradient: LinearGradient( |
||||
|
begin: Alignment.topLeft, |
||||
|
end: Alignment.bottomRight, |
||||
|
colors: isDark |
||||
|
? [const Color(0xFF2A2A2A), const Color(0xFF1A2332)] |
||||
|
: [const Color(0xFFF0F4FF), const Color(0xFFE8F5E9)], |
||||
|
), |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
border: Border.all( |
||||
|
color: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0), |
||||
|
width: 0.5, |
||||
|
), |
||||
|
), |
||||
|
child: Text(rt, |
||||
|
style: TextStyle(fontSize: 15.sp, color: isDark ? Colors.white : _textPrimary, height: 1.4), |
||||
|
textAlign: TextAlign.center, |
||||
|
maxLines: 3, overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
); |
||||
|
}), |
||||
|
// ── 按钮行 ── |
||||
|
Row( |
||||
|
children: [ |
||||
|
// 中间:PTT 按钮 |
||||
|
Expanded( |
||||
|
child: ptt |
||||
|
? _buildPttRecordingCenter(isDark) |
||||
|
: IgnorePointer( |
||||
|
ignoring: disabled && !pttClickable, |
||||
|
child: Opacity( |
||||
|
opacity: (disabled && !pttClickable) ? 0.5 : 1.0, |
||||
|
child: _buildPttIdleButton(isDark), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 8.w), |
||||
|
// 右侧:电话按钮 |
||||
|
IgnorePointer( |
||||
|
ignoring: sideDisabled, |
||||
|
child: Opacity( |
||||
|
opacity: sideDisabled ? 0.4 : 1.0, |
||||
|
child: _roundButton( |
||||
|
onTap: controller.startCall, |
||||
|
gradient: const LinearGradient( |
||||
|
colors: [Color(0xFF34C759), Color(0xFF28A745)], |
||||
|
begin: Alignment.topLeft, end: Alignment.bottomRight, |
||||
|
), |
||||
|
shadow: _green.withValues(alpha: 0.4), |
||||
|
size: 44, |
||||
|
child: Icon(Icons.phone, color: Colors.white, size: 20.sp), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 按住说话按钮(空闲状态)— 参考微信风格 |
||||
|
Widget _buildPttIdleButton(bool isDark) { |
||||
|
return Listener( |
||||
|
onPointerDown: (_) => controller.startPtt(), |
||||
|
child: Container( |
||||
|
height: 42.h, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDark ? const Color(0xFF2A2A2A) : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(21.r), |
||||
|
border: Border.all(color: isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD), width: 1), |
||||
|
), |
||||
|
child: Center( |
||||
|
child: Text('按住 说话', |
||||
|
style: TextStyle( |
||||
|
fontSize: 15.sp, |
||||
|
color: isDark ? Colors.white70 : _textPrimary, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
letterSpacing: 2.w, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// PTT 录音中:仅中间部分 |
||||
|
/// 连接中=与按住说话相同样式,录音中=橙色 |
||||
|
Widget _buildPttRecordingCenter(bool isDark) { |
||||
|
return Obx(() { |
||||
|
final s = controller.status.value; |
||||
|
final isConnecting = s == MobileElfStatus.connecting; |
||||
|
|
||||
|
if (isConnecting) { |
||||
|
// 连接中:与按住说话空闲状态一样的外观 + 加载指示 |
||||
|
return Container( |
||||
|
height: 42.h, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDark ? const Color(0xFF2A2A2A) : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(21.r), |
||||
|
border: Border.all(color: isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD), width: 1), |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
SizedBox(width: 14.w, height: 14.w, |
||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: isDark ? Colors.white70 : _textSecondary)), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text('连接中...', style: TextStyle(fontSize: 14.sp, color: isDark ? Colors.white70 : _textSecondary, fontWeight: FontWeight.w500)), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 录音中:橙色 |
||||
|
return Container( |
||||
|
height: 42.h, |
||||
|
decoration: BoxDecoration( |
||||
|
gradient: const LinearGradient(colors: [Color(0xFFF79009), Color(0xFFDC6803)]), |
||||
|
borderRadius: BorderRadius.circular(21.r), |
||||
|
boxShadow: [BoxShadow( |
||||
|
color: _orange.withValues(alpha: 0.3), |
||||
|
blurRadius: 8, offset: const Offset(0, 2), |
||||
|
)], |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Obx(() => _RmsBars(rms: controller.micRms.value, color: Colors.white)), |
||||
|
SizedBox(width: 8.w), |
||||
|
Obx(() => Text('松开发送 · ${controller.pttSeconds.value}s', |
||||
|
style: TextStyle(fontSize: 14.sp, color: Colors.white, fontWeight: FontWeight.w500))), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// ── 通用圆形按钮 ────────────────────────────────────────────────────────── |
||||
|
|
||||
|
Widget _roundButton({ |
||||
|
required VoidCallback onTap, |
||||
|
required Widget child, |
||||
|
Color? color, |
||||
|
Gradient? gradient, |
||||
|
Color? shadow, |
||||
|
Color? border, |
||||
|
double size = 56, |
||||
|
}) { |
||||
|
return GestureDetector( |
||||
|
onTap: onTap, |
||||
|
child: Container( |
||||
|
width: size.w, |
||||
|
height: size.w, |
||||
|
decoration: BoxDecoration( |
||||
|
shape: BoxShape.circle, |
||||
|
color: gradient == null ? color : null, |
||||
|
gradient: gradient, |
||||
|
border: border != null ? Border.all(color: border, width: 2) : null, |
||||
|
boxShadow: shadow != null |
||||
|
? [BoxShadow(color: shadow, blurRadius: 14, offset: const Offset(0, 4))] |
||||
|
: null, |
||||
|
), |
||||
|
child: Center(child: child), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// ══════════════════════════════════════════════════════════════════════════ |
||||
|
// 工具方法 |
||||
|
// ══════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
/// 渐变圆形头像 |
||||
|
Widget _gradientCircle(double size, String text, double fontSize) { |
||||
|
return Container( |
||||
|
width: size, |
||||
|
height: size, |
||||
|
decoration: const BoxDecoration( |
||||
|
shape: BoxShape.circle, |
||||
|
gradient: _avatarGradient, |
||||
|
), |
||||
|
child: Center( |
||||
|
child: Text(text, |
||||
|
style: TextStyle( |
||||
|
color: Colors.white, |
||||
|
fontSize: fontSize, |
||||
|
fontWeight: FontWeight.w600)), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
|
||||
|
String _fmtDuration(int secs) { |
||||
|
final m = (secs ~/ 60).toString().padLeft(2, '0'); |
||||
|
final s = (secs % 60).toString().padLeft(2, '0'); |
||||
|
return '$m:$s'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ═══════════════════════════════════════════════════════════════════════════ |
||||
|
// 辅助类 |
||||
|
// ═══════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
class _PillCfg { |
||||
|
final Color bg, fg; |
||||
|
final String label; |
||||
|
const _PillCfg(this.bg, this.fg, this.label); |
||||
|
} |
||||
|
|
||||
|
/// 3 条小波浪动画 |
||||
|
class _MiniWave extends StatefulWidget { |
||||
|
final Color color; |
||||
|
const _MiniWave({required this.color}); |
||||
|
|
||||
|
@override |
||||
|
State<_MiniWave> createState() => _MiniWaveState(); |
||||
|
} |
||||
|
|
||||
|
class _MiniWaveState extends State<_MiniWave> |
||||
|
with SingleTickerProviderStateMixin { |
||||
|
late final AnimationController _ctrl; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_ctrl = AnimationController( |
||||
|
vsync: this, duration: const Duration(milliseconds: 800)) |
||||
|
..repeat(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_ctrl.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return AnimatedBuilder( |
||||
|
animation: _ctrl, |
||||
|
builder: (_, __) => Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.center, |
||||
|
children: List.generate(3, (i) { |
||||
|
final phase = (_ctrl.value + i * 0.2) % 1.0; |
||||
|
final h = 3.0 + 7.0 * (0.5 - (phase - 0.5).abs()) * 2; |
||||
|
return Container( |
||||
|
width: 2.5, |
||||
|
height: h.clamp(3.0, 10.0), |
||||
|
margin: const EdgeInsets.symmetric(horizontal: 1), |
||||
|
decoration: BoxDecoration( |
||||
|
color: widget.color, |
||||
|
borderRadius: BorderRadius.circular(2), |
||||
|
), |
||||
|
); |
||||
|
}), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 麦克风音量指示器(通话模式用) |
||||
|
class _VolumeIndicator extends StatelessWidget { |
||||
|
final double rms; |
||||
|
final bool alive; |
||||
|
final bool isDark; |
||||
|
const _VolumeIndicator({required this.rms, required this.alive, required this.isDark}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
// rms 范围大约 0~6000,归一化到 0~1 |
||||
|
final level = alive ? (rms / 5000).clamp(0.0, 1.0) : 0.0; |
||||
|
const barCount = 5; |
||||
|
return Container( |
||||
|
width: 44.w, height: 44.w, |
||||
|
decoration: BoxDecoration( |
||||
|
shape: BoxShape.circle, |
||||
|
color: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFF2F4F7), |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
crossAxisAlignment: CrossAxisAlignment.center, |
||||
|
children: List.generate(barCount, (i) { |
||||
|
final threshold = (i + 1) / barCount; |
||||
|
final active = level >= threshold; |
||||
|
return Container( |
||||
|
width: 3.w, |
||||
|
height: (8 + i * 4).h, |
||||
|
margin: EdgeInsets.symmetric(horizontal: 1.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: active |
||||
|
? const Color(0xFF34C759) |
||||
|
: (isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD)), |
||||
|
borderRadius: BorderRadius.circular(1.5.r), |
||||
|
), |
||||
|
); |
||||
|
}), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// PTT 录音时响应麦克风音量的波形条 |
||||
|
class _RmsBars extends StatelessWidget { |
||||
|
final double rms; |
||||
|
final Color color; |
||||
|
const _RmsBars({required this.rms, required this.color}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
// rms 范围约 0~6000,归一化到 0~1 |
||||
|
final level = (rms / 5000).clamp(0.0, 1.0); |
||||
|
const barCount = 3; |
||||
|
return Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.center, |
||||
|
children: List.generate(barCount, (i) { |
||||
|
final threshold = (i + 1) / barCount; |
||||
|
final h = level >= threshold ? 10.0 : 3.0; |
||||
|
return AnimatedContainer( |
||||
|
duration: const Duration(milliseconds: 80), |
||||
|
width: 2.5, |
||||
|
height: h, |
||||
|
margin: const EdgeInsets.symmetric(horizontal: 1), |
||||
|
decoration: BoxDecoration( |
||||
|
color: color, |
||||
|
borderRadius: BorderRadius.circular(2), |
||||
|
), |
||||
|
); |
||||
|
}), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,935 @@ |
|||||
|
package com.yunqiinnovation.agent_service |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.media.AudioFormat |
||||
|
import android.media.AudioManager |
||||
|
import android.media.AudioRecord |
||||
|
import android.media.MediaPlayer |
||||
|
import android.media.MediaRecorder |
||||
|
import android.media.ToneGenerator |
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import android.util.Log |
||||
|
import com.yunqiinnovation.chat_storage.ChatStorageHelper |
||||
|
import kotlinx.coroutines.* |
||||
|
import okhttp3.* |
||||
|
import okhttp3.MediaType.Companion.toMediaType |
||||
|
import okhttp3.RequestBody.Companion.toRequestBody |
||||
|
import okio.ByteString.Companion.toByteString |
||||
|
import org.json.JSONArray |
||||
|
import org.json.JSONObject |
||||
|
import java.io.IOException |
||||
|
import java.security.MessageDigest |
||||
|
import java.security.SecureRandom |
||||
|
import java.security.cert.X509Certificate |
||||
|
import java.util.UUID |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
import javax.net.ssl.SSLContext |
||||
|
import javax.net.ssl.TrustManager |
||||
|
import javax.net.ssl.X509TrustManager |
||||
|
import kotlin.coroutines.resumeWithException |
||||
|
import kotlin.math.sqrt |
||||
|
|
||||
|
/** |
||||
|
* STS 配置参数(移动精灵 A80 平台) |
||||
|
*/ |
||||
|
data class StsConfig( |
||||
|
val productId: String, |
||||
|
val productKey: String, |
||||
|
var deviceNo: String, |
||||
|
var deviceId: String, |
||||
|
var deviceSecret: String, |
||||
|
val agentId: String, |
||||
|
val cmei: String = "", |
||||
|
) |
||||
|
|
||||
|
/** |
||||
|
* StsAgent — 中国移动 A80 WS 一体化语音对话实现 |
||||
|
* |
||||
|
* 通过一条 WSS 连接完成 ASR + AI + TTS,事件以与标准 stt_llm_tts 相同的格式 |
||||
|
* 通过 [onEvent] 回调发出,供 AgentService 转发到 Flutter EventChannel。 |
||||
|
*/ |
||||
|
class StsAgent( |
||||
|
private val context: Context, |
||||
|
private var config: StsConfig, |
||||
|
private val onEvent: (eventName: String, data: Map<String, Any>) -> Unit, |
||||
|
private val onCredentialsFetched: ((deviceId: String, deviceSecret: String, deviceNo: String) -> Unit)? = null, |
||||
|
) : CoroutineScope { |
||||
|
|
||||
|
private val TAG = "StsAgent" |
||||
|
private val job = SupervisorJob() |
||||
|
override val coroutineContext = Dispatchers.IO + job |
||||
|
|
||||
|
init { |
||||
|
Log.i(TAG, "[YIDONG] StsAgent初始化: productId=${config.productId} deviceNo=${config.deviceNo} deviceId=${config.deviceId} agentId=${config.agentId} cmei=${config.cmei} hasSecret=${config.deviceSecret.isNotEmpty()}") |
||||
|
fetchCredentials() |
||||
|
} |
||||
|
|
||||
|
/** 动态更新 CMEI(首次初始化时可能尚未获取到) */ |
||||
|
fun updateCmei(cmei: String) { |
||||
|
if (cmei.isNotEmpty() && cmei != config.cmei) { |
||||
|
config = config.copy(cmei = cmei) |
||||
|
Log.i(TAG, "[YIDONG] updateCmei: cmei已更新为 $cmei") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ── WebSocket ───────────────────────────────────────────────────────────── |
||||
|
private val okHttpClient: OkHttpClient = buildTrustAllClient() |
||||
|
private var webSocket: WebSocket? = null |
||||
|
private var readyReceived = false |
||||
|
private var sessionEnded = true |
||||
|
private var dialogRequestId = "" |
||||
|
|
||||
|
// ── 录音 ────────────────────────────────────────────────────────────────── |
||||
|
private val SAMPLE_RATE = 16000 |
||||
|
private val FRAME_SIZE = 5120 // 160ms @ 16kHz 16bit mono |
||||
|
private val RECORD_CHUNK = FRAME_SIZE / 2 // 读取块大小 |
||||
|
private val RECORD_BUFFER = maxOf( |
||||
|
AudioRecord.getMinBufferSize( |
||||
|
SAMPLE_RATE, |
||||
|
AudioFormat.CHANNEL_IN_MONO, |
||||
|
AudioFormat.ENCODING_PCM_16BIT |
||||
|
), FRAME_SIZE * 2 |
||||
|
) |
||||
|
private var audioRecord: AudioRecord? = null |
||||
|
private var recordingJob: Job? = null |
||||
|
private var micChunkCount = 0 |
||||
|
|
||||
|
// ── 发送缓冲 ────────────────────────────────────────────────────────────── |
||||
|
private val sendBuffer = ArrayDeque<Byte>() |
||||
|
private var sendBufferOffset = 0 |
||||
|
|
||||
|
// ── 预缓冲(VAD 触发前 ~1.5s)──────────────────────────────────────────── |
||||
|
private val preBuffer = ArrayDeque<ByteArray>() |
||||
|
private val preBufferLock = Any() |
||||
|
|
||||
|
// ── 麦克风读写锁(防止 read() 与 stop()/release() 并发导致 native SIGABRT)── |
||||
|
private val micLock = Any() |
||||
|
private val PRE_BUFFER_MAX_CHUNKS = 75 |
||||
|
|
||||
|
// ── 本地 VAD ────────────────────────────────────────────────────────────── |
||||
|
private var speechDetected = false |
||||
|
private val VAD_THRESHOLD = 800.0 |
||||
|
private val VAD_ONSET_CHUNKS = 3 |
||||
|
private var vadOnsetCount = 0 |
||||
|
|
||||
|
// ── 状态标记 ────────────────────────────────────────────────────────────── |
||||
|
private var forwardingAudio = false |
||||
|
private var wsConnecting = false |
||||
|
private var inCall = false |
||||
|
private var pttRecording = false |
||||
|
|
||||
|
// ── TTS 播放队列 ────────────────────────────────────────────────────────── |
||||
|
private val ttsQueue = ArrayDeque<String>() |
||||
|
private var ttsPlaying = false |
||||
|
private var ttsInterrupted = false |
||||
|
private var currentPlayer: MediaPlayer? = null |
||||
|
private val mainHandler = Handler(Looper.getMainLooper()) |
||||
|
/** 静音开关:设为 true 时停止当前播放、跳过后续 TTS,文本接收完后主动关闭连接 */ |
||||
|
var ttsMuted = false |
||||
|
set(value) { |
||||
|
field = value |
||||
|
if (value) { |
||||
|
// 立即停止当前播放 |
||||
|
ttsQueue.clear() |
||||
|
ttsPlaying = false |
||||
|
mainHandler.post { |
||||
|
try { currentPlayer?.stop(); currentPlayer?.release() } catch (_: Exception) {} |
||||
|
currentPlayer = null |
||||
|
} |
||||
|
emit("tts_canceled", emptyMap()) |
||||
|
// 如果会话已结束(文本已全部接收),主动关闭连接 |
||||
|
if (sessionEnded) { |
||||
|
closeWs() |
||||
|
if (inCall) { resetVad(); startIdleTimer() } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ── 消息历史(WS 上下文) ────────────────────────────────────────────────── |
||||
|
private var messages = JSONArray() |
||||
|
private val chatStorageHelper = ChatStorageHelper(context) |
||||
|
private var currentSessionId = "" |
||||
|
private var pendingAnswer = "" // 累积最完整的回复文本,会话结束时统一发送 |
||||
|
|
||||
|
// ── 计时器 ──────────────────────────────────────────────────────────────── |
||||
|
private var micWatchdogJob: Job? = null |
||||
|
private var lastWatchdogChunkCount = 0 |
||||
|
private var idleTimerJob: Job? = null |
||||
|
private val IDLE_TIMEOUT_MS = 15_000L |
||||
|
|
||||
|
// ── 凭证就绪 ────────────────────────────────────────────────────────────── |
||||
|
var credentialReady = false |
||||
|
private set |
||||
|
|
||||
|
// ── 心跳冷却 ────────────────────────────────────────────────────────────── |
||||
|
private val HEARTBEAT_COOLDOWN_MS = 3_600_000L // 1 小时 |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 凭证获取 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
fun fetchCredentials() { |
||||
|
if (config.deviceSecret.isNotEmpty()) { |
||||
|
credentialReady = true |
||||
|
Log.i(TAG, "[YIDONG] fetchCredentials: 凭证已存在,跳过请求 deviceNo=${config.deviceNo} deviceId=${config.deviceId}") |
||||
|
reportHeartbeat() // 凭证已有,直接上报心跳 |
||||
|
return |
||||
|
} |
||||
|
Log.i(TAG, "[YIDONG] fetchCredentials: 开始请求密钥 productId=${config.productId} deviceNo=${config.deviceNo}") |
||||
|
launch { |
||||
|
try { |
||||
|
val body = JSONObject().apply { |
||||
|
put("deviceNoType", "SN") |
||||
|
put("deviceNo", config.deviceNo) |
||||
|
put("productId", config.productId) |
||||
|
put("productKey", config.productKey) |
||||
|
}.toString().toRequestBody("application/json".toMediaType()) |
||||
|
|
||||
|
val req = Request.Builder() |
||||
|
.url("https://ivs.chinamobiledevice.com:11443/v2/customer/device/secret/info") |
||||
|
.post(body) |
||||
|
.build() |
||||
|
|
||||
|
okHttpClient.newCall(req).execute().use { resp -> |
||||
|
val json = JSONObject(resp.body!!.string()) |
||||
|
if (json.getString("code") == "200" && json.getBoolean("success")) { |
||||
|
val d = json.getJSONObject("data") |
||||
|
config = config.copy( |
||||
|
deviceNo = d.optString("deviceNo", config.deviceNo), |
||||
|
deviceId = d.optString("deviceId", ""), |
||||
|
deviceSecret = d.optString("deviceSecret", ""), |
||||
|
) |
||||
|
credentialReady = config.deviceSecret.isNotEmpty() |
||||
|
if (credentialReady) { |
||||
|
Log.i(TAG, "[YIDONG] fetchCredentials: 密钥获取成功 deviceId=${config.deviceId} deviceNo=${config.deviceNo} cmei=${config.cmei}") |
||||
|
onCredentialsFetched?.invoke(config.deviceId, config.deviceSecret, config.deviceNo) |
||||
|
reportHeartbeat() // 凭证获取成功后立即上报心跳 |
||||
|
} else { |
||||
|
Log.e(TAG, "[YIDONG] fetchCredentials: 响应成功但deviceSecret为空") |
||||
|
emit("credential_failed", mapOf("reason" to "设备未认证,deviceSecret为空")) |
||||
|
} |
||||
|
} else { |
||||
|
val msg = json.optString("message", "未知错误") |
||||
|
Log.e(TAG, "[YIDONG] fetchCredentials: 密钥获取失败 code=${json.optString("code")} msg=$msg") |
||||
|
emit("credential_failed", mapOf("reason" to "设备未认证:$msg")) |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "[YIDONG] fetchCredentials: 请求异常 ${e.message}") |
||||
|
emit("credential_failed", mapOf("reason" to "设备认证请求失败:${e.message}")) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 向中国移动平台上报心跳 */ |
||||
|
fun reportHeartbeat() { |
||||
|
if (!credentialReady) { |
||||
|
Log.w(TAG, "[YIDONG] reportHeartbeat: 凭证未就绪,跳过上报") |
||||
|
return |
||||
|
} |
||||
|
// 1 小时冷却:StsAgent 会被反复重建,init/fetchCredentials 都会调到这里, |
||||
|
// 必须在原生层拦,否则每次重建都会打一发心跳 |
||||
|
val prefs = context.getSharedPreferences("sts_agent", Context.MODE_PRIVATE) |
||||
|
val last = prefs.getLong("last_heartbeat_ts", 0L) |
||||
|
val now = System.currentTimeMillis() |
||||
|
if (last > 0L && now - last < HEARTBEAT_COOLDOWN_MS) { |
||||
|
Log.i(TAG, "[YIDONG] reportHeartbeat: 距上次 ${(now - last) / 1000}s,冷却中跳过") |
||||
|
return |
||||
|
} |
||||
|
prefs.edit().putLong("last_heartbeat_ts", now).apply() |
||||
|
Log.i(TAG, "[YIDONG] reportHeartbeat: 开始上报 deviceId=${config.deviceId} deviceNo=${config.deviceNo} cmei=${config.cmei}") |
||||
|
launch { |
||||
|
try { |
||||
|
val netType = try { |
||||
|
val cm = context.getSystemService(android.content.Context.CONNECTIVITY_SERVICE) |
||||
|
as android.net.ConnectivityManager |
||||
|
val net = cm.activeNetworkInfo |
||||
|
when (net?.type) { |
||||
|
android.net.ConnectivityManager.TYPE_WIFI -> "WiFi" |
||||
|
android.net.ConnectivityManager.TYPE_MOBILE -> "5G" |
||||
|
else -> "WiFi" |
||||
|
} |
||||
|
} catch (_: Exception) { "WiFi" } |
||||
|
|
||||
|
val body = JSONObject().apply { |
||||
|
put("deviceId", config.deviceId) |
||||
|
put("deviceSecret", config.deviceSecret) |
||||
|
put("productId", config.productId) |
||||
|
put("productKey", config.productKey) |
||||
|
put("params", JSONObject().apply { |
||||
|
put("innerIp", JSONArray().put("127.0.0.1")) |
||||
|
put("netSpeed", "0Mbps") |
||||
|
put("netType", netType) |
||||
|
put("platform", "Android${android.os.Build.VERSION.RELEASE}") |
||||
|
put("sdkVersion", "ai_http_1.0") |
||||
|
put("firmwareVersion", "1.0.0") |
||||
|
put("imei", "") |
||||
|
put("cmei", config.cmei) // 优先使用 UserDevice 的 CMEI |
||||
|
put("mac", "") |
||||
|
}) |
||||
|
}.toString().toRequestBody("application/json".toMediaType()) |
||||
|
|
||||
|
val req = Request.Builder() |
||||
|
.url("https://ivs.chinamobiledevice.com:11443/v2/customer/device/report") |
||||
|
.post(body) |
||||
|
.build() |
||||
|
|
||||
|
okHttpClient.newCall(req).execute().use { resp -> |
||||
|
val json = JSONObject(resp.body!!.string()) |
||||
|
val code = json.optString("code") |
||||
|
val msg = json.optString("message") |
||||
|
if (code == "200") { |
||||
|
Log.i(TAG, "[YIDONG] reportHeartbeat: 上报成功 code=$code msg=$msg") |
||||
|
emit("heartbeat_reported", mapOf("time" to System.currentTimeMillis())) |
||||
|
} else { |
||||
|
Log.e(TAG, "[YIDONG] reportHeartbeat: 上报失败 code=$code msg=$msg") |
||||
|
// 上报失败回滚时间戳,避免卡 1 小时无法重试 |
||||
|
prefs.edit().putLong("last_heartbeat_ts", last).apply() |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "[YIDONG] reportHeartbeat: 请求异常 ${e.message}") |
||||
|
// 异常同样回滚时间戳 |
||||
|
prefs.edit().putLong("last_heartbeat_ts", last).apply() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 公开接口 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
/** 通话模式:开启麦克风,VAD 自动触发 WS */ |
||||
|
fun startCall() { |
||||
|
if (inCall) return |
||||
|
if (ttsPlaying) interruptTts() |
||||
|
if (pttRecording) stopPtt(cancel = true) |
||||
|
if (!credentialReady) { emitError("移动精灵凭证未就绪,请稍后重试"); return } |
||||
|
Log.i(TAG, "[YIDONG] startCall: 启动通话模式 deviceNo=${config.deviceNo} agentId=${config.agentId}") |
||||
|
openMic() |
||||
|
startMicWatchdog() |
||||
|
inCall = true |
||||
|
startIdleTimer() |
||||
|
} |
||||
|
|
||||
|
/** PTT 模式:立即连 WS 并开始录音 */ |
||||
|
fun startPtt() { |
||||
|
if (pttRecording) { |
||||
|
if (forwardingAudio) return // 真正在录音中,忽略重复调用 |
||||
|
// forwardingAudio=false:WS 已关闭,正处于 TTS 播报等待阶段(如 BLE 唤醒等待 AI 回复期间) |
||||
|
// 调用方(BleAgent)已通过 interruptCurrentResponse() 中断了 TTS, |
||||
|
// 此处只做静默状态重置,不再重复发出 tts_canceled 事件 |
||||
|
Log.i(TAG, "[YIDONG] startPtt: 静默重置旧会话,重新启动") |
||||
|
stopMicHard() |
||||
|
sessionEnded = false |
||||
|
pttRecording = false |
||||
|
} |
||||
|
if (ttsPlaying) interruptTts() |
||||
|
if (!credentialReady) { emitError("移动精灵凭证未就绪"); return } |
||||
|
Log.i(TAG, "[YIDONG] startPtt: 启动PTT模式 deviceNo=${config.deviceNo} agentId=${config.agentId}") |
||||
|
|
||||
|
pttRecording = true |
||||
|
openMic() |
||||
|
startMicWatchdog() |
||||
|
ttsInterrupted = false |
||||
|
closeWs() |
||||
|
sessionEnded = false |
||||
|
wsConnecting = true |
||||
|
dialogRequestId = UUID.randomUUID().toString() |
||||
|
clearSendBuffer() |
||||
|
pendingAnswer = "" |
||||
|
|
||||
|
launch { |
||||
|
try { |
||||
|
connectWs() |
||||
|
forwardingAudio = true |
||||
|
wsConnecting = false |
||||
|
emit("recognition_started", emptyMap()) |
||||
|
} catch (e: Exception) { |
||||
|
wsConnecting = false |
||||
|
pttRecording = false |
||||
|
stopMicWatchdog() |
||||
|
emitError("连接失败: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** PTT 松开:发送剩余音频 + finish */ |
||||
|
fun stopPtt(cancel: Boolean = false) { |
||||
|
pttRecording = false |
||||
|
stopMicWatchdog() |
||||
|
if (cancel) { |
||||
|
forwardingAudio = false |
||||
|
closeWs() |
||||
|
stopMicHard() |
||||
|
return |
||||
|
} |
||||
|
if (forwardingAudio) { |
||||
|
forwardingAudio = false |
||||
|
synchronized(sendBufferLock) { |
||||
|
val remain = sendBuffer.size - sendBufferOffset |
||||
|
if (remain > 0) { |
||||
|
sendAudioChunk(sendBuffer.toMutableList().subList(sendBufferOffset, sendBuffer.size).toByteArray()) |
||||
|
} |
||||
|
sendBuffer.clear(); sendBufferOffset = 0 |
||||
|
} |
||||
|
sendFinish() |
||||
|
emit("recognition_stopped", emptyMap()) |
||||
|
} |
||||
|
stopMicHard() |
||||
|
} |
||||
|
|
||||
|
/** 统一停止:根据当前状态自动选择 stopPtt 或 disconnect */ |
||||
|
fun stop() { |
||||
|
if (pttRecording) stopPtt() |
||||
|
else disconnect() |
||||
|
} |
||||
|
|
||||
|
/** 挂断通话 */ |
||||
|
fun disconnect() { |
||||
|
stopMicWatchdog() |
||||
|
cancelIdleTimer() |
||||
|
forwardingAudio = false |
||||
|
wsConnecting = false |
||||
|
speechDetected = false |
||||
|
stopMicHard() |
||||
|
ttsInterrupted = true |
||||
|
ttsQueue.clear() |
||||
|
ttsPlaying = false |
||||
|
mainHandler.post { |
||||
|
try { currentPlayer?.stop(); currentPlayer?.release() } catch (_: Exception) {} |
||||
|
currentPlayer = null |
||||
|
} |
||||
|
closeWs() |
||||
|
synchronized(preBufferLock) { preBuffer.clear() } |
||||
|
clearSendBuffer() |
||||
|
flushPendingAnswer() |
||||
|
inCall = false |
||||
|
pttRecording = false |
||||
|
} |
||||
|
|
||||
|
/** 打断 AI 播报 */ |
||||
|
fun interruptSpeaking() { |
||||
|
interruptTts() |
||||
|
ttsInterrupted = false |
||||
|
if (inCall) resetVad() |
||||
|
} |
||||
|
|
||||
|
/** 清空消息历史 */ |
||||
|
fun clearHistory() { |
||||
|
messages = JSONArray() |
||||
|
} |
||||
|
|
||||
|
/** 释放资源 */ |
||||
|
fun dispose() { |
||||
|
disconnect() |
||||
|
job.cancel() |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 麦克风 & 本地 VAD |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private fun openMic() { |
||||
|
stopMicHard() |
||||
|
speechDetected = false; vadOnsetCount = 0; micChunkCount = 0 |
||||
|
try { |
||||
|
audioRecord = AudioRecord( |
||||
|
MediaRecorder.AudioSource.VOICE_COMMUNICATION, |
||||
|
SAMPLE_RATE, |
||||
|
AudioFormat.CHANNEL_IN_MONO, |
||||
|
AudioFormat.ENCODING_PCM_16BIT, |
||||
|
RECORD_BUFFER |
||||
|
) |
||||
|
audioRecord?.startRecording() |
||||
|
recordingJob = launch { |
||||
|
val buf = ByteArray(RECORD_CHUNK) |
||||
|
while (isActive && (inCall || pttRecording)) { |
||||
|
// 使用非阻塞读 + micLock,确保与 stopMicHard() 中的 stop()/release() 互斥。 |
||||
|
// READ_NON_BLOCKING 让 read() 立即返回(微秒级),锁持有时间极短, |
||||
|
// 彻底消除 releaseBuffer 并发导致的 native SIGABRT。 |
||||
|
val read = synchronized(micLock) { |
||||
|
audioRecord?.read(buf, 0, buf.size, AudioRecord.READ_NON_BLOCKING) ?: -1 |
||||
|
} |
||||
|
if (read < 0) break |
||||
|
if (read > 0) { micChunkCount++; onMicAudio(buf.copyOf(read)) } |
||||
|
else delay(5) // 无数据时释放协程让出线程,避免忙等 |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "打开麦克风失败: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun stopMicHard() { |
||||
|
recordingJob?.cancel(); recordingJob = null |
||||
|
// 拿 micLock 后再 stop()/release(),与录音循环的 read() 互斥, |
||||
|
// 保证 stop() 永远不会在 read() 内部 releaseBuffer 期间被调用。 |
||||
|
synchronized(micLock) { |
||||
|
val ar = audioRecord |
||||
|
audioRecord = null |
||||
|
try { ar?.stop(); ar?.release() } catch (_: Exception) {} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun startMicWatchdog() { |
||||
|
stopMicWatchdog() |
||||
|
lastWatchdogChunkCount = micChunkCount |
||||
|
micWatchdogJob = launch { |
||||
|
while (isActive && (inCall || pttRecording)) { |
||||
|
delay(1500) |
||||
|
if (micChunkCount == lastWatchdogChunkCount && !ttsPlaying) openMic() |
||||
|
lastWatchdogChunkCount = micChunkCount |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun stopMicWatchdog() { micWatchdogJob?.cancel(); micWatchdogJob = null } |
||||
|
|
||||
|
private fun onMicAudio(chunk: ByteArray) { |
||||
|
if (!inCall && !pttRecording) return |
||||
|
if (forwardingAudio && webSocket != null) { |
||||
|
if (micChunkCount % 3 == 0) emit("mic_rms", mapOf("rms" to calcRms(chunk))) |
||||
|
feedSendBuffer(chunk); return |
||||
|
} |
||||
|
if (wsConnecting) { addToPreBuffer(chunk); return } |
||||
|
if (ttsPlaying) return |
||||
|
|
||||
|
addToPreBuffer(chunk) |
||||
|
val rms = calcRms(chunk) |
||||
|
// 发送麦克风音量给 Dart 层 |
||||
|
if (micChunkCount % 3 == 0) { |
||||
|
emit("mic_rms", mapOf("rms" to rms)) |
||||
|
} |
||||
|
if (rms >= VAD_THRESHOLD) { |
||||
|
vadOnsetCount++ |
||||
|
if (vadOnsetCount >= VAD_ONSET_CHUNKS && !speechDetected) { |
||||
|
speechDetected = true |
||||
|
cancelIdleTimer() |
||||
|
launch { onSpeechDetected() } |
||||
|
} |
||||
|
} else { |
||||
|
vadOnsetCount = 0 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun calcRms(pcm: ByteArray): Double { |
||||
|
if (pcm.size < 2) return 0.0 |
||||
|
val samples = pcm.size / 2 |
||||
|
var sum = 0.0 |
||||
|
for (i in 0 until pcm.size - 1 step 2) { |
||||
|
var s = (pcm[i].toInt() and 0xFF) or (pcm[i + 1].toInt() shl 8) |
||||
|
if (s >= 32768) s -= 65536 |
||||
|
sum += s.toLong() * s.toLong() |
||||
|
} |
||||
|
return sqrt(sum / samples) |
||||
|
} |
||||
|
|
||||
|
private fun addToPreBuffer(chunk: ByteArray) { |
||||
|
synchronized(preBufferLock) { |
||||
|
preBuffer.addLast(chunk.copyOf()) |
||||
|
while (preBuffer.size > PRE_BUFFER_MAX_CHUNKS) preBuffer.removeFirst() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// VAD 触发 → WS 连接 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private suspend fun onSpeechDetected() { |
||||
|
if (wsConnecting) return |
||||
|
ttsInterrupted = false |
||||
|
closeWs() |
||||
|
sessionEnded = false |
||||
|
wsConnecting = true |
||||
|
dialogRequestId = UUID.randomUUID().toString() |
||||
|
clearSendBuffer() |
||||
|
pendingAnswer = "" |
||||
|
|
||||
|
try { |
||||
|
connectWs() |
||||
|
} catch (e: Exception) { |
||||
|
wsConnecting = false; speechDetected = false |
||||
|
emitError("连接失败: ${e.message}") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
forwardingAudio = true; wsConnecting = false |
||||
|
playReadyBeep() |
||||
|
emit("recognition_started", emptyMap()) |
||||
|
|
||||
|
val cached = synchronized(preBufferLock) { preBuffer.toList().also { preBuffer.clear() } } |
||||
|
for (c in cached) feedSendBuffer(c) |
||||
|
} |
||||
|
|
||||
|
/** 唤醒成功提示音:通知用户可以说话了 */ |
||||
|
private fun playReadyBeep() { |
||||
|
try { |
||||
|
val toneGen = ToneGenerator(AudioManager.STREAM_MUSIC, 60) |
||||
|
toneGen.startTone(ToneGenerator.TONE_PROP_BEEP, 180) |
||||
|
mainHandler.postDelayed({ toneGen.release() }, 300) |
||||
|
} catch (e: Exception) { |
||||
|
Log.w(TAG, "playReadyBeep failed: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// WebSocket |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private suspend fun connectWs(): Unit = suspendCancellableCoroutine { cont -> |
||||
|
val ts = System.currentTimeMillis() |
||||
|
val sign = calcSign(config.deviceSecret, ts.toString()) |
||||
|
val sn = UUID.randomUUID().toString() |
||||
|
val url = "wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr" + |
||||
|
"?deviceNo=${config.deviceNo}&deviceId=${config.deviceId}" + |
||||
|
"&productId=${config.productId}&productKey=${config.productKey}" + |
||||
|
"&sign=$sign&sn=$sn&ts=$ts" |
||||
|
Log.i(TAG, "[YIDONG] connectWs: 连接WSS deviceNo=${config.deviceNo} deviceId=${config.deviceId} sn=$sn") |
||||
|
|
||||
|
val req = Request.Builder().url(url) |
||||
|
.header("Origin", "https://ivs.chinamobiledevice.com:11443") |
||||
|
.header("Host", "ivs.chinamobiledevice.com:11443") |
||||
|
.build() |
||||
|
|
||||
|
var resolved = false |
||||
|
|
||||
|
webSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() { |
||||
|
override fun onOpen(ws: WebSocket, response: Response) { |
||||
|
sendStartFrame() |
||||
|
} |
||||
|
|
||||
|
override fun onMessage(ws: WebSocket, text: String) { |
||||
|
try { |
||||
|
val msg = JSONObject(text) |
||||
|
if (msg.optString("type") == "ready") { |
||||
|
Log.i(TAG, "[YIDONG] connectWs: 收到ready,WSS连接成功") |
||||
|
if (!resolved) { resolved = true; cont.resume(Unit) {} } |
||||
|
} else { |
||||
|
launch { handleMessage(msg) } |
||||
|
} |
||||
|
} catch (_: Exception) {} |
||||
|
} |
||||
|
|
||||
|
override fun onMessage(ws: WebSocket, bytes: okio.ByteString) {} |
||||
|
|
||||
|
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { |
||||
|
Log.e(TAG, "[YIDONG] connectWs: WSS连接失败 ${t.message}") |
||||
|
if (webSocket === ws) webSocket = null // 只清除自己的引用,避免覆盖新 WS |
||||
|
if (!resolved) { resolved = true; cont.resumeWithException(t) } |
||||
|
else launch { handleWsDone() } |
||||
|
} |
||||
|
|
||||
|
override fun onClosing(ws: WebSocket, code: Int, reason: String) { ws.close(1000, null) } |
||||
|
|
||||
|
override fun onClosed(ws: WebSocket, code: Int, reason: String) { |
||||
|
if (webSocket === ws) webSocket = null // 只清除自己的引用,避免覆盖新 WS |
||||
|
if (!resolved) { resolved = true; cont.resumeWithException(IOException("WS closed")) } |
||||
|
else launch { handleWsDone() } |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
cont.invokeOnCancellation { closeWs() } |
||||
|
|
||||
|
// 15s 超时 |
||||
|
launch { |
||||
|
delay(15_000) |
||||
|
if (!resolved) { |
||||
|
resolved = true; closeWs() |
||||
|
cont.resumeWithException(IOException("等待 ready 超时")) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun handleWsDone() { |
||||
|
forwardingAudio = false; wsConnecting = false |
||||
|
when { |
||||
|
sessionEnded && inCall -> if (!ttsPlaying) { resetVad(); startIdleTimer() } |
||||
|
sessionEnded -> { |
||||
|
// PTT 结束;若无 TTS 排队或播放(如无语音输入场景),立即补发 tts_completed |
||||
|
// 避免 Flutter 侧 _pttResultPending 永久无法清除导致按钮锁死 |
||||
|
if (!ttsPlaying && ttsQueue.isEmpty()) emit("tts_completed", emptyMap()) |
||||
|
} |
||||
|
inCall -> resetVad() |
||||
|
// WS 意外关闭(session 未正常结束,如极速松手音频未发出),同样补发保底 |
||||
|
else -> if (!ttsPlaying && ttsQueue.isEmpty() && !ttsInterrupted) emit("tts_completed", emptyMap()) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun resetVad() { |
||||
|
speechDetected = false; vadOnsetCount = 0 |
||||
|
synchronized(preBufferLock) { preBuffer.clear() } |
||||
|
openMic() |
||||
|
} |
||||
|
|
||||
|
private fun closeWs() { |
||||
|
try { webSocket?.cancel() } catch (_: Exception) {} |
||||
|
webSocket = null |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 发送帧 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private fun sendStartFrame() { |
||||
|
currentSessionId = UUID.randomUUID().toString() |
||||
|
val payload = JSONObject().apply { |
||||
|
put("type", "start") |
||||
|
put("data", JSONObject().apply { |
||||
|
put("cuid", config.deviceId) |
||||
|
put("format", "pcm") |
||||
|
put("sample", 16000) |
||||
|
put("support_dcs", 2) |
||||
|
put("chunk_size", 10240) |
||||
|
put("support_tts", true) |
||||
|
put("support_text2dcs", true) |
||||
|
put("user_agent", "voitrans/1.0") |
||||
|
put("dialog_request_id", dialogRequestId) |
||||
|
put("access_rc", true) |
||||
|
put("support_part_tts", true) |
||||
|
put("need_stoplisten", true) |
||||
|
put("need_dialogue_finish", true) |
||||
|
put("result_trans2directive", false) |
||||
|
put("rc_version", "5") |
||||
|
put("messages", messages) |
||||
|
put("client_context", JSONArray()) |
||||
|
}) |
||||
|
} |
||||
|
webSocket?.send(payload.toString()) |
||||
|
} |
||||
|
|
||||
|
private val sendBufferLock = Any() |
||||
|
|
||||
|
private fun clearSendBuffer() { |
||||
|
synchronized(sendBufferLock) { |
||||
|
sendBuffer.clear(); sendBufferOffset = 0 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun feedSendBuffer(chunk: ByteArray) { |
||||
|
synchronized(sendBufferLock) { |
||||
|
sendBuffer.addAll(chunk.toList()) |
||||
|
while (sendBuffer.size - sendBufferOffset >= FRAME_SIZE) { |
||||
|
val frame = ByteArray(FRAME_SIZE) { i -> sendBuffer[sendBufferOffset + i] } |
||||
|
sendBufferOffset += FRAME_SIZE |
||||
|
sendAudioChunk(frame) |
||||
|
} |
||||
|
// 收缩缓冲 |
||||
|
if (sendBufferOffset >= FRAME_SIZE * 8) { |
||||
|
val toRemove = sendBufferOffset |
||||
|
repeat(toRemove) { if (sendBuffer.isNotEmpty()) sendBuffer.removeFirst() } |
||||
|
sendBufferOffset = 0 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun sendAudioChunk(data: ByteArray) { |
||||
|
try { webSocket?.send(data.toByteString()) } catch (_: Exception) {} |
||||
|
} |
||||
|
|
||||
|
private fun sendFinish() { |
||||
|
try { webSocket?.send("""{"type":"finish"}""") } catch (_: Exception) {} |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 消息处理 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private suspend fun handleMessage(msg: JSONObject) { |
||||
|
when (msg.optString("type")) { |
||||
|
"stoplisten" -> { |
||||
|
forwardingAudio = false; speechDetected = false; vadOnsetCount = 0 |
||||
|
synchronized(sendBufferLock) { |
||||
|
val remain = sendBuffer.size - sendBufferOffset |
||||
|
if (remain > 0) { |
||||
|
sendAudioChunk(ByteArray(remain) { i -> sendBuffer[sendBufferOffset + i] }) |
||||
|
} |
||||
|
sendBuffer.clear(); sendBufferOffset = 0 |
||||
|
} |
||||
|
sendFinish() |
||||
|
emit("recognition_stopped", emptyMap()) |
||||
|
} |
||||
|
"mid_result" -> { |
||||
|
val text = msg.optString("result", "") |
||||
|
if (text.isNotEmpty()) emit("recognizing", mapOf("text" to text)) |
||||
|
} |
||||
|
"fin_result" -> { |
||||
|
val result = msg.optString("result", "") |
||||
|
if (result.isNotEmpty()) { |
||||
|
emit("recognition_result", mapOf("text" to result)) |
||||
|
messages.put(JSONObject(mapOf("role" to "user", "content" to result))) |
||||
|
withContext(Dispatchers.IO) { |
||||
|
try { |
||||
|
chatStorageHelper.saveMessage(config.agentId, currentSessionId, result, "user") |
||||
|
} catch (_: Exception) {} |
||||
|
} |
||||
|
} |
||||
|
if (msg.optInt("end", 0) == 1) markSessionEnded() |
||||
|
} |
||||
|
"inside_rc" -> handleInsideRc(msg) |
||||
|
"dcs_decide" -> if (msg.optInt("end", 0) == 1) markSessionEnded() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private suspend fun handleInsideRc(msg: JSONObject) { |
||||
|
val data = msg.optJSONObject("data") ?: return |
||||
|
if (data.optInt("code", -1) != 0) return |
||||
|
|
||||
|
val rawAnswer = data.optString("assistant_answer", "") |
||||
|
val cleanAnswer = try { |
||||
|
JSONObject(rawAnswer).optString("content", rawAnswer) |
||||
|
} catch (_: Exception) { rawAnswer } |
||||
|
|
||||
|
var ttsUrl: String? = null |
||||
|
val items = data.optJSONArray("data") |
||||
|
if (items != null) { |
||||
|
for (i in 0 until items.length()) { |
||||
|
val item = items.optJSONObject(i) ?: continue |
||||
|
val h = item.optJSONObject("header") ?: continue |
||||
|
if (h.optString("namespace") == "ai.fxzsos.device_interface.voice_output" && |
||||
|
h.optString("name") == "Speak" |
||||
|
) { |
||||
|
ttsUrl = item.optJSONObject("payload")?.optString("url") |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 每次取更完整的回复文本,立即发送给 UI |
||||
|
if (cleanAnswer.isNotEmpty() && cleanAnswer.length > pendingAnswer.length) { |
||||
|
pendingAnswer = cleanAnswer |
||||
|
emit("assistant_response", mapOf("response" to cleanAnswer, "sessionid" to currentSessionId)) |
||||
|
} |
||||
|
ttsUrl?.takeIf { it.isNotEmpty() }?.let { enqueueTts(it) } |
||||
|
if (msg.optInt("end", 0) == 1) markSessionEnded() |
||||
|
} |
||||
|
|
||||
|
/** 将 pendingAnswer 保存到历史和存储,并清空 */ |
||||
|
private fun flushPendingAnswer() { |
||||
|
if (pendingAnswer.isEmpty()) return |
||||
|
val answer = pendingAnswer |
||||
|
val sid = currentSessionId |
||||
|
messages.put(JSONObject(mapOf("role" to "assistant", "content" to answer))) |
||||
|
launch(Dispatchers.IO) { |
||||
|
try { |
||||
|
chatStorageHelper.saveMessage(config.agentId, sid, answer, "assistant") |
||||
|
} catch (_: Exception) {} |
||||
|
} |
||||
|
pendingAnswer = "" |
||||
|
} |
||||
|
|
||||
|
private fun markSessionEnded() { |
||||
|
if (sessionEnded) return |
||||
|
sessionEnded = true; forwardingAudio = false; speechDetected = false; vadOnsetCount = 0 |
||||
|
clearSendBuffer() |
||||
|
flushPendingAnswer() |
||||
|
// 静音模式下文本接收完毕,发送完成事件并关闭连接 |
||||
|
if (ttsMuted) { |
||||
|
emit("tts_completed", emptyMap()) |
||||
|
closeWs() |
||||
|
if (inCall) { resetVad(); startIdleTimer() } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// TTS 播放队列 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private fun enqueueTts(url: String) { |
||||
|
if (ttsMuted) return // 静音模式跳过 TTS |
||||
|
ttsQueue.addLast(url) |
||||
|
if (!ttsPlaying) { |
||||
|
stopMicHard() |
||||
|
mainHandler.post { playNextTts() } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun playNextTts() { |
||||
|
if (ttsQueue.isEmpty() || ttsInterrupted) { |
||||
|
ttsPlaying = false |
||||
|
if (!ttsInterrupted) { |
||||
|
emit("tts_completed", emptyMap()) |
||||
|
if (inCall) { resetVad(); startIdleTimer() } |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
ttsPlaying = true |
||||
|
emit("tts_started", emptyMap()) |
||||
|
val url = ttsQueue.removeFirst() |
||||
|
try { |
||||
|
currentPlayer?.stop(); currentPlayer?.release() |
||||
|
currentPlayer = MediaPlayer().apply { |
||||
|
setDataSource(url) |
||||
|
setOnCompletionListener { |
||||
|
if (!ttsInterrupted) mainHandler.post { playNextTts() } |
||||
|
} |
||||
|
setOnErrorListener { _, _, _ -> |
||||
|
if (!ttsInterrupted) mainHandler.post { playNextTts() } |
||||
|
true |
||||
|
} |
||||
|
prepare() |
||||
|
start() |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "TTS 播放失败: ${e.message}") |
||||
|
if (!ttsInterrupted) mainHandler.post { playNextTts() } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun interruptTts() { |
||||
|
ttsInterrupted = true; ttsQueue.clear(); ttsPlaying = false |
||||
|
mainHandler.post { |
||||
|
try { currentPlayer?.stop(); currentPlayer?.release() } catch (_: Exception) {} |
||||
|
currentPlayer = null |
||||
|
} |
||||
|
emit("tts_canceled", emptyMap()) |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 超时 & 工具 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private fun startIdleTimer() { |
||||
|
cancelIdleTimer() |
||||
|
idleTimerJob = launch { |
||||
|
delay(IDLE_TIMEOUT_MS) |
||||
|
if (inCall && !speechDetected) disconnect() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun cancelIdleTimer() { idleTimerJob?.cancel(); idleTimerJob = null } |
||||
|
|
||||
|
private fun calcSign(secret: String, ts: String): String { |
||||
|
val digest = MessageDigest.getInstance("MD5").digest("$secret$ts".toByteArray()) |
||||
|
return digest.joinToString("") { "%02x".format(it) } |
||||
|
} |
||||
|
|
||||
|
private fun emit(eventName: String, data: Map<String, Any>) { |
||||
|
launch(Dispatchers.Main) { onEvent(eventName, data) } |
||||
|
} |
||||
|
|
||||
|
private fun emitError(msg: String) = emit("error", mapOf("message" to msg)) |
||||
|
|
||||
|
private fun buildTrustAllClient(): OkHttpClient { |
||||
|
return try { |
||||
|
val trustAll = arrayOf<TrustManager>(object : X509TrustManager { |
||||
|
override fun checkClientTrusted(c: Array<X509Certificate>, t: String) {} |
||||
|
override fun checkServerTrusted(c: Array<X509Certificate>, t: String) {} |
||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray() |
||||
|
}) |
||||
|
val sc = SSLContext.getInstance("TLS") |
||||
|
sc.init(null, trustAll, SecureRandom()) |
||||
|
OkHttpClient.Builder() |
||||
|
.sslSocketFactory(sc.socketFactory, trustAll[0] as X509TrustManager) |
||||
|
.hostnameVerifier { _, _ -> true } |
||||
|
.connectTimeout(15, TimeUnit.SECONDS) |
||||
|
.readTimeout(90, TimeUnit.SECONDS) |
||||
|
.writeTimeout(90, TimeUnit.SECONDS) |
||||
|
.build() |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "buildTrustAllClient 失败,使用默认客户端", e) |
||||
|
OkHttpClient() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,613 @@ |
|||||
|
import Foundation |
||||
|
import UIKit |
||||
|
import AVFoundation |
||||
|
import CryptoKit |
||||
|
import os.log |
||||
|
import chat_storage |
||||
|
|
||||
|
// MARK: - StsConfig |
||||
|
|
||||
|
struct StsConfig { |
||||
|
let productId: String |
||||
|
let productKey: String |
||||
|
var deviceNo: String |
||||
|
var deviceId: String |
||||
|
var deviceSecret: String |
||||
|
let agentId: String |
||||
|
var cmei: String = "" |
||||
|
} |
||||
|
|
||||
|
// MARK: - StsAgent |
||||
|
|
||||
|
/// StsAgent — 中国移动 A80 WS 一体化语音对话实现(iOS) |
||||
|
/// |
||||
|
/// 通过一条 WSS 连接完成 ASR + AI + TTS,事件格式与标准 stt_llm_tts 完全一致, |
||||
|
/// 通过 onEvent 回调发出,供 AgentServiceImpl 转发到 Flutter EventChannel。 |
||||
|
class StsAgent: NSObject { |
||||
|
|
||||
|
private let TAG = "StsAgent" |
||||
|
private let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "StsAgent") |
||||
|
|
||||
|
private var config: StsConfig |
||||
|
private let onEvent: (String, [String: Any]) -> Void |
||||
|
private let onCredentialsFetched: ((String, String, String) -> Void)? |
||||
|
|
||||
|
// MARK: WebSocket |
||||
|
private var webSocketTask: URLSessionWebSocketTask? |
||||
|
private var urlSession: URLSession! |
||||
|
private var readyReceived = false |
||||
|
private var sessionEnded = true |
||||
|
private var dialogRequestId = "" |
||||
|
|
||||
|
// MARK: 录音 |
||||
|
private let SAMPLE_RATE: Double = 16000 |
||||
|
private let FRAME_SIZE = 5120 // 160ms @ 16kHz 16bit mono |
||||
|
private var audioEngine: AVAudioEngine? |
||||
|
private var inputNode: AVAudioInputNode? |
||||
|
private var recordingActive = false |
||||
|
private var sendBuffer = Data() |
||||
|
private var micChunkCount = 0 |
||||
|
|
||||
|
// MARK: VAD |
||||
|
private let VAD_THRESHOLD: Float = 800 |
||||
|
private let VAD_ONSET_CHUNKS = 3 |
||||
|
private var vadOnsetCount = 0 |
||||
|
private var vadActive = false |
||||
|
private var preBuffer: [[UInt8]] = [] |
||||
|
private let PRE_BUFFER_MAX = 75 |
||||
|
|
||||
|
// MARK: TTS |
||||
|
private var ttsQueue: [String] = [] |
||||
|
private var ttsPlayer: AVPlayer? |
||||
|
private var ttsPlaying = false |
||||
|
private var ttsPlayerObserver: Any? |
||||
|
|
||||
|
// MARK: 历史 |
||||
|
private var messages: [[String: String]] = [] |
||||
|
private var currentSessionId = "" |
||||
|
private let chatStorage = ChatStorageHelper.shared |
||||
|
|
||||
|
// MARK: 模式 |
||||
|
private var isPttMode = false |
||||
|
|
||||
|
// MARK: 凭证请求 |
||||
|
private var credentialsFetched = false |
||||
|
|
||||
|
// MARK: 心跳冷却 |
||||
|
/// 1 小时冷却(毫秒),避免 StsAgent 反复重建 / fetchCredentials 反复触发时频繁上报 |
||||
|
private let HEARTBEAT_COOLDOWN_MS: Int64 = 3_600_000 |
||||
|
private let HEARTBEAT_PREFS_KEY = "sts_agent_last_heartbeat_ts" |
||||
|
|
||||
|
init(config: StsConfig, |
||||
|
onEvent: @escaping (String, [String: Any]) -> Void, |
||||
|
onCredentialsFetched: ((String, String, String) -> Void)? = nil) { |
||||
|
self.config = config |
||||
|
self.onEvent = onEvent |
||||
|
self.onCredentialsFetched = onCredentialsFetched |
||||
|
super.init() |
||||
|
let sessionConfig = URLSessionConfiguration.default |
||||
|
urlSession = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: OperationQueue()) |
||||
|
os_log("StsAgent 初始化: productId=%{public}@ deviceNo=%{public}@ deviceId=%{public}@ cmei=%{public}@ hasSecret=%{public}@", |
||||
|
log: logger, type: .info, |
||||
|
config.productId, config.deviceNo, config.deviceId, config.cmei, |
||||
|
config.deviceSecret.isEmpty ? "false" : "true") |
||||
|
// 与 Android 行为对齐:凭证已有时立即尝试上报一次心跳(受 1 小时冷却保护) |
||||
|
if !config.deviceSecret.isEmpty && !config.deviceId.isEmpty { |
||||
|
credentialsFetched = true |
||||
|
reportHeartbeat() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 动态更新 CMEI(首次初始化时可能尚未获取到) |
||||
|
func updateCmei(_ cmei: String) { |
||||
|
guard !cmei.isEmpty, cmei != config.cmei else { return } |
||||
|
config.cmei = cmei |
||||
|
os_log("updateCmei: cmei 已更新为 %{public}@", log: logger, type: .info, cmei) |
||||
|
} |
||||
|
|
||||
|
// MARK: - 公共接口 |
||||
|
|
||||
|
func startCall() { |
||||
|
isPttMode = false |
||||
|
os_log("StsAgent startCall", log: logger, type: .info) |
||||
|
fetchCredentialsIfNeeded { [weak self] in |
||||
|
self?.connect() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func startPtt() { |
||||
|
isPttMode = true |
||||
|
os_log("StsAgent startPtt", log: logger, type: .info) |
||||
|
fetchCredentialsIfNeeded { [weak self] in |
||||
|
self?.connect() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func stopPtt() { |
||||
|
os_log("StsAgent stopPtt", log: logger, type: .info) |
||||
|
guard isPttMode else { return } |
||||
|
sendFinishFrame() |
||||
|
} |
||||
|
|
||||
|
/// 统一停止:根据当前模式自动选择 stopPtt 或 dispose |
||||
|
func stop() { |
||||
|
if isPttMode { |
||||
|
stopPtt() |
||||
|
} else { |
||||
|
dispose() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func interruptSpeaking() { |
||||
|
os_log("StsAgent interruptSpeaking", log: logger, type: .info) |
||||
|
stopTts() |
||||
|
emit("tts_canceled") |
||||
|
} |
||||
|
|
||||
|
func clearHistory() { |
||||
|
messages.removeAll() |
||||
|
DispatchQueue.global().async { [weak self] in |
||||
|
guard let self = self else { return } |
||||
|
_ = self.chatStorage.deleteMessages(agentId: self.config.agentId, messageIds: nil as [Int]?) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func dispose() { |
||||
|
os_log("StsAgent dispose", log: logger, type: .info) |
||||
|
stopRecording() |
||||
|
webSocketTask?.cancel(with: .normalClosure, reason: nil) |
||||
|
webSocketTask = nil |
||||
|
stopTts() |
||||
|
} |
||||
|
|
||||
|
// MARK: - 凭证获取 |
||||
|
|
||||
|
/// 公开入口:主动触发认证检查(Dart 层 AgentService.fetchCredentials 会调用) |
||||
|
func fetchCredentials() { |
||||
|
fetchCredentialsIfNeeded { [weak self] in |
||||
|
// 成功与否都不做额外处理;fetchCredentialsIfNeeded 内部会在命中凭证时触发心跳 |
||||
|
_ = self |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func fetchCredentialsIfNeeded(completion: @escaping () -> Void) { |
||||
|
if credentialsFetched && !config.deviceId.isEmpty && !config.deviceSecret.isEmpty { |
||||
|
// 凭证已有,直接上报心跳(受冷却保护) |
||||
|
reportHeartbeat() |
||||
|
completion() |
||||
|
return |
||||
|
} |
||||
|
let ts = Int64(Date().timeIntervalSince1970 * 1000) |
||||
|
let sign = calcSign(secret: config.productKey, ts: ts) |
||||
|
let urlStr = "https://ivs.chinamobiledevice.com:11443/v2/customer/device/secret/info" |
||||
|
guard var components = URLComponents(string: urlStr) else { completion(); return } |
||||
|
components.queryItems = [ |
||||
|
URLQueryItem(name: "productId", value: config.productId), |
||||
|
URLQueryItem(name: "deviceNo", value: config.deviceNo), |
||||
|
URLQueryItem(name: "ts", value: "\(ts)"), |
||||
|
URLQueryItem(name: "sign", value: sign), |
||||
|
] |
||||
|
guard let url = components.url else { completion(); return } |
||||
|
var req = URLRequest(url: url) |
||||
|
req.timeoutInterval = 10 |
||||
|
URLSession.shared.dataTask(with: req) { [weak self] data, _, error in |
||||
|
guard let self = self else { return } |
||||
|
if let error = error { |
||||
|
os_log("凭证请求失败: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
||||
|
completion() |
||||
|
return |
||||
|
} |
||||
|
guard let data = data, |
||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
||||
|
let body = json["body"] as? [String: Any] else { |
||||
|
completion() |
||||
|
return |
||||
|
} |
||||
|
if let deviceId = body["deviceId"] as? String { self.config.deviceId = deviceId } |
||||
|
if let deviceSecret = body["deviceSecret"] as? String { self.config.deviceSecret = deviceSecret } |
||||
|
if let deviceNo = body["deviceNo"] as? String { self.config.deviceNo = deviceNo } |
||||
|
self.credentialsFetched = true |
||||
|
self.onCredentialsFetched?(self.config.deviceId, self.config.deviceSecret, self.config.deviceNo) |
||||
|
// 凭证获取成功后立即上报心跳(受冷却保护) |
||||
|
self.reportHeartbeat() |
||||
|
completion() |
||||
|
}.resume() |
||||
|
} |
||||
|
|
||||
|
// MARK: - 心跳上报 |
||||
|
|
||||
|
/// 向中国移动平台上报心跳;1 小时冷却通过 UserDefaults 持久化,所有调用路径共享同一把闸门 |
||||
|
func reportHeartbeat() { |
||||
|
guard credentialsFetched, |
||||
|
!config.deviceId.isEmpty, |
||||
|
!config.deviceSecret.isEmpty else { |
||||
|
os_log("reportHeartbeat: 凭证未就绪,跳过上报", log: logger, type: .info) |
||||
|
return |
||||
|
} |
||||
|
let defaults = UserDefaults.standard |
||||
|
// 用 Double 存毫秒时间戳,避开 Int64 的 UserDefaults 桥接问题 |
||||
|
let last = defaults.double(forKey: HEARTBEAT_PREFS_KEY) |
||||
|
let now = Date().timeIntervalSince1970 * 1000 |
||||
|
if last > 0, now - last < Double(HEARTBEAT_COOLDOWN_MS) { |
||||
|
os_log("reportHeartbeat: 距上次 %{public}ds,冷却中跳过", log: logger, type: .info, Int((now - last) / 1000)) |
||||
|
return |
||||
|
} |
||||
|
defaults.set(now, forKey: HEARTBEAT_PREFS_KEY) |
||||
|
os_log("reportHeartbeat: 开始上报 deviceId=%{public}@ deviceNo=%{public}@ cmei=%{public}@", |
||||
|
log: logger, type: .info, config.deviceId, config.deviceNo, config.cmei) |
||||
|
|
||||
|
let urlStr = "https://ivs.chinamobiledevice.com:11443/v2/customer/device/report" |
||||
|
guard let url = URL(string: urlStr) else { return } |
||||
|
var req = URLRequest(url: url) |
||||
|
req.httpMethod = "POST" |
||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
||||
|
req.timeoutInterval = 10 |
||||
|
|
||||
|
let systemVersion = UIDevice.current.systemVersion |
||||
|
let params: [String: Any] = [ |
||||
|
"innerIp": ["127.0.0.1"], |
||||
|
"netSpeed": "0Mbps", |
||||
|
"netType": "WiFi", |
||||
|
"platform": "iOS\(systemVersion)", |
||||
|
"sdkVersion": "ai_http_1.0", |
||||
|
"firmwareVersion": "1.0.0", |
||||
|
"imei": "", |
||||
|
"cmei": config.cmei, |
||||
|
"mac": "", |
||||
|
] |
||||
|
let bodyDict: [String: Any] = [ |
||||
|
"deviceId": config.deviceId, |
||||
|
"deviceSecret": config.deviceSecret, |
||||
|
"productId": config.productId, |
||||
|
"productKey": config.productKey, |
||||
|
"params": params, |
||||
|
] |
||||
|
guard let bodyData = try? JSONSerialization.data(withJSONObject: bodyDict, options: []) else { return } |
||||
|
req.httpBody = bodyData |
||||
|
|
||||
|
URLSession.shared.dataTask(with: req) { [weak self] data, _, error in |
||||
|
guard let self = self else { return } |
||||
|
if let error = error { |
||||
|
os_log("reportHeartbeat: 请求异常 %{public}@", log: self.logger, type: .error, error.localizedDescription) |
||||
|
// 异常回滚时间戳,避免卡 1 小时无法重试 |
||||
|
UserDefaults.standard.set(last, forKey: self.HEARTBEAT_PREFS_KEY) |
||||
|
return |
||||
|
} |
||||
|
guard let data = data, |
||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { |
||||
|
os_log("reportHeartbeat: 响应解析失败", log: self.logger, type: .error) |
||||
|
UserDefaults.standard.set(last, forKey: self.HEARTBEAT_PREFS_KEY) |
||||
|
return |
||||
|
} |
||||
|
// code 可能是 String 或 NSNumber,统一转成字符串 |
||||
|
let code: String = { |
||||
|
if let s = json["code"] as? String { return s } |
||||
|
if let n = json["code"] as? NSNumber { return n.stringValue } |
||||
|
return "" |
||||
|
}() |
||||
|
let msg = (json["message"] as? String) ?? "" |
||||
|
if code == "200" { |
||||
|
os_log("reportHeartbeat: 上报成功 code=%{public}@ msg=%{public}@", log: self.logger, type: .info, code, msg) |
||||
|
self.emit("heartbeat_reported", data: ["time": Int64(now)]) |
||||
|
} else { |
||||
|
os_log("reportHeartbeat: 上报失败 code=%{public}@ msg=%{public}@", log: self.logger, type: .error, code, msg) |
||||
|
// 上报失败回滚时间戳 |
||||
|
UserDefaults.standard.set(last, forKey: self.HEARTBEAT_PREFS_KEY) |
||||
|
} |
||||
|
}.resume() |
||||
|
} |
||||
|
|
||||
|
// MARK: - WebSocket 连接 |
||||
|
|
||||
|
private func connect() { |
||||
|
let ts = Int64(Date().timeIntervalSince1970 * 1000) |
||||
|
let sign = calcSign(secret: config.deviceSecret, ts: ts) |
||||
|
let urlStr = "wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr" |
||||
|
guard var components = URLComponents(string: urlStr) else { return } |
||||
|
components.queryItems = [ |
||||
|
URLQueryItem(name: "productId", value: config.productId), |
||||
|
URLQueryItem(name: "deviceId", value: config.deviceId), |
||||
|
URLQueryItem(name: "ts", value: "\(ts)"), |
||||
|
URLQueryItem(name: "sign", value: sign), |
||||
|
] |
||||
|
guard let url = components.url else { return } |
||||
|
webSocketTask = urlSession.webSocketTask(with: url) |
||||
|
webSocketTask?.resume() |
||||
|
receiveMessages() |
||||
|
|
||||
|
// 发送 start 帧 |
||||
|
sendStartFrame() |
||||
|
} |
||||
|
|
||||
|
private func sendStartFrame() { |
||||
|
dialogRequestId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() |
||||
|
currentSessionId = dialogRequestId |
||||
|
let payload: [String: Any] = [ |
||||
|
"msgType": "start", |
||||
|
"dialogRequestId": dialogRequestId, |
||||
|
"params": [ |
||||
|
"asrParams": [ |
||||
|
"format": "pcm", |
||||
|
"sampleRate": 16000, |
||||
|
"lang": "zh-CN", |
||||
|
"vadEnable": false, |
||||
|
], |
||||
|
"contextParams": [ |
||||
|
"history": messages.map { ["role": $0["role"] ?? "", "content": $0["content"] ?? ""] } |
||||
|
] |
||||
|
] |
||||
|
] |
||||
|
sendJson(payload) |
||||
|
} |
||||
|
|
||||
|
private func receiveMessages() { |
||||
|
webSocketTask?.receive { [weak self] result in |
||||
|
guard let self = self else { return } |
||||
|
switch result { |
||||
|
case .success(let msg): |
||||
|
switch msg { |
||||
|
case .string(let text): |
||||
|
self.handleMessage(text) |
||||
|
default: |
||||
|
break |
||||
|
} |
||||
|
self.receiveMessages() |
||||
|
case .failure(let err): |
||||
|
os_log("WS error: %{public}@", log: self.logger, type: .error, err.localizedDescription) |
||||
|
self.emit("error", data: ["message": err.localizedDescription]) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 消息处理 |
||||
|
|
||||
|
private func handleMessage(_ text: String) { |
||||
|
guard let data = text.data(using: .utf8), |
||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } |
||||
|
let msgType = json["msgType"] as? String ?? "" |
||||
|
os_log("WS recv: %{public}@", log: logger, type: .debug, msgType) |
||||
|
|
||||
|
switch msgType { |
||||
|
case "ready": |
||||
|
readyReceived = true |
||||
|
sessionEnded = false |
||||
|
emit("recognition_started") |
||||
|
startRecording() |
||||
|
if isPttMode { |
||||
|
// PTT 模式等待 stopPtt() 调用发送 finish |
||||
|
} |
||||
|
|
||||
|
case "mid_result": |
||||
|
let result = (json["data"] as? [String: Any])?["result"] as? String ?? "" |
||||
|
if !result.isEmpty { emit("recognizing", data: ["text": result]) } |
||||
|
|
||||
|
case "fin_result": |
||||
|
let result = (json["data"] as? [String: Any])?["result"] as? String ?? "" |
||||
|
emit("recognition_result", data: ["text": result]) |
||||
|
emit("recognition_stopped") |
||||
|
stopRecording() |
||||
|
if !result.isEmpty { |
||||
|
messages.append(["role": "user", "content": result]) |
||||
|
saveToChatStorage(text: result, role: "user") |
||||
|
} |
||||
|
|
||||
|
case "stoplisten": |
||||
|
stopRecording() |
||||
|
emit("recognition_stopped") |
||||
|
|
||||
|
case "inside_rc": |
||||
|
handleInsideRc(json["data"] as? [String: Any]) |
||||
|
|
||||
|
case "dcs_decide": |
||||
|
// 语义决策,忽略 |
||||
|
break |
||||
|
|
||||
|
default: |
||||
|
os_log("未处理的消息类型: %{public}@", log: logger, type: .info, msgType) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func handleInsideRc(_ data: [String: Any]?) { |
||||
|
guard let data = data else { return } |
||||
|
guard let rcType = data["rcType"] as? String else { return } |
||||
|
if rcType == "assistant_answer" { |
||||
|
let answer = (data["answer"] as? [String: Any])?["text"] as? String ?? "" |
||||
|
let cleanAnswer = answer.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
let sid = data["sessionId"] as? String ?? currentSessionId |
||||
|
if !cleanAnswer.isEmpty { |
||||
|
messages.append(["role": "assistant", "content": cleanAnswer]) |
||||
|
saveToChatStorage(text: cleanAnswer, role: "assistant") |
||||
|
emit("assistant_response", data: ["response": cleanAnswer, "sessionid": sid]) |
||||
|
} |
||||
|
// TTS URLs |
||||
|
if let ttsData = data["tts"] as? [[String: Any]] { |
||||
|
for item in ttsData { |
||||
|
if let url = item["url"] as? String, !url.isEmpty { |
||||
|
ttsQueue.append(url) |
||||
|
} |
||||
|
} |
||||
|
if !ttsPlaying { playNextTts() } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - TTS 播放 |
||||
|
|
||||
|
private func playNextTts() { |
||||
|
guard !ttsQueue.isEmpty else { |
||||
|
ttsPlaying = false |
||||
|
emit("tts_completed") |
||||
|
return |
||||
|
} |
||||
|
let urlStr = ttsQueue.removeFirst() |
||||
|
guard let url = URL(string: urlStr) else { playNextTts(); return } |
||||
|
|
||||
|
ttsPlaying = true |
||||
|
if ttsQueue.isEmpty && !ttsPlaying { emit("tts_started") } |
||||
|
emit("tts_started") |
||||
|
|
||||
|
let item = AVPlayerItem(url: url) |
||||
|
ttsPlayer = AVPlayer(playerItem: item) |
||||
|
if let observer = ttsPlayerObserver { |
||||
|
NotificationCenter.default.removeObserver(observer) |
||||
|
} |
||||
|
ttsPlayerObserver = NotificationCenter.default.addObserver( |
||||
|
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main |
||||
|
) { [weak self] _ in |
||||
|
self?.playNextTts() |
||||
|
} |
||||
|
ttsPlayer?.play() |
||||
|
} |
||||
|
|
||||
|
private func stopTts() { |
||||
|
ttsPlayer?.pause() |
||||
|
ttsPlayer = nil |
||||
|
ttsQueue.removeAll() |
||||
|
ttsPlaying = false |
||||
|
if let observer = ttsPlayerObserver { |
||||
|
NotificationCenter.default.removeObserver(observer) |
||||
|
ttsPlayerObserver = nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 录音 |
||||
|
|
||||
|
private func startRecording() { |
||||
|
guard !recordingActive else { return } |
||||
|
let engine = AVAudioEngine() |
||||
|
audioEngine = engine |
||||
|
inputNode = engine.inputNode |
||||
|
let fmt = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: SAMPLE_RATE, channels: 1, interleaved: true)! |
||||
|
let hwFmt = inputNode!.outputFormat(forBus: 0) |
||||
|
let converterFmt = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: SAMPLE_RATE, channels: 1, interleaved: true)! |
||||
|
|
||||
|
inputNode!.installTap(onBus: 0, bufferSize: 4096, format: hwFmt) { [weak self] buffer, _ in |
||||
|
guard let self = self, self.recordingActive else { return } |
||||
|
// 转换为 16kHz PCM16 |
||||
|
guard let cvt = AVAudioConverter(from: hwFmt, to: converterFmt) else { return } |
||||
|
let frameCount = AVAudioFrameCount(Double(buffer.frameLength) * self.SAMPLE_RATE / hwFmt.sampleRate) |
||||
|
guard let outBuf = AVAudioPCMBuffer(pcmFormat: converterFmt, frameCapacity: frameCount) else { return } |
||||
|
var error: NSError? |
||||
|
cvt.convert(to: outBuf, error: &error) { _, outStatus in |
||||
|
outStatus.pointee = .haveData |
||||
|
return buffer |
||||
|
} |
||||
|
if error != nil { return } |
||||
|
let byteCount = Int(outBuf.frameLength) * 2 |
||||
|
let ptr = outBuf.int16ChannelData![0] |
||||
|
let data = Data(bytes: ptr, count: byteCount) |
||||
|
self.onMicAudio(data) |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker]) |
||||
|
try AVAudioSession.sharedInstance().setActive(true) |
||||
|
try engine.start() |
||||
|
recordingActive = true |
||||
|
} catch { |
||||
|
os_log("录音启动失败: %{public}@", log: logger, type: .error, error.localizedDescription) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func stopRecording() { |
||||
|
guard recordingActive else { return } |
||||
|
recordingActive = false |
||||
|
inputNode?.removeTap(onBus: 0) |
||||
|
audioEngine?.stop() |
||||
|
audioEngine = nil |
||||
|
inputNode = nil |
||||
|
sendBuffer.removeAll() |
||||
|
micChunkCount = 0 |
||||
|
vadActive = false |
||||
|
vadOnsetCount = 0 |
||||
|
preBuffer.removeAll() |
||||
|
} |
||||
|
|
||||
|
private func onMicAudio(_ pcm: Data) { |
||||
|
micChunkCount += 1 |
||||
|
let rms = calcRms(pcm) |
||||
|
let chunk = [UInt8](pcm) |
||||
|
|
||||
|
if !vadActive { |
||||
|
if preBuffer.count >= PRE_BUFFER_MAX { preBuffer.removeFirst() } |
||||
|
preBuffer.append(chunk) |
||||
|
} |
||||
|
|
||||
|
if !vadActive && rms > VAD_THRESHOLD { |
||||
|
vadOnsetCount += 1 |
||||
|
if vadOnsetCount >= VAD_ONSET_CHUNKS { |
||||
|
vadActive = true |
||||
|
// 先发预缓存 |
||||
|
for c in preBuffer { feedSendBuffer(Data(c)) } |
||||
|
preBuffer.removeAll() |
||||
|
} |
||||
|
} else if vadActive { |
||||
|
feedSendBuffer(pcm) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func calcRms(_ pcm: Data) -> Float { |
||||
|
let count = pcm.count / 2 |
||||
|
guard count > 0 else { return 0 } |
||||
|
var sum: Float = 0 |
||||
|
pcm.withUnsafeBytes { ptr in |
||||
|
let samples = ptr.bindMemory(to: Int16.self) |
||||
|
for i in 0..<count { |
||||
|
let s = Float(samples[i]) |
||||
|
sum += s * s |
||||
|
} |
||||
|
} |
||||
|
return sqrtf(sum / Float(count)) |
||||
|
} |
||||
|
|
||||
|
private func feedSendBuffer(_ chunk: Data) { |
||||
|
sendBuffer.append(chunk) |
||||
|
while sendBuffer.count >= FRAME_SIZE { |
||||
|
let frame = sendBuffer.prefix(FRAME_SIZE) |
||||
|
sendBuffer.removeFirst(FRAME_SIZE) |
||||
|
webSocketTask?.send(.data(Data(frame))) { _ in } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func sendFinishFrame() { |
||||
|
let payload: [String: Any] = [ |
||||
|
"msgType": "finish", |
||||
|
"dialogRequestId": dialogRequestId, |
||||
|
] |
||||
|
sendJson(payload) |
||||
|
} |
||||
|
|
||||
|
// MARK: - 工具 |
||||
|
|
||||
|
private func sendJson(_ dict: [String: Any]) { |
||||
|
guard let data = try? JSONSerialization.data(withJSONObject: dict), |
||||
|
let str = String(data: data, encoding: .utf8) else { return } |
||||
|
webSocketTask?.send(.string(str)) { _ in } |
||||
|
} |
||||
|
|
||||
|
private func calcSign(secret: String, ts: Int64) -> String { |
||||
|
let raw = "\(secret)\(ts)" |
||||
|
let data = Data(raw.utf8) |
||||
|
let digest = Insecure.MD5.hash(data: data) |
||||
|
return digest.map { String(format: "%02x", $0) }.joined() |
||||
|
} |
||||
|
|
||||
|
private func emit(_ name: String, data: [String: Any] = [:]) { |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.onEvent(name, data) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func saveToChatStorage(text: String, role: String) { |
||||
|
DispatchQueue.global().async { [weak self] in |
||||
|
guard let self = self else { return } |
||||
|
_ = self.chatStorage.saveMessage( |
||||
|
agentId: self.config.agentId, |
||||
|
sessionId: self.currentSessionId, |
||||
|
message: text, |
||||
|
sender: role, |
||||
|
metadata: nil |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,563 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="zh"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=375, initial-scale=1"> |
||||
|
<title>移动精灵 UI 预览</title> |
||||
|
<style> |
||||
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
||||
|
body { |
||||
|
background: #e8edf2; |
||||
|
font-family: -apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif; |
||||
|
display: flex; gap: 20px; padding: 24px; |
||||
|
flex-wrap: wrap; justify-content: center; align-items: flex-start; |
||||
|
} |
||||
|
|
||||
|
/* ── Phone shell ── */ |
||||
|
.phone { |
||||
|
width: 375px; height: 700px; |
||||
|
background: #f4f6f9; |
||||
|
border-radius: 44px; |
||||
|
border: 1px solid #d0d5dd; |
||||
|
overflow: hidden; |
||||
|
display: flex; flex-direction: column; |
||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06); |
||||
|
} |
||||
|
|
||||
|
/* ── AppBar ── */ |
||||
|
.appbar { |
||||
|
height: 56px; flex-shrink: 0; |
||||
|
background: #fff; |
||||
|
border-bottom: 1px solid #eaecf0; |
||||
|
display: flex; align-items: center; |
||||
|
padding: 0 16px; gap: 8px; |
||||
|
} |
||||
|
.appbar-back { color: #667085; font-size: 22px; line-height: 1; cursor: pointer; } |
||||
|
.appbar-avatar { |
||||
|
width: 32px; height: 32px; border-radius: 50%; |
||||
|
background: linear-gradient(135deg, #667eea, #764ba2); |
||||
|
display: flex; align-items: center; justify-content: center; |
||||
|
color: #fff; font-size: 14px; font-weight: 600; flex-shrink: 0; |
||||
|
} |
||||
|
.appbar-name { font-size: 16px; font-weight: 600; color: #101828; flex: 1; } |
||||
|
.status-pill { |
||||
|
font-size: 11px; font-weight: 500; |
||||
|
padding: 3px 10px; border-radius: 20px; |
||||
|
display: flex; align-items: center; gap: 4px; |
||||
|
} |
||||
|
.pill-idle { background: #f2f4f7; color: #667085; } |
||||
|
.pill-connecting { background: #fffaeb; color: #b54708; } |
||||
|
.pill-recording { background: #ecfdf3; color: #027a48; } |
||||
|
.pill-thinking { background: #eff8ff; color: #175cd3; } |
||||
|
.pill-speaking { background: #ecfdf3; color: #027a48; } |
||||
|
.pill-timer { font-weight: 400; } |
||||
|
|
||||
|
/* ── Chat area ── */ |
||||
|
.chat-area { |
||||
|
flex: 1; overflow-y: auto; |
||||
|
padding: 16px 16px 8px; |
||||
|
display: flex; flex-direction: column; gap: 14px; |
||||
|
} |
||||
|
.empty-state { |
||||
|
flex: 1; display: flex; flex-direction: column; |
||||
|
align-items: center; justify-content: center; gap: 8px; |
||||
|
} |
||||
|
.empty-avatar { |
||||
|
width: 80px; height: 80px; border-radius: 50%; |
||||
|
background: linear-gradient(135deg, #667eea, #764ba2); |
||||
|
display: flex; align-items: center; justify-content: center; |
||||
|
color: #fff; font-size: 32px; font-weight: 600; margin-bottom: 12px; |
||||
|
} |
||||
|
.empty-title { font-size: 20px; font-weight: bold; color: #101828; } |
||||
|
.empty-sub { font-size: 13px; color: #667085; } |
||||
|
|
||||
|
.msg-row { display: flex; flex-direction: column; } |
||||
|
.msg-row.user { align-items: flex-end; } |
||||
|
.msg-row.ai { align-items: flex-start; gap: 4px; } |
||||
|
.ai-label { display: flex; align-items: center; gap: 6px; } |
||||
|
.ai-dot { |
||||
|
width: 22px; height: 22px; border-radius: 50%; |
||||
|
background: linear-gradient(135deg, #667eea, #764ba2); |
||||
|
display: flex; align-items: center; justify-content: center; |
||||
|
color: #fff; font-size: 10px; |
||||
|
} |
||||
|
.ai-name { font-size: 11px; color: #98a2b3; } |
||||
|
.bubble { |
||||
|
max-width: 72%; padding: 10px 14px; |
||||
|
font-size: 15px; line-height: 1.55; |
||||
|
word-break: break-all; |
||||
|
} |
||||
|
.bubble-user { |
||||
|
background: #2e7cf6; color: #fff; |
||||
|
border-radius: 18px 18px 4px 18px; |
||||
|
} |
||||
|
.bubble-ai { |
||||
|
background: #fff; color: #1d2939; |
||||
|
border-radius: 4px 18px 18px 18px; |
||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.06); |
||||
|
} |
||||
|
.msg-time { font-size: 11px; color: #d0d5dd; margin-top: 3px; } |
||||
|
|
||||
|
/* ── Bottom bar ── */ |
||||
|
.bottom-bar { |
||||
|
flex-shrink: 0; |
||||
|
background: #fff; |
||||
|
border-top: 1px solid #eaecf0; |
||||
|
box-shadow: 0 -2px 12px rgba(0,0,0,0.05); |
||||
|
} |
||||
|
|
||||
|
/* ── 输入区域 ── */ |
||||
|
.input-row { |
||||
|
display: flex; align-items: center; gap: 8px; |
||||
|
padding: 8px 12px 18px; |
||||
|
} |
||||
|
.mode-toggle { |
||||
|
width: 38px; height: 38px; border-radius: 50%; |
||||
|
background: #f2f4f7; border: none; |
||||
|
display: flex; align-items: center; justify-content: center; |
||||
|
cursor: pointer; flex-shrink: 0; color: #667085; font-size: 18px; |
||||
|
} |
||||
|
/* 文本输入框(内含发送按钮) */ |
||||
|
.text-input-wrap { |
||||
|
flex: 1; height: 42px; border-radius: 21px; |
||||
|
background: #f2f4f7; |
||||
|
display: flex; align-items: center; |
||||
|
padding: 0 4px 0 16px; |
||||
|
} |
||||
|
.text-input-wrap input { |
||||
|
flex: 1; height: 100%; border: none; background: transparent; |
||||
|
font-size: 14px; color: #101828; outline: none; |
||||
|
} |
||||
|
.text-input-wrap input::placeholder { color: #98a2b3; } |
||||
|
.send-btn-inner { |
||||
|
width: 32px; height: 32px; border-radius: 50%; |
||||
|
background: linear-gradient(135deg, #667eea, #5a67d8); |
||||
|
border: none; cursor: pointer; color: #fff; font-size: 16px; |
||||
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0; |
||||
|
} |
||||
|
.call-btn { |
||||
|
width: 44px; height: 44px; border-radius: 50%; |
||||
|
background: linear-gradient(135deg, #34c759, #28a745); |
||||
|
border: none; cursor: pointer; color: #fff; font-size: 18px; |
||||
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0; |
||||
|
box-shadow: 0 4px 14px rgba(52,199,89,.4); |
||||
|
} |
||||
|
|
||||
|
/* 按住说话按钮(微信风格) */ |
||||
|
.ptt-btn { |
||||
|
flex: 1; height: 42px; border-radius: 21px; |
||||
|
background: #fff; border: 1px solid #d0d5dd; |
||||
|
display: flex; align-items: center; justify-content: center; |
||||
|
cursor: pointer; font-size: 15px; color: #101828; font-weight: 600; |
||||
|
letter-spacing: 2px; |
||||
|
transition: all .15s; |
||||
|
user-select: none; |
||||
|
} |
||||
|
.ptt-btn:active { |
||||
|
background: #f2f4f7; |
||||
|
border-color: #98a2b3; |
||||
|
} |
||||
|
/* 禁用状态 */ |
||||
|
.ptt-btn.disabled { |
||||
|
opacity: 0.5; |
||||
|
pointer-events: none; |
||||
|
} |
||||
|
|
||||
|
/* PTT 录音中 - 中间按钮(橙色) */ |
||||
|
.ptt-record-center { |
||||
|
flex: 1; height: 42px; border-radius: 21px; |
||||
|
background: linear-gradient(135deg, #f79009, #dc6803); |
||||
|
display: flex; align-items: center; justify-content: center; gap: 8px; |
||||
|
box-shadow: 0 2px 8px rgba(247,144,9,.3); |
||||
|
color: #fff; font-size: 14px; font-weight: 500; |
||||
|
} |
||||
|
/* PTT 连接中 - 与按住说话空闲状态相同 */ |
||||
|
.ptt-connecting-center { |
||||
|
flex: 1; height: 42px; border-radius: 21px; |
||||
|
background: #fff; border: 1px solid #d0d5dd; |
||||
|
display: flex; align-items: center; justify-content: center; gap: 8px; |
||||
|
color: #667085; font-size: 14px; font-weight: 500; |
||||
|
} |
||||
|
/* 取消按钮(替换左侧切换按钮位置) */ |
||||
|
.cancel-toggle { |
||||
|
width: 38px; height: 38px; border-radius: 50%; |
||||
|
background: #f2f4f7; border: 1px solid #eaecf0; |
||||
|
display: flex; align-items: center; justify-content: center; |
||||
|
cursor: pointer; flex-shrink: 0; color: #ef4444; font-size: 16px; |
||||
|
} |
||||
|
|
||||
|
/* PTT 实时识别文字(渐变背景) */ |
||||
|
.ptt-realtime { |
||||
|
padding: 12px 20px; margin: 0 12px 4px; |
||||
|
background: linear-gradient(135deg, #f0f4ff, #e8f5e9); |
||||
|
border: 0.5px solid #e2e8f0; |
||||
|
border-radius: 16px; |
||||
|
font-size: 15px; color: #101828; |
||||
|
text-align: center; |
||||
|
line-height: 1.4; |
||||
|
} |
||||
|
|
||||
|
/* ── 状态提示条 ── */ |
||||
|
.status-strip { |
||||
|
padding: 8px 16px; |
||||
|
display: flex; align-items: center; gap: 8px; |
||||
|
font-size: 13px; font-weight: 500; |
||||
|
} |
||||
|
.strip-speaking { background: #ecfdf3; color: #16a34a; } |
||||
|
.strip-thinking { background: #eff8ff; color: #175cd3; } |
||||
|
.strip-connecting { background: #fffaeb; color: #b54708; } |
||||
|
.strip-recording { background: #ecfdf3; color: #16a34a; } |
||||
|
.interrupt-btn { |
||||
|
margin-left: auto; |
||||
|
padding: 4px 12px; border-radius: 16px; |
||||
|
border: 1px solid #ef4444; background: transparent; |
||||
|
color: #ef4444; font-size: 12px; font-weight: 500; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
/* ── 通话底栏 ── */ |
||||
|
.call-bottom { |
||||
|
padding: 12px 20px 18px; |
||||
|
display: flex; align-items: center; gap: 12px; |
||||
|
} |
||||
|
.call-status { |
||||
|
display: flex; align-items: center; gap: 6px; flex: 1; |
||||
|
} |
||||
|
.call-dot { width: 8px; height: 8px; border-radius: 50%; } |
||||
|
.call-label { font-size: 13px; color: #667085; } |
||||
|
/* 音量指示器 */ |
||||
|
.vol-indicator { |
||||
|
width: 44px; height: 44px; border-radius: 50%; |
||||
|
background: #f2f4f7; |
||||
|
display: flex; align-items: center; justify-content: center; gap: 2px; |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
.vol-bar { |
||||
|
width: 3px; border-radius: 1.5px; background: #d0d5dd; |
||||
|
transition: background .15s; |
||||
|
} |
||||
|
.vol-bar.active { background: #34c759; } |
||||
|
.hangup-btn { |
||||
|
width: 52px; height: 52px; border-radius: 50%; |
||||
|
background: #ef4444; border: none; color: #fff; |
||||
|
display: flex; align-items: center; justify-content: center; |
||||
|
cursor: pointer; flex-shrink: 0; |
||||
|
box-shadow: 0 4px 14px rgba(239,68,68,.3); |
||||
|
} |
||||
|
|
||||
|
/* Spinner */ |
||||
|
.spinner { |
||||
|
width: 22px; height: 22px; |
||||
|
border: 2.5px solid currentColor; border-top-color: transparent; |
||||
|
border-radius: 50%; animation: spin .7s linear infinite; |
||||
|
} |
||||
|
.spinner-sm { width: 14px; height: 14px; border-width: 2px; } |
||||
|
@keyframes spin { to { transform: rotate(360deg); } } |
||||
|
|
||||
|
/* Mini wave */ |
||||
|
.mini-wave { display: flex; gap: 2px; align-items: center; } |
||||
|
.mini-wave span { |
||||
|
display: block; width: 2.5px; border-radius: 2px; |
||||
|
animation: mwave .8s ease-in-out infinite; |
||||
|
} |
||||
|
.mini-wave span:nth-child(1) { animation-delay: 0s; } |
||||
|
.mini-wave span:nth-child(2) { animation-delay: .13s; } |
||||
|
.mini-wave span:nth-child(3) { animation-delay: .26s; } |
||||
|
@keyframes mwave { |
||||
|
0%,100% { height: 3px; } |
||||
|
50% { height: 10px; } |
||||
|
} |
||||
|
|
||||
|
.label { |
||||
|
font-size: 11px; color: #98a2b3; text-align: center; |
||||
|
padding: 6px 0 0; letter-spacing: .3px; font-weight: 500; |
||||
|
} |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
|
||||
|
<!-- ① 文本输入模式(空闲) --> |
||||
|
<div> |
||||
|
<p class="label">① 文本输入模式</p> |
||||
|
<div class="phone"> |
||||
|
<div class="appbar"> |
||||
|
<span class="appbar-back">‹</span> |
||||
|
<div class="appbar-avatar">晓</div> |
||||
|
<span class="appbar-name">移动精灵</span> |
||||
|
<span class="status-pill pill-idle">空闲</span> |
||||
|
</div> |
||||
|
<div class="chat-area"> |
||||
|
<div class="empty-state"> |
||||
|
<div class="empty-avatar">晓</div> |
||||
|
<div class="empty-title">移动精灵</div> |
||||
|
<div class="empty-sub">点击右下角按钮,即可与晓晓对话</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="bottom-bar"> |
||||
|
<div class="input-row"> |
||||
|
<button class="mode-toggle" title="切换到语音">🎤</button> |
||||
|
<div class="text-input-wrap"> |
||||
|
<input placeholder="继续提问..." /> |
||||
|
<button class="send-btn-inner">↑</button> |
||||
|
</div> |
||||
|
<button class="call-btn">📞</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ② 语音输入模式(空闲) --> |
||||
|
<div> |
||||
|
<p class="label">② 语音模式 - 按住说话</p> |
||||
|
<div class="phone"> |
||||
|
<div class="appbar"> |
||||
|
<span class="appbar-back">‹</span> |
||||
|
<div class="appbar-avatar">晓</div> |
||||
|
<span class="appbar-name">移动精灵</span> |
||||
|
<span class="status-pill pill-idle">空闲</span> |
||||
|
</div> |
||||
|
<div class="chat-area"> |
||||
|
<div class="msg-row ai"> |
||||
|
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div> |
||||
|
<div class="bubble bubble-ai">你好!我是晓晓,有什么可以帮你的吗?</div> |
||||
|
<span class="msg-time">14:23</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="bottom-bar"> |
||||
|
<div class="input-row"> |
||||
|
<button class="mode-toggle" title="切换到文本">⌨</button> |
||||
|
<div class="ptt-btn">按住 说话</div> |
||||
|
<button class="call-btn">📞</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ③ PTT 按住中 - 连接阶段(灰色,保留两侧按钮) --> |
||||
|
<div> |
||||
|
<p class="label">③ 按住说话 - 连接中(同空闲样式)</p> |
||||
|
<div class="phone"> |
||||
|
<div class="appbar"> |
||||
|
<span class="appbar-back">‹</span> |
||||
|
<div class="appbar-avatar">晓</div> |
||||
|
<span class="appbar-name">移动精灵</span> |
||||
|
<span class="status-pill pill-connecting">连接中…</span> |
||||
|
</div> |
||||
|
<div class="chat-area"> |
||||
|
<div class="msg-row ai"> |
||||
|
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div> |
||||
|
<div class="bubble bubble-ai">你好!我是晓晓,有什么可以帮你的吗?</div> |
||||
|
<span class="msg-time">14:23</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="bottom-bar"> |
||||
|
<div class="input-row"> |
||||
|
<button class="mode-toggle" style="opacity:0.4;pointer-events:none;">⌨</button> |
||||
|
<div class="ptt-connecting-center"> |
||||
|
<div class="spinner spinner-sm" style="color:#667085;"></div> |
||||
|
<span>连接中...</span> |
||||
|
</div> |
||||
|
<button class="call-btn" style="opacity:0.4;pointer-events:none;">📞</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ④ PTT 按住中 - 录音中 + 实时识别(绿色,渐变识别背景) --> |
||||
|
<div> |
||||
|
<p class="label">④ 按住说话 - 录音中(橙色)</p> |
||||
|
<div class="phone"> |
||||
|
<div class="appbar"> |
||||
|
<span class="appbar-back">‹</span> |
||||
|
<div class="appbar-avatar">晓</div> |
||||
|
<span class="appbar-name">移动精灵</span> |
||||
|
<span class="status-pill pill-recording">识别中</span> |
||||
|
</div> |
||||
|
<div class="chat-area"> |
||||
|
<div class="msg-row ai"> |
||||
|
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div> |
||||
|
<div class="bubble bubble-ai">你好!我是晓晓,有什么可以帮你的吗?</div> |
||||
|
<span class="msg-time">14:23</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="bottom-bar"> |
||||
|
<!-- 居中实时识别文字(渐变背景) --> |
||||
|
<div class="ptt-realtime">今天天气怎么样?</div> |
||||
|
<div class="input-row" style="padding-top:4px;"> |
||||
|
<button class="mode-toggle" style="opacity:0.4;pointer-events:none;">⌨</button> |
||||
|
<div class="ptt-record-center"> |
||||
|
<div class="mini-wave"><span style="background:#fff;height:6px;"></span><span style="background:#fff;height:10px;"></span><span style="background:#fff;height:4px;"></span></div> |
||||
|
<span>松开发送 · 2s</span> |
||||
|
</div> |
||||
|
<button class="call-btn" style="opacity:0.4;pointer-events:none;">📞</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ⑤ AI 思考中(输入栏保留,半透明禁用) --> |
||||
|
<div> |
||||
|
<p class="label">⑤ AI 思考中(输入栏禁用)</p> |
||||
|
<div class="phone"> |
||||
|
<div class="appbar"> |
||||
|
<span class="appbar-back">‹</span> |
||||
|
<div class="appbar-avatar">晓</div> |
||||
|
<span class="appbar-name">移动精灵</span> |
||||
|
<span class="status-pill pill-thinking">处理中</span> |
||||
|
</div> |
||||
|
<div class="chat-area"> |
||||
|
<div class="msg-row ai"> |
||||
|
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div> |
||||
|
<div class="bubble bubble-ai">你好!我是晓晓,有什么可以帮你的吗?</div> |
||||
|
<span class="msg-time">14:23</span> |
||||
|
</div> |
||||
|
<div class="msg-row user"> |
||||
|
<div class="bubble bubble-user">今天天气怎么样?</div> |
||||
|
<span class="msg-time">14:24</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="bottom-bar"> |
||||
|
<div class="status-strip strip-thinking"> |
||||
|
<div class="spinner spinner-sm" style="color:#175cd3;"></div> |
||||
|
<span>AI 思考中...</span> |
||||
|
</div> |
||||
|
<div class="input-row" style="padding-top:4px; opacity:0.5; pointer-events:none;"> |
||||
|
<button class="mode-toggle">⌨</button> |
||||
|
<div class="ptt-btn">按住 说话</div> |
||||
|
<button class="call-btn">📞</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ⑥ AI 播报中(输入栏保留,可打断) --> |
||||
|
<div> |
||||
|
<p class="label">⑥ AI 播报中(可打断)</p> |
||||
|
<div class="phone"> |
||||
|
<div class="appbar"> |
||||
|
<span class="appbar-back">‹</span> |
||||
|
<div class="appbar-avatar">晓</div> |
||||
|
<span class="appbar-name">移动精灵</span> |
||||
|
<span class="status-pill pill-speaking">朗读中</span> |
||||
|
</div> |
||||
|
<div class="chat-area"> |
||||
|
<div class="msg-row user"> |
||||
|
<div class="bubble bubble-user">今天天气怎么样?</div> |
||||
|
<span class="msg-time">14:24</span> |
||||
|
</div> |
||||
|
<div class="msg-row ai"> |
||||
|
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div> |
||||
|
<div class="bubble bubble-ai">今天晴天,气温 18°C,微风,空气质量优,很适合出行哦</div> |
||||
|
<span class="msg-time">14:24</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="bottom-bar"> |
||||
|
<div class="status-strip strip-speaking"> |
||||
|
<div class="mini-wave"><span style="background:#16a34a;"></span><span style="background:#16a34a;"></span><span style="background:#16a34a;"></span></div> |
||||
|
<span>晓晓正在朗读...</span> |
||||
|
<button class="interrupt-btn">✕ 打断</button> |
||||
|
</div> |
||||
|
<div class="input-row" style="padding-top:4px; opacity:0.5; pointer-events:none;"> |
||||
|
<button class="mode-toggle">⌨</button> |
||||
|
<div class="ptt-btn">按住 说话</div> |
||||
|
<button class="call-btn">📞</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ⑦ 通话模式 - 等待说话(音量指示器 + 挂断) --> |
||||
|
<div> |
||||
|
<p class="label">⑦ 通话模式 - 等待说话</p> |
||||
|
<div class="phone"> |
||||
|
<div class="appbar"> |
||||
|
<span class="appbar-back">‹</span> |
||||
|
<div class="appbar-avatar">晓</div> |
||||
|
<span class="appbar-name">移动精灵</span> |
||||
|
<span class="status-pill pill-recording">通话中 <span class="pill-timer">01:23</span></span> |
||||
|
</div> |
||||
|
<div class="chat-area"> |
||||
|
<div class="msg-row user"> |
||||
|
<div class="bubble bubble-user">今天天气怎么样?</div> |
||||
|
<span class="msg-time">14:24</span> |
||||
|
</div> |
||||
|
<div class="msg-row ai"> |
||||
|
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div> |
||||
|
<div class="bubble bubble-ai">今天晴天,气温 18°C,微风,空气质量优</div> |
||||
|
<span class="msg-time">14:24</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="bottom-bar"> |
||||
|
<div class="call-bottom"> |
||||
|
<div class="call-status"> |
||||
|
<div class="call-dot" style="background:#34c759;"></div> |
||||
|
<span class="call-label">通话中</span> |
||||
|
<span class="call-label">01:23</span> |
||||
|
</div> |
||||
|
<!-- 音量指示器 --> |
||||
|
<div class="vol-indicator"> |
||||
|
<div class="vol-bar active" style="height:8px;"></div> |
||||
|
<div class="vol-bar active" style="height:12px;"></div> |
||||
|
<div class="vol-bar active" style="height:16px;"></div> |
||||
|
<div class="vol-bar" style="height:20px;"></div> |
||||
|
<div class="vol-bar" style="height:24px;"></div> |
||||
|
</div> |
||||
|
<!-- 挂断按钮 --> |
||||
|
<button class="hangup-btn"> |
||||
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 0 1 0-1.36C3.36 8.85 7.47 7 12 7s8.64 1.85 11.71 4.72c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.1-.7-.28-.79-.73-1.68-1.36-2.66-1.85-.33-.16-.56-.5-.56-.9v-3.1C14.15 9.25 12.6 9 12 9z"/></svg> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- ⑧ 通话模式 - AI 播报中 --> |
||||
|
<div> |
||||
|
<p class="label">⑧ 通话模式 - AI 播报</p> |
||||
|
<div class="phone"> |
||||
|
<div class="appbar"> |
||||
|
<span class="appbar-back">‹</span> |
||||
|
<div class="appbar-avatar">晓</div> |
||||
|
<span class="appbar-name">移动精灵</span> |
||||
|
<span class="status-pill pill-speaking">朗读中 <span class="pill-timer">01:48</span></span> |
||||
|
</div> |
||||
|
<div class="chat-area"> |
||||
|
<div class="msg-row user"> |
||||
|
<div class="bubble bubble-user">Flutter 有什么优势?</div> |
||||
|
<span class="msg-time">14:41</span> |
||||
|
</div> |
||||
|
<div class="msg-row ai"> |
||||
|
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div> |
||||
|
<div class="bubble bubble-ai">Flutter 的核心优势在于跨平台能力强、渲染性能高,以及 Dart 语言的高效开发体验...</div> |
||||
|
<span class="msg-time">14:41</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="bottom-bar"> |
||||
|
<div class="status-strip strip-speaking"> |
||||
|
<div class="mini-wave"><span style="background:#16a34a;"></span><span style="background:#16a34a;"></span><span style="background:#16a34a;"></span></div> |
||||
|
<span>晓晓正在朗读...</span> |
||||
|
<button class="interrupt-btn">✕ 打断</button> |
||||
|
</div> |
||||
|
<div class="call-bottom" style="padding-top:6px;"> |
||||
|
<div class="call-status"> |
||||
|
<div class="call-dot" style="background:#34c759;"></div> |
||||
|
<span class="call-label">通话中</span> |
||||
|
<span class="call-label">01:48</span> |
||||
|
</div> |
||||
|
<div class="vol-indicator"> |
||||
|
<div class="vol-bar" style="height:8px;"></div> |
||||
|
<div class="vol-bar" style="height:12px;"></div> |
||||
|
<div class="vol-bar" style="height:16px;"></div> |
||||
|
<div class="vol-bar" style="height:20px;"></div> |
||||
|
<div class="vol-bar" style="height:24px;"></div> |
||||
|
</div> |
||||
|
<button class="hangup-btn"> |
||||
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 0 1 0-1.36C3.36 8.85 7.47 7 12 7s8.64 1.85 11.71 4.72c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.1-.7-.28-.79-.73-1.68-1.36-2.66-1.85-.33-.16-.56-.5-.56-.9v-3.1C14.15 9.25 12.6 9 12 9z"/></svg> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,81 @@ |
|||||
|
#!/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.') |
||||
Loading…
Reference in new issue