0812
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AIAvatar extends StatelessWidget {
|
||||
class AssistantAvatar extends StatelessWidget {
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
const AIAvatar({
|
||||
const AssistantAvatar({
|
||||
super.key,
|
||||
this.width = 120,
|
||||
this.height = 140,
|
||||
this.width = 132,
|
||||
this.height = 132,
|
||||
});
|
||||
|
||||
@override
|
||||
35
lib/widgets/chat_box.dart
Normal file
35
lib/widgets/chat_box.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/chat_message.dart';
|
||||
import 'chat_bubble.dart';
|
||||
|
||||
class ChatBox extends StatelessWidget {
|
||||
final ScrollController scrollController;
|
||||
final List<ChatMessage> messages;
|
||||
const ChatBox({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.messages,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: messages.length,
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[messages.length - 1 - index];
|
||||
final isTop = index == messages.length - 1;
|
||||
final isBottom = index == 0;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: isTop ? 0 : 6,
|
||||
bottom: isBottom ? 0 : 6,
|
||||
),
|
||||
child: ChatBubble(message: message),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,346 @@
|
||||
import 'package:ai_chat_assistant/utils/common_util.dart';
|
||||
import 'package:basic_intl/intl.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import '../enums/message_status.dart';
|
||||
import '../models/chat_message.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/message_service.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
|
||||
class ChatBubble extends StatelessWidget {
|
||||
class ChatBubble extends StatefulWidget {
|
||||
final ChatMessage message;
|
||||
|
||||
const ChatBubble({super.key, required this.message});
|
||||
|
||||
@override
|
||||
State<ChatBubble> createState() => _ChatBubbleState();
|
||||
}
|
||||
|
||||
class _ChatBubbleState extends State<ChatBubble> {
|
||||
bool _liked = false;
|
||||
bool _disliked = false;
|
||||
|
||||
ChatMessage get message => widget.message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isThinking = message.status == MessageStatus.thinking;
|
||||
return Align(
|
||||
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: message.isUser
|
||||
? const Color(0xFF6C63FF)
|
||||
: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
message.text,
|
||||
style: TextStyle(
|
||||
color: message.isUser ? Colors.white : Colors.white,
|
||||
fontSize: 16,
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
message.isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 50,
|
||||
),
|
||||
width: isThinking ? double.infinity : null,
|
||||
child: isThinking
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: message.isUser
|
||||
? CommonUtil.commonColor
|
||||
: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: _buildAssistantContent(isThinking),
|
||||
)
|
||||
: IntrinsicWidth(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: message.isUser
|
||||
? CommonUtil.commonColor
|
||||
: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: message.isUser
|
||||
? _buildUserContent()
|
||||
: _buildAssistantContent(isThinking),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildUserContent() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (message.text.trim().isEmpty) ...[
|
||||
_buildStatusRow(hasBottomMargin: false),
|
||||
] else ...[
|
||||
_buildStatusRow(hasBottomMargin: true),
|
||||
Text(
|
||||
message.text,
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
)
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssistantContent(bool isThinking) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildStatusRow(hasBottomMargin: message.text.trim().isNotEmpty),
|
||||
if (message.text.trim().isNotEmpty) _buildMarkdownBody(),
|
||||
_buildDisclaimer(isThinking),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusRow({required bool hasBottomMargin}) {
|
||||
Widget icon;
|
||||
Color color;
|
||||
|
||||
switch (message.status) {
|
||||
case MessageStatus.listening:
|
||||
case MessageStatus.recognizing:
|
||||
case MessageStatus.thinking:
|
||||
case MessageStatus.executing:
|
||||
icon = const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue)),
|
||||
);
|
||||
color = Colors.white;
|
||||
break;
|
||||
case MessageStatus.completed:
|
||||
case MessageStatus.success:
|
||||
icon = const Icon(Icons.check_circle, size: 16, color: Colors.green);
|
||||
color = Colors.green;
|
||||
break;
|
||||
case MessageStatus.failure:
|
||||
icon = const Icon(Icons.error, size: 16, color: Colors.red);
|
||||
color = Colors.red;
|
||||
break;
|
||||
case MessageStatus.aborted:
|
||||
icon = const Icon(Icons.block, size: 16, color: Colors.grey);
|
||||
color = Colors.grey;
|
||||
break;
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _statusRow(
|
||||
icon: icon, color: color, hasBottomMargin: hasBottomMargin);
|
||||
}
|
||||
|
||||
Widget _statusRow({
|
||||
required Widget icon,
|
||||
required Color color,
|
||||
required bool hasBottomMargin,
|
||||
Color? backgroundColor,
|
||||
BorderRadius? borderRadius,
|
||||
}) {
|
||||
return Container(
|
||||
margin: hasBottomMargin ? const EdgeInsets.only(bottom: 6) : null,
|
||||
decoration: backgroundColor != null
|
||||
? BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: borderRadius ?? BorderRadius.circular(0))
|
||||
: null,
|
||||
padding: backgroundColor != null ? const EdgeInsets.all(6) : null,
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
message.isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
Intl.getCurrentLocale().startsWith('zh')
|
||||
? message.status.chinese
|
||||
: message.status.english,
|
||||
style: TextStyle(
|
||||
fontSize: 16, color: color, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMarkdownBody() {
|
||||
return MarkdownBody(
|
||||
data: message.text,
|
||||
styleSheet: _markdownStyleSheet,
|
||||
sizedImageBuilder: (config) {
|
||||
return Image.network(
|
||||
config.uri.toString(),
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
},
|
||||
onTapLink: (text, href, title) {
|
||||
// todo
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static final MarkdownStyleSheet _markdownStyleSheet = MarkdownStyleSheet(
|
||||
p: const TextStyle(fontSize: 16, color: Colors.white, height: 1.5),
|
||||
h1: const TextStyle(
|
||||
fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
h2: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
h3: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
h4: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
|
||||
h5: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
|
||||
h6: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
|
||||
strong: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.bold),
|
||||
em: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
height: 1.5,
|
||||
fontStyle: FontStyle.italic),
|
||||
code: const TextStyle(
|
||||
fontSize: 16, backgroundColor: Color(0xFFF5F5F5), color: Colors.white),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(4)),
|
||||
blockquote: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
border: Border(left: BorderSide(color: Colors.grey, width: 4))),
|
||||
listBullet: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
|
||||
tableHead: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.bold),
|
||||
tableBody: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
|
||||
tableBorder: TableBorder.all(color: Colors.grey, width: 1),
|
||||
tableCellsPadding: const EdgeInsets.all(8),
|
||||
tableColumnWidth: const FlexColumnWidth(),
|
||||
);
|
||||
|
||||
Widget _buildDisclaimer(bool isThinking) {
|
||||
if (!isThinking && message.status != MessageStatus.completed) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
const Divider(color: Colors.grey, height: 1),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: 24,
|
||||
child: isThinking
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Provider.of<MessageService>(context, listen: false)
|
||||
.abortReply();
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.stop_circle_outlined,
|
||||
size: 16, color: CommonUtil.commonColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
Intl.getCurrentLocale().startsWith('zh')
|
||||
? '停止回答'
|
||||
: 'Stop',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
Intl.getCurrentLocale().startsWith('zh')
|
||||
? '内容由AI生成,具体以实际情况为准'
|
||||
: 'Generated by AI, for reference only.',
|
||||
style:
|
||||
const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
await Clipboard.setData(ClipboardData(
|
||||
text:
|
||||
CommonUtil.cleanText(message.text, false)));
|
||||
Fluttertoast.showToast(
|
||||
msg: Intl.getCurrentLocale().startsWith('zh')
|
||||
? '已复制到剪贴板'
|
||||
: 'Copied to clipboard');
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(left: 12),
|
||||
child: Icon(Icons.content_copy,
|
||||
size: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_liked = !_liked;
|
||||
if (_liked) _disliked = false;
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Icon(
|
||||
Icons.thumb_up_off_alt,
|
||||
size: 16,
|
||||
color: _liked
|
||||
? CommonUtil.commonColor
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_disliked = !_disliked;
|
||||
if (_disliked) _liked = false;
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Icon(
|
||||
Icons.thumb_down_off_alt,
|
||||
size: 16,
|
||||
color: _disliked
|
||||
? CommonUtil.commonColor
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
91
lib/widgets/chat_footer.dart
Normal file
91
lib/widgets/chat_footer.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'package:basic_intl/intl.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/message_service.dart';
|
||||
import '../utils/common_util.dart';
|
||||
import 'voice_animation.dart';
|
||||
|
||||
class ChatFooter extends StatelessWidget {
|
||||
final VoidCallback? onClear;
|
||||
|
||||
const ChatFooter({super.key, this.onClear});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.transparent,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, bottom: 12),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
color: CommonUtil.commonColor),
|
||||
iconSize: 30,
|
||||
tooltip: '清除会话',
|
||||
onPressed: onClear,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Consumer<MessageService>(
|
||||
builder: (context, messageService, child) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
messageService.abortReply();
|
||||
},
|
||||
onLongPress: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
HapticFeedback.heavyImpact();
|
||||
});
|
||||
messageService.startVoiceInput();
|
||||
},
|
||||
onLongPressUp: () {
|
||||
HapticFeedback.lightImpact();
|
||||
messageService.stopAndProcessVoiceInput();
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: 48,
|
||||
width: double.infinity,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: messageService.isRecording
|
||||
? Color(0xFF8E24AA).withValues(alpha: 0.42)
|
||||
: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: messageService.isRecording
|
||||
? const VoiceWaveAnimation()
|
||||
: Text(
|
||||
Intl.getCurrentLocale().startsWith('zh')
|
||||
? '按 住 说 话'
|
||||
: 'Hold to Speak',
|
||||
style: TextStyle(
|
||||
color: CommonUtil.commonColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12, right: 12),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.keyboard, color: CommonUtil.commonColor),
|
||||
iconSize: 30,
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/widgets/chat_header.dart
Normal file
76
lib/widgets/chat_header.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:basic_intl/intl.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'assistant_avatar.dart';
|
||||
|
||||
class ChatHeader extends StatelessWidget {
|
||||
final VoidCallback? onClose;
|
||||
const ChatHeader({super.key, this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 6),
|
||||
child: SizedBox(
|
||||
child: AssistantAvatar(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 48),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
Intl.getCurrentLocale().startsWith('zh')
|
||||
? 'Hi, 我是众众!'
|
||||
: 'Hi, I\'m Zhongzhong!',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
Intl.getCurrentLocale().startsWith('zh')
|
||||
? '您的专属看、选、买、用车助手'
|
||||
: 'Your exclusive assistant',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, right: 6),
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: onClose,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 1,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:record/record.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:convert';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'dart:math';
|
||||
|
||||
class ChatInput extends StatefulWidget {
|
||||
final Function(String) onSendMessage;
|
||||
final Function(String) onAIResponse;
|
||||
// 新增一个回调,用于处理 AI 流式回复
|
||||
final Function(String, bool) onAIStreamResponse;
|
||||
|
||||
const ChatInput({
|
||||
super.key,
|
||||
required this.onSendMessage,
|
||||
required this.onAIResponse,
|
||||
required this.onAIStreamResponse, // 添加这个参数
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final AudioRecorder _recorder = AudioRecorder();
|
||||
List<int> _audioBuffer = [];
|
||||
String? _tempFilePath;
|
||||
bool _isRecording = false;
|
||||
|
||||
// 添加静态变量存储用户ID和会话ID
|
||||
static String? _cachedUserId;
|
||||
static String? _cachedConversationId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkPermission();
|
||||
}
|
||||
|
||||
Future<void> _checkPermission() async {
|
||||
final hasPermission = await _recorder.hasPermission();
|
||||
print('麦克风权限状态: $hasPermission');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_recorder.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
final text = _controller.text;
|
||||
if (text.trim().isNotEmpty) {
|
||||
widget.onSendMessage(text);
|
||||
_controller.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startRecording() async {
|
||||
setState(() {
|
||||
_isRecording = true;
|
||||
_audioBuffer = [];
|
||||
});
|
||||
|
||||
print('开始录音...');
|
||||
|
||||
try {
|
||||
// 使用文件录音可能更稳定
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
_tempFilePath = '${tempDir.path}/temp_audio_${DateTime.now().millisecondsSinceEpoch}.opus';
|
||||
|
||||
print('录音文件路径: $_tempFilePath');
|
||||
|
||||
if (await _recorder.hasPermission()) {
|
||||
|
||||
await _recorder.start(
|
||||
RecordConfig(encoder: AudioEncoder.opus),
|
||||
path: _tempFilePath!,
|
||||
);
|
||||
|
||||
print('录音已开始,使用OPUS格式,文件: $_tempFilePath');
|
||||
} else {
|
||||
print('没有麦克风权限,无法开始录音');
|
||||
}
|
||||
} catch (e) {
|
||||
print('录音开始出错: $e');
|
||||
setState(() {
|
||||
_isRecording = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopAndSendRecording() async {
|
||||
if (!_isRecording) return;
|
||||
|
||||
setState(() {
|
||||
_isRecording = false;
|
||||
});
|
||||
|
||||
print('停止录音...');
|
||||
try {
|
||||
final path = await _recorder.stop();
|
||||
print('录音已停止,文件路径: $path');
|
||||
|
||||
if (path != null) {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
print('读取到录音数据: ${bytes.length} 字节');
|
||||
|
||||
if (bytes.isNotEmpty) {
|
||||
// 第一步: 发送音频到语音识别接口
|
||||
final recognizedText = await _sendAudioToServer(bytes);
|
||||
if (recognizedText != null) {
|
||||
// 把识别的文本作为用户消息展示
|
||||
widget.onAIResponse(recognizedText);
|
||||
|
||||
// 第二步: 将识别的文本发送到 Chat SSE 接口
|
||||
_sendTextToChatSSE(recognizedText);
|
||||
}
|
||||
} else {
|
||||
print('录音数据为空');
|
||||
}
|
||||
} else {
|
||||
print('录音文件不存在: $path');
|
||||
}
|
||||
} else {
|
||||
print('录音路径为空');
|
||||
}
|
||||
} catch (e) {
|
||||
print('停止录音或发送过程出错: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 修改返回类型,以便获取识别的文本
|
||||
Future<String?> _sendAudioToServer(List<int> audioBytes) async {
|
||||
try {
|
||||
print('准备发送OPUS音频数据,大小: ${audioBytes.length} 字节');
|
||||
final uri = Uri.parse('http://143.64.185.20:18606/voice');
|
||||
print('发送到: $uri');
|
||||
|
||||
final request = http.MultipartRequest('POST', uri);
|
||||
request.files.add(
|
||||
http.MultipartFile.fromBytes(
|
||||
'audio',
|
||||
audioBytes,
|
||||
filename: 'record.wav',
|
||||
contentType: MediaType('audio', 'wav'),
|
||||
),
|
||||
);
|
||||
request.fields['lang'] = 'cn';
|
||||
|
||||
print('发送请求...');
|
||||
final streamResponse = await request.send();
|
||||
print('收到响应,状态码: ${streamResponse.statusCode}');
|
||||
|
||||
final response = await http.Response.fromStream(streamResponse);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
print('响应内容: ${response.body}');
|
||||
final text = _parseTextFromJson(response.body);
|
||||
if (text != null) {
|
||||
print('解析出文本: $text');
|
||||
return text; // 返回识别的文本
|
||||
} else {
|
||||
print('解析文本失败');
|
||||
}
|
||||
} else {
|
||||
print('请求失败,状态码: ${response.statusCode},响应: ${response.body}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('发送录音到服务器时出错: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 添加这个方法,用于从JSON响应中解析文本
|
||||
String? _parseTextFromJson(String body) {
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded.containsKey('text')) {
|
||||
return decoded['text'] as String?;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('JSON解析错误: $e, 原始数据: $body');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 新增方法:发送文本到 Chat SSE 接口
|
||||
void _sendTextToChatSSE(String text) async {
|
||||
print('将识别的文本发送到 Chat SSE 接口: $text');
|
||||
|
||||
// 如果用户ID未初始化,则生成一个
|
||||
if (_cachedUserId == null) {
|
||||
_cachedUserId = _generateRandomUserId(6); // 生成6位随机ID
|
||||
print('初始化用户ID: $_cachedUserId');
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 HttpClient 来处理 SSE
|
||||
final client = HttpClient();
|
||||
// 设置 URL 和参数
|
||||
final chatUri = Uri.parse('http://143.64.185.20:18606/chat');
|
||||
final request = await client.postUrl(chatUri);
|
||||
|
||||
// 设置请求头
|
||||
request.headers.set('Content-Type', 'application/json');
|
||||
request.headers.set('Accept', 'text/event-stream');
|
||||
|
||||
// 设置请求体
|
||||
final body = {
|
||||
'message': text,
|
||||
'user': _cachedUserId,
|
||||
};
|
||||
|
||||
// 如果有缓存的会话ID,则添加到请求体
|
||||
if (_cachedConversationId != null) {
|
||||
body['conversation_id'] = _cachedConversationId;
|
||||
print('使用缓存的会话ID: $_cachedConversationId');
|
||||
} else {
|
||||
print('首次请求,不使用会话ID');
|
||||
}
|
||||
|
||||
request.add(utf8.encode(json.encode(body)));
|
||||
|
||||
// 发送请求并获取 SSE 流
|
||||
final response = await request.close();
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
print('SSE 连接成功,开始接收流数据');
|
||||
|
||||
// 创建一个变量保存累积的 AI 回复
|
||||
String accumulatedResponse = '';
|
||||
|
||||
// 使用 transform 将流数据转换为字符串
|
||||
await for (final data in response.transform(utf8.decoder)) {
|
||||
// 处理 SSE 数据(格式为 "data: {...}\n\n")
|
||||
final lines = data.split('\n');
|
||||
|
||||
for (var line in lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
// 提取 JSON 数据部分
|
||||
final jsonStr = line.substring(5).trim();
|
||||
|
||||
if (jsonStr == '[DONE]') {
|
||||
// 流结束
|
||||
print('SSE 流结束');
|
||||
// 发送最终完整的回复,标记为完成
|
||||
widget.onAIStreamResponse(accumulatedResponse, true);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
final jsonData = json.decode(jsonStr);
|
||||
|
||||
// 尝试提取会话ID (如果存在)
|
||||
if (jsonData.containsKey('conversation_id') && _cachedConversationId == null) {
|
||||
_cachedConversationId = jsonData['conversation_id'];
|
||||
print('从响应中提取并缓存会话ID: $_cachedConversationId');
|
||||
}
|
||||
|
||||
// 提取并累加内容片段
|
||||
if(jsonData['event'].toString().contains('message')){
|
||||
// 代表是有实际消息的数据
|
||||
final textChunk = jsonData.containsKey('answer') ?
|
||||
jsonData['answer'] : '';
|
||||
accumulatedResponse += textChunk;
|
||||
widget.onAIStreamResponse(accumulatedResponse, false);
|
||||
}
|
||||
} catch (e) {
|
||||
print('解析 SSE 数据出错: $e, 原始数据: $jsonStr');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print('SSE 连接失败,状态码: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('SSE 连接出错: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 生成随机用户ID的辅助方法
|
||||
String _generateRandomUserId(int length) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
final random = Random();
|
||||
return String.fromCharCodes(
|
||||
Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length)))
|
||||
);
|
||||
}
|
||||
|
||||
// 添加一个新方法来呼出键盘
|
||||
void _showKeyboard() {
|
||||
FocusScope.of(context).requestFocus(FocusNode());
|
||||
// 给输入框焦点,触发键盘弹出
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
FocusScope.of(context).requestFocus(
|
||||
FocusNode()..requestFocus()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
color: Colors.transparent,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onLongPress: _startRecording,
|
||||
onLongPressUp: _stopAndSendRecording,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _isRecording
|
||||
? Colors.white.withOpacity(0.25)
|
||||
: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Text(
|
||||
_isRecording ? '正在说话中...' : '按住说话',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
// 替换为键盘图标
|
||||
icon: const Icon(Icons.keyboard, color: Colors.white),
|
||||
// 修改点击行为为呼出键盘
|
||||
onPressed: _showKeyboard,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
98
lib/widgets/floating_icon.dart
Normal file
98
lib/widgets/floating_icon.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:ai_chat_assistant/services/vehicle_state_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/message_service.dart';
|
||||
import '../screens/full_screen.dart';
|
||||
import '../screens/part_screen.dart';
|
||||
|
||||
class FloatingIcon extends StatefulWidget {
|
||||
const FloatingIcon({super.key});
|
||||
|
||||
@override
|
||||
State<FloatingIcon> createState() => _FloatingIconState();
|
||||
}
|
||||
|
||||
class _FloatingIconState extends State<FloatingIcon> {
|
||||
Offset _position = const Offset(10, 120);
|
||||
final iconSize = 80.0;
|
||||
bool _isShowPartScreen = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// VehicleStateService();
|
||||
}
|
||||
|
||||
void _showPartScreen() {
|
||||
setState(() {
|
||||
_isShowPartScreen = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _hidePartScreen() {
|
||||
setState(() {
|
||||
_isShowPartScreen = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _showFullScreen() async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const FullScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updatePosition(Offset delta) {
|
||||
setState(() {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
double newX =
|
||||
(_position.dx - delta.dx).clamp(0.0, screenSize.width - iconSize);
|
||||
double newY =
|
||||
(_position.dy - delta.dy).clamp(0.0, screenSize.height - iconSize);
|
||||
_position = Offset(newX, newY);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
if (_isShowPartScreen)
|
||||
PartScreen(
|
||||
onHide: _hidePartScreen,
|
||||
),
|
||||
Positioned(
|
||||
bottom: _position.dy,
|
||||
right: _position.dx,
|
||||
child: Consumer<MessageService>(
|
||||
builder: (context, messageService, child) {
|
||||
return GestureDetector(
|
||||
onTap: _showFullScreen,
|
||||
onLongPress: () async {
|
||||
_showPartScreen();
|
||||
await messageService.startVoiceInput();
|
||||
},
|
||||
onLongPressUp: () async {
|
||||
await messageService.stopAndProcessVoiceInput();
|
||||
final hasMessage = messageService.messages.isNotEmpty;
|
||||
if (!hasMessage) {
|
||||
_hidePartScreen();
|
||||
}
|
||||
},
|
||||
onPanUpdate: (details) => _updatePosition(details.delta),
|
||||
child: Image.asset(
|
||||
messageService.isRecording
|
||||
? 'assets/images/ai2.png'
|
||||
: 'assets/images/ai1.png',
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,25 @@ import 'package:flutter/material.dart';
|
||||
|
||||
class GradientBackground extends StatelessWidget {
|
||||
final Widget child;
|
||||
final List<Color> colors;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
const GradientBackground({super.key, required this.child});
|
||||
const GradientBackground(
|
||||
{super.key,
|
||||
required this.child,
|
||||
required this.colors,
|
||||
this.borderRadius});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF451663),
|
||||
Color(0xFF17042B),
|
||||
Color(0xFF0B021D),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: colors,
|
||||
),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
113
lib/widgets/voice_animation.dart
Normal file
113
lib/widgets/voice_animation.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class VoiceWaveAnimation extends StatefulWidget {
|
||||
const VoiceWaveAnimation({super.key});
|
||||
|
||||
@override
|
||||
State<VoiceWaveAnimation> createState() => _VoiceWaveAnimationState();
|
||||
}
|
||||
|
||||
class _VoiceWaveAnimationState extends State<VoiceWaveAnimation> with TickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 创建一个持续动画控制器
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: 160,
|
||||
height: 30,
|
||||
child: CustomPaint(
|
||||
painter: SineWavePainter(
|
||||
animation: _controller,
|
||||
count: 2, // 波浪数量
|
||||
color: Colors.white,
|
||||
amplitudeFactor: 0.5, // 波幅因子
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SineWavePainter extends CustomPainter {
|
||||
final Animation<double> animation;
|
||||
final int count; // 波浪数量
|
||||
final Color color;
|
||||
final double amplitudeFactor; // 波幅因子
|
||||
|
||||
SineWavePainter({
|
||||
required this.animation,
|
||||
this.count = 2,
|
||||
required this.color,
|
||||
this.amplitudeFactor = 1.0,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Paint paint = Paint()
|
||||
..color = color.withValues(alpha: 0.7)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
final Paint paint2 = Paint()
|
||||
..color = color.withValues(alpha: 0.3)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
final double width = size.width;
|
||||
final double height = size.height;
|
||||
final double centerY = height / 2;
|
||||
|
||||
// 绘制第一个波浪
|
||||
final path = Path();
|
||||
final path2 = Path();
|
||||
|
||||
// 创建两条不同相位的波形,一条主要一条次要
|
||||
path.moveTo(0, centerY);
|
||||
path2.moveTo(0, centerY);
|
||||
|
||||
for (int i = 0; i < width; i++) {
|
||||
// 主波形
|
||||
final double x = i.toDouble();
|
||||
final double normalizedX = (x / width) * count * 2 * pi;
|
||||
final double offset = 2 * pi * animation.value;
|
||||
|
||||
// 使用随机振幅变化模拟声音波动
|
||||
final double randomAmplitude = (sin(normalizedX + offset) + 1) / 2 * amplitudeFactor;
|
||||
path.lineTo(x, centerY - randomAmplitude * 10);
|
||||
|
||||
// 次波形,相位滞后
|
||||
final double randomAmplitude2 = (sin(normalizedX + offset + pi / 6) + 1) / 2 * amplitudeFactor;
|
||||
path2.lineTo(x, centerY - randomAmplitude2 * 5);
|
||||
}
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
canvas.drawPath(path2, paint2);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user