351 lines
11 KiB
Dart
351 lines
11 KiB
Dart
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,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
} |