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>
|
class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
with SingleTickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
_CallPhase _phase = _CallPhase.ringing;
|
_CallPhase _phase = _CallPhase.ringing;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
|
|
@ -43,6 +43,10 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
final List<double> _waveHeights = List.generate(20, (_) => 0.3);
|
final List<double> _waveHeights = List.generate(20, (_) => 0.3);
|
||||||
Timer? _waveTimer;
|
Timer? _waveTimer;
|
||||||
|
|
||||||
|
// Agent state (from lk.agent.state participant attribute)
|
||||||
|
String _agentState = '';
|
||||||
|
late AnimationController _thinkingController;
|
||||||
|
|
||||||
// Mute & speaker state
|
// Mute & speaker state
|
||||||
bool _isMuted = false;
|
bool _isMuted = false;
|
||||||
bool _isSpeaker = true;
|
bool _isSpeaker = true;
|
||||||
|
|
@ -69,6 +73,10 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 500),
|
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();
|
_onCallEnded();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
..on<ParticipantAttributesChanged>((event) {
|
||||||
|
final state = event.attributes['lk.agent.state'];
|
||||||
|
if (state != null && state != _agentState && mounted) {
|
||||||
|
setState(() => _agentState = state);
|
||||||
|
}
|
||||||
|
})
|
||||||
..on<DataReceivedEvent>((event) {
|
..on<DataReceivedEvent>((event) {
|
||||||
if (event.topic == 'text_reply') {
|
if (event.topic == 'text_reply') {
|
||||||
try {
|
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),
|
const Spacer(flex: 3),
|
||||||
_buildControls(),
|
_buildControls(),
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
|
|
@ -692,14 +707,19 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
margin: const EdgeInsets.only(left: 8),
|
margin: const EdgeInsets.only(left: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: _isReconnecting ? AppColors.warning : AppColors.success,
|
color: _isReconnecting
|
||||||
|
? AppColors.warning
|
||||||
|
: _agentState == 'thinking'
|
||||||
|
? AppColors.warning
|
||||||
|
: AppColors.success,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text('iAgent',
|
const Text('iAgent',
|
||||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(_durationLabel,
|
Text(
|
||||||
|
_agentState == 'thinking' ? '思考中...' : _durationLabel,
|
||||||
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
||||||
if (_isReconnecting) ...[
|
if (_isReconnecting) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
|
|
@ -861,6 +881,8 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
case _CallPhase.connecting:
|
case _CallPhase.connecting:
|
||||||
return '正在建立安全连接';
|
return '正在建立安全连接';
|
||||||
case _CallPhase.active:
|
case _CallPhase.active:
|
||||||
|
if (_agentState == 'thinking') return '思考中...';
|
||||||
|
if (_agentState == 'initializing') return '正在初始化...';
|
||||||
return '语音通话中';
|
return '语音通话中';
|
||||||
case _CallPhase.ended:
|
case _CallPhase.ended:
|
||||||
return _durationLabel;
|
return _durationLabel;
|
||||||
|
|
@ -896,7 +918,9 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
|
|
||||||
Widget _buildAvatar() {
|
Widget _buildAvatar() {
|
||||||
final isActive = _phase == _CallPhase.active;
|
final isActive = _phase == _CallPhase.active;
|
||||||
return AnimatedContainer(
|
final isThinking = isActive && _agentState == 'thinking';
|
||||||
|
|
||||||
|
final avatar = AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
width: isActive ? 100 : 120,
|
width: isActive ? 100 : 120,
|
||||||
height: isActive ? 100 : 120,
|
height: isActive ? 100 : 120,
|
||||||
|
|
@ -904,13 +928,18 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: AppColors.primary.withOpacity(0.15),
|
color: AppColors.primary.withOpacity(0.15),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isActive ? AppColors.success : AppColors.primary,
|
color: isThinking
|
||||||
|
? AppColors.warning
|
||||||
|
: isActive
|
||||||
|
? AppColors.success
|
||||||
|
: AppColors.primary,
|
||||||
width: 3,
|
width: 3,
|
||||||
),
|
),
|
||||||
boxShadow: isActive
|
boxShadow: isActive
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.success.withOpacity(0.3),
|
color: (isThinking ? AppColors.warning : AppColors.success)
|
||||||
|
.withOpacity(0.3),
|
||||||
blurRadius: 24,
|
blurRadius: 24,
|
||||||
spreadRadius: 4,
|
spreadRadius: 4,
|
||||||
),
|
),
|
||||||
|
|
@ -920,9 +949,39 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.smart_toy,
|
Icons.smart_toy,
|
||||||
size: isActive ? 48 : 56,
|
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() {
|
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() {
|
Widget _buildTextInputArea() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
|
@ -1140,6 +1229,7 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
_durationTimer?.cancel();
|
_durationTimer?.cancel();
|
||||||
_waveTimer?.cancel();
|
_waveTimer?.cancel();
|
||||||
_waveController.dispose();
|
_waveController.dispose();
|
||||||
|
_thinkingController.dispose();
|
||||||
_stopwatch.stop();
|
_stopwatch.stop();
|
||||||
_room?.disconnect();
|
_room?.disconnect();
|
||||||
_room?.dispose();
|
_room?.dispose();
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from livekit.agents import (
|
||||||
cli,
|
cli,
|
||||||
room_io,
|
room_io,
|
||||||
)
|
)
|
||||||
|
from livekit.agents.voice import BackgroundAudioPlayer, BuiltinAudioClip
|
||||||
from livekit.agents.utils import http_context
|
from livekit.agents.utils import http_context
|
||||||
from livekit.plugins import silero
|
from livekit.plugins import silero
|
||||||
|
|
||||||
|
|
@ -309,6 +310,12 @@ async def entrypoint(ctx: JobContext) -> None:
|
||||||
room_output_options=room_io.RoomOutputOptions(),
|
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)
|
logger.info("Voice session started for room %s", ctx.room.name)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue