it0/it0_app/lib/features/chat/presentation/pages/chat_page.dart

674 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)),
),
],
),
],
),
);
}
}