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:
parent
121ca5a5aa
commit
33bd1aa3aa
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue