diff --git a/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart b/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart index 213635e..4fe75f7 100644 --- a/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart +++ b/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart @@ -25,7 +25,7 @@ class AgentCallPage extends ConsumerStatefulWidget { } class _AgentCallPageState extends ConsumerState - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { _CallPhase _phase = _CallPhase.ringing; String? _errorMessage; @@ -43,6 +43,10 @@ class _AgentCallPageState extends ConsumerState final List _waveHeights = List.generate(20, (_) => 0.3); Timer? _waveTimer; + // Agent state (from lk.agent.state participant attribute) + String _agentState = ''; + late AnimationController _thinkingController; + // Mute & speaker state bool _isMuted = false; bool _isSpeaker = true; @@ -69,6 +73,10 @@ class _AgentCallPageState extends ConsumerState vsync: this, duration: const Duration(milliseconds: 500), ); + _thinkingController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); } // --------------------------------------------------------------------------- @@ -141,6 +149,12 @@ class _AgentCallPageState extends ConsumerState _onCallEnded(); } }) + ..on((event) { + final state = event.attributes['lk.agent.state']; + if (state != null && state != _agentState && mounted) { + setState(() => _agentState = state); + } + }) ..on((event) { if (event.topic == 'text_reply') { try { @@ -630,7 +644,8 @@ class _AgentCallPageState extends ConsumerState ], ), ), - if (_phase == _CallPhase.active) _buildWaveform(), + if (_phase == _CallPhase.active) + _agentState == 'thinking' ? _buildThinkingDots() : _buildWaveform(), const Spacer(flex: 3), _buildControls(), const SizedBox(height: 48), @@ -692,14 +707,19 @@ class _AgentCallPageState extends ConsumerState margin: const EdgeInsets.only(left: 8), decoration: BoxDecoration( shape: BoxShape.circle, - color: _isReconnecting ? AppColors.warning : AppColors.success, + color: _isReconnecting + ? AppColors.warning + : _agentState == 'thinking' + ? AppColors.warning + : AppColors.success, ), ), const SizedBox(width: 8), const Text('iAgent', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)), const SizedBox(width: 8), - Text(_durationLabel, + Text( + _agentState == 'thinking' ? '思考中...' : _durationLabel, style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)), if (_isReconnecting) ...[ const SizedBox(width: 6), @@ -861,6 +881,8 @@ class _AgentCallPageState extends ConsumerState case _CallPhase.connecting: return '正在建立安全连接'; case _CallPhase.active: + if (_agentState == 'thinking') return '思考中...'; + if (_agentState == 'initializing') return '正在初始化...'; return '语音通话中'; case _CallPhase.ended: return _durationLabel; @@ -896,7 +918,9 @@ class _AgentCallPageState extends ConsumerState Widget _buildAvatar() { final isActive = _phase == _CallPhase.active; - return AnimatedContainer( + final isThinking = isActive && _agentState == 'thinking'; + + final avatar = AnimatedContainer( duration: const Duration(milliseconds: 400), width: isActive ? 100 : 120, height: isActive ? 100 : 120, @@ -904,13 +928,18 @@ class _AgentCallPageState extends ConsumerState shape: BoxShape.circle, color: AppColors.primary.withOpacity(0.15), border: Border.all( - color: isActive ? AppColors.success : AppColors.primary, + color: isThinking + ? AppColors.warning + : isActive + ? AppColors.success + : AppColors.primary, width: 3, ), boxShadow: isActive ? [ BoxShadow( - color: AppColors.success.withOpacity(0.3), + color: (isThinking ? AppColors.warning : AppColors.success) + .withOpacity(0.3), blurRadius: 24, spreadRadius: 4, ), @@ -920,9 +949,39 @@ class _AgentCallPageState extends ConsumerState child: Icon( Icons.smart_toy, size: isActive ? 48 : 56, - color: isActive ? AppColors.success : AppColors.primary, + color: isThinking + ? AppColors.warning + : isActive + ? AppColors.success + : AppColors.primary, ), ); + + if (!isThinking) return avatar; + + // Pulsing glow ring around avatar when thinking + return AnimatedBuilder( + animation: _thinkingController, + builder: (context, child) { + final pulse = (sin(_thinkingController.value * 2 * pi) + 1) / 2; + return Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: AppColors.warning.withOpacity(0.15 + pulse * 0.25), + blurRadius: 20 + pulse * 16, + spreadRadius: pulse * 8, + ), + ], + ), + child: child, + ); + }, + child: avatar, + ); } Widget _buildWaveform() { @@ -947,6 +1006,36 @@ class _AgentCallPageState extends ConsumerState ); } + /// Three bouncing dots animation shown while agent is thinking. + Widget _buildThinkingDots() { + return SizedBox( + height: 60, + child: AnimatedBuilder( + animation: _thinkingController, + builder: (context, _) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(3, (i) { + final offset = i * 0.33; + final t = (_thinkingController.value + offset) % 1.0; + final bounce = sin(t * pi); + return Container( + width: 12, + height: 12, + margin: const EdgeInsets.symmetric(horizontal: 6), + transform: Matrix4.translationValues(0, -bounce * 16, 0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.warning.withOpacity(0.5 + bounce * 0.5), + ), + ); + }), + ); + }, + ), + ); + } + Widget _buildTextInputArea() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), @@ -1140,6 +1229,7 @@ class _AgentCallPageState extends ConsumerState _durationTimer?.cancel(); _waveTimer?.cancel(); _waveController.dispose(); + _thinkingController.dispose(); _stopwatch.stop(); _room?.disconnect(); _room?.dispose(); diff --git a/packages/services/voice-agent/src/agent.py b/packages/services/voice-agent/src/agent.py index e1f26c6..9fa57a1 100644 --- a/packages/services/voice-agent/src/agent.py +++ b/packages/services/voice-agent/src/agent.py @@ -23,6 +23,7 @@ from livekit.agents import ( cli, room_io, ) +from livekit.agents.voice import BackgroundAudioPlayer, BuiltinAudioClip from livekit.agents.utils import http_context from livekit.plugins import silero @@ -309,6 +310,12 @@ async def entrypoint(ctx: JobContext) -> None: room_output_options=room_io.RoomOutputOptions(), ) + # Play keyboard typing sound while agent is thinking (waiting for LLM) + bg_audio = BackgroundAudioPlayer( + thinking_sound=BuiltinAudioClip.KEYBOARD_TYPING, + ) + await bg_audio.start(room=ctx.room, agent_session=session) + logger.info("Voice session started for room %s", ctx.room.name) # ---------------------------------------------------------------------