feat: add "thinking" state indicator for voice calls

- voice-agent: enable BackgroundAudioPlayer with keyboard typing sound
  during LLM thinking state (auto-plays when agent enters "thinking",
  stops when "speaking" starts)
- Flutter: monitor lk.agent.state participant attribute from LiveKit
  agent, show pulsing dots animation + "思考中..." text when thinking,
  avatar border changes to warning color with pulsing glow ring
- Both call mode and chat mode headers show thinking state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-03 05:45:04 -08:00
parent 121ca5a5aa
commit 33bd1aa3aa
2 changed files with 105 additions and 8 deletions

View File

@ -25,7 +25,7 @@ class AgentCallPage extends ConsumerStatefulWidget {
}
class _AgentCallPageState extends ConsumerState<AgentCallPage>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin {
_CallPhase _phase = _CallPhase.ringing;
String? _errorMessage;
@ -43,6 +43,10 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
final List<double> _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<AgentCallPage>
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<AgentCallPage>
_onCallEnded();
}
})
..on<ParticipantAttributesChanged>((event) {
final state = event.attributes['lk.agent.state'];
if (state != null && state != _agentState && mounted) {
setState(() => _agentState = state);
}
})
..on<DataReceivedEvent>((event) {
if (event.topic == 'text_reply') {
try {
@ -630,7 +644,8 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
],
),
),
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<AgentCallPage>
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<AgentCallPage>
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<AgentCallPage>
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<AgentCallPage>
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<AgentCallPage>
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<AgentCallPage>
);
}
/// 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<AgentCallPage>
_durationTimer?.cancel();
_waveTimer?.cancel();
_waveController.dispose();
_thinkingController.dispose();
_stopwatch.stop();
_room?.disconnect();
_room?.dispose();

View File

@ -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)
# ---------------------------------------------------------------------