674 lines
23 KiB
Dart
674 lines
23 KiB
Dart
import 'dart:async';
|
||
import 'dart:typed_data';
|
||
import 'package:dio/dio.dart' show FormData, MultipartFile;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:record/record.dart';
|
||
import '../../../../core/audio/noise_reducer.dart';
|
||
import '../../../../core/audio/speech_enhancer.dart';
|
||
import '../../../../core/config/api_endpoints.dart';
|
||
import '../../../../core/network/dio_client.dart';
|
||
import '../../../../core/theme/app_colors.dart';
|
||
import '../../domain/entities/chat_message.dart';
|
||
import '../providers/chat_providers.dart';
|
||
import '../widgets/message_bubble.dart';
|
||
import '../widgets/tool_execution_card.dart';
|
||
import '../widgets/approval_action_card.dart';
|
||
import '../widgets/agent_thinking_indicator.dart';
|
||
import '../widgets/stream_text_widget.dart';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Chat page – ConsumerStatefulWidget
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class ChatPage extends ConsumerStatefulWidget {
|
||
const ChatPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<ChatPage> createState() => _ChatPageState();
|
||
}
|
||
|
||
class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderStateMixin {
|
||
final _messageController = TextEditingController();
|
||
final _scrollController = ScrollController();
|
||
|
||
// -- Voice input (record + GTCRN denoise + backend STT) -------------------
|
||
late final AudioRecorder _recorder;
|
||
final SpeechEnhancer _enhancer = SpeechEnhancer();
|
||
bool _isListening = false;
|
||
bool _isTranscribing = false;
|
||
List<List<int>> _audioChunks = [];
|
||
StreamSubscription<List<int>>? _audioSubscription;
|
||
late AnimationController _micPulseController;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_recorder = AudioRecorder();
|
||
_enhancer.init(); // load GTCRN model in background
|
||
_micPulseController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 800),
|
||
);
|
||
}
|
||
|
||
Future<void> _startListening() async {
|
||
final hasPermission = await _recorder.hasPermission();
|
||
if (!hasPermission || !mounted) return;
|
||
|
||
setState(() => _isListening = true);
|
||
_micPulseController.repeat(reverse: true);
|
||
_audioChunks = [];
|
||
|
||
// Stream raw PCM 16kHz mono with platform noise suppression + AGC
|
||
final stream = await _recorder.startStream(const RecordConfig(
|
||
encoder: AudioEncoder.pcm16bits,
|
||
sampleRate: 16000,
|
||
numChannels: 1,
|
||
noiseSuppress: true,
|
||
autoGain: true,
|
||
));
|
||
|
||
_audioSubscription = stream.listen((data) {
|
||
_audioChunks.add(data);
|
||
});
|
||
}
|
||
|
||
Future<void> _stopListening({bool autoSubmit = false}) async {
|
||
if (!_isListening) return;
|
||
|
||
// Stop recording and stream
|
||
await _recorder.stop();
|
||
await _audioSubscription?.cancel();
|
||
_audioSubscription = null;
|
||
_micPulseController.stop();
|
||
_micPulseController.reset();
|
||
|
||
if (!mounted) return;
|
||
setState(() => _isListening = false);
|
||
|
||
if (!autoSubmit || _audioChunks.isEmpty) return;
|
||
|
||
// Transcribe via backend
|
||
setState(() => _isTranscribing = true);
|
||
|
||
try {
|
||
// Combine recorded chunks into a single PCM buffer
|
||
final allBytes = _audioChunks.expand((c) => c).toList();
|
||
final pcmData = Uint8List.fromList(allBytes);
|
||
_audioChunks = [];
|
||
|
||
// GTCRN ML denoise (light) + trim leading/trailing silence
|
||
final denoised = _enhancer.enhance(pcmData);
|
||
final trimmed = NoiseReducer.trimSilence(denoised);
|
||
|
||
if (trimmed.isEmpty) {
|
||
if (mounted) setState(() => _isTranscribing = false);
|
||
return;
|
||
}
|
||
|
||
// POST to backend /voice/transcribe
|
||
final dio = ref.read(dioClientProvider);
|
||
final formData = FormData.fromMap({
|
||
'audio': MultipartFile.fromBytes(trimmed, filename: 'audio.pcm'),
|
||
});
|
||
final response = await dio.post(
|
||
ApiEndpoints.transcribe,
|
||
data: formData,
|
||
);
|
||
|
||
final text =
|
||
(response.data as Map<String, dynamic>)['text'] as String? ?? '';
|
||
if (text.isNotEmpty && mounted) {
|
||
_messageController.text = text;
|
||
_send();
|
||
}
|
||
} catch (_) {
|
||
// Voice failed silently – user can still type
|
||
} finally {
|
||
if (mounted) setState(() => _isTranscribing = false);
|
||
}
|
||
}
|
||
|
||
// -- Send ------------------------------------------------------------------
|
||
|
||
void _send() {
|
||
final text = _messageController.text.trim();
|
||
if (text.isEmpty) return;
|
||
_messageController.clear();
|
||
ref.read(chatProvider.notifier).sendMessage(text);
|
||
_scrollToBottom();
|
||
}
|
||
|
||
void _scrollToBottom() {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (_scrollController.hasClients) {
|
||
_scrollController.animateTo(
|
||
_scrollController.position.maxScrollExtent + 80,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
// -- Message widget dispatch -----------------------------------------------
|
||
|
||
Widget _buildMessageWidget(ChatMessage message, ChatState chatState) {
|
||
final isLastMessage = chatState.messages.last.id == message.id;
|
||
final isStreamingNow = isLastMessage && chatState.isStreaming;
|
||
|
||
switch (message.type) {
|
||
case MessageType.toolUse:
|
||
case MessageType.toolResult:
|
||
if (message.toolExecution != null) {
|
||
return ToolExecutionCard(toolExecution: message.toolExecution!);
|
||
}
|
||
return MessageBubble(message: message);
|
||
|
||
case MessageType.approval:
|
||
if (message.approvalRequest != null) {
|
||
return ApprovalActionCard(
|
||
approvalRequest: message.approvalRequest!,
|
||
onApprove: () => ref.read(chatProvider.notifier)
|
||
.approveCommand(message.approvalRequest!.taskId),
|
||
onReject: (reason) => ref.read(chatProvider.notifier)
|
||
.rejectCommand(message.approvalRequest!.taskId, reason: reason),
|
||
);
|
||
}
|
||
return MessageBubble(message: message);
|
||
|
||
case MessageType.standingOrderDraft:
|
||
return _StandingOrderDraftCard(
|
||
draft: message.metadata ?? {},
|
||
onConfirm: () => ref.read(chatProvider.notifier)
|
||
.confirmStandingOrder(message.metadata ?? {}),
|
||
);
|
||
|
||
case MessageType.thinking:
|
||
// AI thinking content — italic label + streaming text
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
constraints: BoxConstraints(
|
||
maxWidth: MediaQuery.of(context).size.width * 0.78,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(16),
|
||
topRight: Radius.circular(16),
|
||
bottomLeft: Radius.circular(4),
|
||
bottomRight: Radius.circular(16),
|
||
),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'思考中...',
|
||
style: TextStyle(
|
||
color: AppColors.textMuted,
|
||
fontSize: 11,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
StreamTextWidget(
|
||
text: message.content,
|
||
isStreaming: isStreamingNow,
|
||
style: TextStyle(
|
||
color: AppColors.textSecondary,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
|
||
case MessageType.text:
|
||
default:
|
||
// User messages → standard bubble
|
||
if (message.role == MessageRole.user) {
|
||
return MessageBubble(message: message);
|
||
}
|
||
// AI text messages — use StreamTextWidget when actively streaming
|
||
if (isStreamingNow) {
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
constraints: BoxConstraints(
|
||
maxWidth: MediaQuery.of(context).size.width * 0.78,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(16),
|
||
topRight: Radius.circular(16),
|
||
bottomLeft: Radius.circular(4),
|
||
bottomRight: Radius.circular(16),
|
||
),
|
||
),
|
||
child: StreamTextWidget(
|
||
text: message.content,
|
||
isStreaming: true,
|
||
style: const TextStyle(
|
||
color: AppColors.textPrimary,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return MessageBubble(message: message);
|
||
}
|
||
}
|
||
|
||
// -- Build -----------------------------------------------------------------
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final chatState = ref.watch(chatProvider);
|
||
|
||
// Auto-scroll when messages change
|
||
ref.listen(chatProvider, (_, __) => _scrollToBottom());
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('AI 对话'),
|
||
actions: [
|
||
// Stop button during streaming
|
||
if (chatState.isStreaming)
|
||
IconButton(
|
||
icon: const Icon(Icons.stop_circle_outlined),
|
||
tooltip: '停止',
|
||
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
|
||
),
|
||
if (chatState.messages.isNotEmpty)
|
||
IconButton(
|
||
icon: const Icon(Icons.delete_outline),
|
||
tooltip: '清空对话',
|
||
onPressed: () => ref.read(chatProvider.notifier).clearChat(),
|
||
),
|
||
// Voice input button
|
||
GestureDetector(
|
||
onLongPressStart: (_) => _startListening(),
|
||
onLongPressEnd: (_) => _stopListening(autoSubmit: true),
|
||
child: AnimatedBuilder(
|
||
animation: _micPulseController,
|
||
builder: (context, child) {
|
||
return IconButton(
|
||
icon: Icon(
|
||
_isListening ? Icons.mic : Icons.mic_none,
|
||
color: _isListening
|
||
? Color.lerp(AppColors.error, AppColors.warning, _micPulseController.value)
|
||
: null,
|
||
size: _isListening ? 28 + (_micPulseController.value * 4) : 24,
|
||
),
|
||
onPressed: () {
|
||
if (_isListening) {
|
||
_stopListening(autoSubmit: true);
|
||
} else {
|
||
_startListening();
|
||
}
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
// Error banner
|
||
if (chatState.error != null)
|
||
MaterialBanner(
|
||
content: Text(chatState.error!, style: const TextStyle(color: AppColors.error)),
|
||
backgroundColor: AppColors.error.withOpacity(0.1),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () =>
|
||
ref.read(chatProvider.notifier).clearChat(),
|
||
child: const Text('关闭'),
|
||
),
|
||
],
|
||
),
|
||
|
||
// Agent status bar
|
||
if (chatState.agentStatus != AgentStatus.idle && chatState.agentStatus != AgentStatus.error)
|
||
_AgentStatusBar(status: chatState.agentStatus),
|
||
|
||
// Message list
|
||
Expanded(
|
||
child: chatState.messages.isEmpty
|
||
? Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(Icons.smart_toy_outlined, size: 64, color: AppColors.textMuted),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'开始与 iAgent 对话',
|
||
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'长按麦克风按钮进行语音输入',
|
||
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
)
|
||
: ListView.builder(
|
||
controller: _scrollController,
|
||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||
itemCount: chatState.messages.length +
|
||
(chatState.agentStatus == AgentStatus.thinking ? 1 : 0),
|
||
itemBuilder: (context, index) {
|
||
// Trailing thinking indicator
|
||
if (index == chatState.messages.length) {
|
||
return const AgentThinkingIndicator();
|
||
}
|
||
return _buildMessageWidget(
|
||
chatState.messages[index],
|
||
chatState,
|
||
);
|
||
},
|
||
),
|
||
),
|
||
|
||
// Voice listening / transcribing indicator
|
||
if (_isListening || _isTranscribing)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||
color: (_isListening ? AppColors.error : AppColors.primary)
|
||
.withOpacity(0.1),
|
||
child: Row(
|
||
children: [
|
||
if (_isListening)
|
||
AnimatedBuilder(
|
||
animation: _micPulseController,
|
||
builder: (context, _) => Icon(
|
||
Icons.mic,
|
||
color: AppColors.error,
|
||
size: 20 + (_micPulseController.value * 4),
|
||
),
|
||
)
|
||
else
|
||
const SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
_isListening ? '正在聆听...' : '正在转写...',
|
||
style: TextStyle(
|
||
color: _isListening ? AppColors.error : AppColors.primary,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
if (_isListening)
|
||
TextButton(
|
||
onPressed: () => _stopListening(),
|
||
child: const Text('取消'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// Input row
|
||
_buildInputArea(chatState),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInputArea(ChatState chatState) {
|
||
final isDisabled = chatState.isStreaming || chatState.agentStatus == AgentStatus.awaitingApproval;
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
|
||
),
|
||
child: SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (chatState.agentStatus == AgentStatus.awaitingApproval)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Text(
|
||
'等待审批中...',
|
||
style: TextStyle(color: AppColors.warning, fontSize: 12),
|
||
),
|
||
),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _messageController,
|
||
decoration: const InputDecoration(
|
||
hintText: '向 iAgent 提问...',
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.all(Radius.circular(24)),
|
||
),
|
||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||
),
|
||
textInputAction: TextInputAction.send,
|
||
onSubmitted: (_) => _send(),
|
||
enabled: !isDisabled,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
if (chatState.isStreaming)
|
||
IconButton(
|
||
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error),
|
||
tooltip: '停止',
|
||
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
|
||
)
|
||
else
|
||
IconButton(
|
||
icon: const Icon(Icons.send),
|
||
onPressed: isDisabled ? null : _send,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_messageController.dispose();
|
||
_scrollController.dispose();
|
||
_micPulseController.dispose();
|
||
_audioSubscription?.cancel();
|
||
_recorder.dispose();
|
||
_enhancer.dispose();
|
||
super.dispose();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Agent status bar
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class _AgentStatusBar extends StatelessWidget {
|
||
final AgentStatus status;
|
||
const _AgentStatusBar({required this.status});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final (String label, Color color, IconData icon) = switch (status) {
|
||
AgentStatus.thinking => ('AI 正在思考...', AppColors.info, Icons.psychology),
|
||
AgentStatus.executing => ('正在执行命令...', AppColors.primary, Icons.terminal),
|
||
AgentStatus.awaitingApproval => ('等待审批', AppColors.warning, Icons.shield_outlined),
|
||
_ => ('', AppColors.textMuted, Icons.info_outline),
|
||
};
|
||
|
||
if (label.isEmpty) return const SizedBox.shrink();
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
color: color.withOpacity(0.1),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: status == AgentStatus.executing
|
||
? CircularProgressIndicator(strokeWidth: 2, color: color)
|
||
: Icon(icon, size: 16, color: color),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
label,
|
||
style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Standing order draft card
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class _StandingOrderDraftCard extends StatelessWidget {
|
||
final Map<String, dynamic> draft;
|
||
final VoidCallback onConfirm;
|
||
|
||
const _StandingOrderDraftCard({
|
||
required this.draft,
|
||
required this.onConfirm,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final name = draft['name'] as String? ?? draft['orderName'] as String? ?? '未命名指令';
|
||
final cron = draft['cron'] as String? ?? draft['schedule'] as String? ?? '';
|
||
final command = draft['command'] as String? ?? draft['prompt'] as String? ?? '';
|
||
final targets = draft['targets'] as List? ?? draft['servers'] as List? ?? [];
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: AppColors.primary.withOpacity(0.4)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Header
|
||
Row(
|
||
children: [
|
||
Icon(Icons.schedule, size: 18, color: AppColors.primary),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'常驻指令草案',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 14,
|
||
color: AppColors.primary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
const SizedBox(height: 10),
|
||
|
||
// Name
|
||
Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||
|
||
if (cron.isNotEmpty) ...[
|
||
const SizedBox(height: 6),
|
||
Row(
|
||
children: [
|
||
const Icon(Icons.timer_outlined, size: 14, color: AppColors.textMuted),
|
||
const SizedBox(width: 6),
|
||
Text(cron, style: const TextStyle(color: AppColors.textSecondary, fontSize: 12, fontFamily: 'monospace')),
|
||
],
|
||
),
|
||
],
|
||
|
||
if (command.isNotEmpty) ...[
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.background,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: SelectableText(
|
||
command,
|
||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppColors.textSecondary),
|
||
),
|
||
),
|
||
],
|
||
|
||
if (targets.isNotEmpty) ...[
|
||
const SizedBox(height: 6),
|
||
Row(
|
||
children: [
|
||
const Icon(Icons.dns, size: 14, color: AppColors.textMuted),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
'目标: ${targets.join(", ")}',
|
||
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
|
||
const SizedBox(height: 12),
|
||
|
||
// Action buttons
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
OutlinedButton(
|
||
onPressed: () {
|
||
// Dismiss — no specific API call needed
|
||
},
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: AppColors.textSecondary,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
minimumSize: Size.zero,
|
||
),
|
||
child: const Text('取消', style: TextStyle(fontSize: 13)),
|
||
),
|
||
const SizedBox(width: 8),
|
||
FilledButton(
|
||
onPressed: onConfirm,
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: AppColors.primary,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
minimumSize: Size.zero,
|
||
),
|
||
child: const Text('确认', style: TextStyle(fontSize: 13)),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|