0812
This commit is contained in:
@@ -6,7 +6,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.app003"
|
namespace = "com.example.ai_chat_assistant"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = "29.0.13599879"
|
ndkVersion = "29.0.13599879"
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.example.app003"
|
applicationId = "com.example.ai_chat_assistant"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
|
|||||||
@@ -4,4 +4,5 @@
|
|||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.intent.action.TTS_SERVICE" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<application
|
||||||
android:label="app003"
|
android:label="ai_chat_assistant"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
@@ -41,5 +41,8 @@
|
|||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.TTS_SERVICE"/>
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.example.ai_chat_assistant;
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends FlutterActivity {
|
||||||
|
}
|
||||||
@@ -9,4 +9,6 @@
|
|||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
<!-- Optional: Add this permission if you want to save your recordings in public folders -->
|
<!-- Optional: Add this permission if you want to save your recordings in public folders -->
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.intent.action.TTS_SERVICE" />
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ allprojects {
|
|||||||
maven { url = uri("https://maven.aliyun.com/repository/public/") }
|
maven { url = uri("https://maven.aliyun.com/repository/public/") }
|
||||||
maven { url = uri("https://maven.aliyun.com/repository/google/") }
|
maven { url = uri("https://maven.aliyun.com/repository/google/") }
|
||||||
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin/") }
|
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin/") }
|
||||||
|
maven { url = uri("https://storage.googleapis.com/download.flutter.io")}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
663
android/build/reports/problems/problems-report.html
Normal file
663
android/build/reports/problems/problems-report.html
Normal file
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
systemProp.https.proxyHost=127.0.0.1
|
#systemProp.https.proxyHost=127.0.0.1
|
||||||
systemProp.https.proxyPort=7890
|
#systemProp.https.proxyPort=7890
|
||||||
22
lib/app.dart
22
lib/app.dart
@@ -1,18 +1,26 @@
|
|||||||
|
import 'package:ai_chat_assistant/themes/AppTheme.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'config/theme.dart';
|
import 'services/command_service.dart';
|
||||||
import 'screens/chat_screen.dart';
|
import 'screens/main_screen.dart';
|
||||||
|
|
||||||
class AIChatApp extends StatelessWidget {
|
class ChatAssistantApp extends StatelessWidget {
|
||||||
const AIChatApp({super.key});
|
const ChatAssistantApp({super.key});
|
||||||
|
|
||||||
|
/// 初始化聊天助手
|
||||||
|
///
|
||||||
|
/// [commandCallback] 接收来自AI的车控命令并执行
|
||||||
|
static void initialize({required CommandCallback commandCallback}) {
|
||||||
|
CommandService.registerCallback(commandCallback);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'AI Chat Assistant',
|
title: 'ai_chat_assistant',
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
darkTheme: AppTheme.darkTheme,
|
darkTheme: AppTheme.darkTheme,
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
home: const ChatScreen(),
|
home: const MainScreen(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:sqflite/sqflite.dart';
|
|
||||||
import 'package:path/path.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
|
|
||||||
class DatabaseHelper {
|
|
||||||
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
|
||||||
factory DatabaseHelper() => _instance;
|
|
||||||
static Database? _database;
|
|
||||||
|
|
||||||
DatabaseHelper._internal();
|
|
||||||
|
|
||||||
Future<Database> get database async {
|
|
||||||
if (_database != null) return _database!;
|
|
||||||
_database = await _initDatabase();
|
|
||||||
return _database!;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Database> _initDatabase() async {
|
|
||||||
Directory documentsDirectory = await getApplicationDocumentsDirectory();
|
|
||||||
String path = join(documentsDirectory.path, 'chat.db');
|
|
||||||
return await openDatabase(
|
|
||||||
path,
|
|
||||||
version: 1,
|
|
||||||
onCreate: _onCreate,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onCreate(Database db, int version) async {
|
|
||||||
await db.execute('''
|
|
||||||
CREATE TABLE messages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
role TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
timestamp INTEGER DEFAULT (strftime('%s', 'now'))
|
|
||||||
''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 插入一条消息
|
|
||||||
Future<int> insertMessage(Map<String, dynamic> message) async {
|
|
||||||
Database db = await database;
|
|
||||||
return await db.insert('messages', message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取所有消息(按时间排序)
|
|
||||||
Future<List<Map<String, dynamic>>> getMessages() async {
|
|
||||||
Database db = await database;
|
|
||||||
return await db.query('messages', orderBy: 'timestamp ASC');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除所有消息
|
|
||||||
Future<int> clearMessages() async {
|
|
||||||
Database db = await database;
|
|
||||||
return await db.delete('messages');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6
lib/enums/message_service_state.dart
Normal file
6
lib/enums/message_service_state.dart
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
enum MessageServiceState {
|
||||||
|
idle,
|
||||||
|
recording,
|
||||||
|
recognizing,
|
||||||
|
replying,
|
||||||
|
}
|
||||||
16
lib/enums/message_status.dart
Normal file
16
lib/enums/message_status.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
enum MessageStatus {
|
||||||
|
normal('普通消息', 'Normal'),
|
||||||
|
listening('聆听中', 'Listening'),
|
||||||
|
recognizing('识别中', 'Recognizing'),
|
||||||
|
thinking('思考中', 'Thinking'),
|
||||||
|
completed('完成回答', 'Completed'),
|
||||||
|
executing('执行中', 'Executing'),
|
||||||
|
success('执行成功', 'Success'),
|
||||||
|
failure('执行失败', 'Failure'),
|
||||||
|
aborted('已中止', 'Aborted');
|
||||||
|
|
||||||
|
const MessageStatus(this.chinese, this.english);
|
||||||
|
|
||||||
|
final String chinese;
|
||||||
|
final String english;
|
||||||
|
}
|
||||||
29
lib/enums/vehicle_command_type.dart
Normal file
29
lib/enums/vehicle_command_type.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
enum VehicleCommandType {
|
||||||
|
unknown('未知', 'unknown'),
|
||||||
|
lock('上锁车门', 'lock'),
|
||||||
|
unlock('解锁车门', 'unlock'),
|
||||||
|
openWindow('打开车窗', 'open window'),
|
||||||
|
closeWindow('关闭车窗', 'close window'),
|
||||||
|
appointAC('预约空调', 'appoint AC'),
|
||||||
|
openAC('打开空调', 'open AC'),
|
||||||
|
closeAC('关闭空调', 'close AC'),
|
||||||
|
changeACTemp('修改空调温度', 'change AC temperature'),
|
||||||
|
coolSharply('极速降温', 'cool sharply'),
|
||||||
|
prepareCar('一键备车', 'prepare car'),
|
||||||
|
meltSnow('一键融雪', 'melt snow'),
|
||||||
|
openTrunk('打开后备箱', 'open trunk'),
|
||||||
|
closeTrunk('关闭后备箱', 'close trunk'),
|
||||||
|
honk('鸣笛', 'honk'),
|
||||||
|
locateCar('定位车辆', 'locate car'),
|
||||||
|
openWheelHeat('开启方向盘加热', 'open wheel heat'),
|
||||||
|
closeWheelHeat('关闭方向盘加热', 'close wheel heat'),
|
||||||
|
openMainSeatHeat('开启主座椅加热', 'open main seat heat'),
|
||||||
|
closeMainSeatHeat('关闭主座椅加热', 'close main seat heat'),
|
||||||
|
openMinorSeatHeat('开启副座椅加热', 'open minor seat heat'),
|
||||||
|
closeMinorSeatHeat('关闭副座椅加热', 'close minor seat heat');
|
||||||
|
|
||||||
|
const VehicleCommandType(this.chinese, this.english);
|
||||||
|
|
||||||
|
final String chinese;
|
||||||
|
final String english;
|
||||||
|
}
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'services/message_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
runApp(const AIChatApp());
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
if (!await Permission.microphone.isGranted) {
|
||||||
|
await Permission.microphone.request();
|
||||||
|
}
|
||||||
|
runApp(
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (_) => MessageService(),
|
||||||
|
child: const ChatAssistantApp(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import '../enums/message_status.dart';
|
||||||
|
|
||||||
class ChatMessage {
|
class ChatMessage {
|
||||||
|
final String id;
|
||||||
final String text;
|
final String text;
|
||||||
final bool isUser;
|
final bool isUser;
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
|
MessageStatus status;
|
||||||
|
|
||||||
ChatMessage({
|
ChatMessage({
|
||||||
|
required this.id,
|
||||||
required this.text,
|
required this.text,
|
||||||
required this.isUser,
|
required this.isUser,
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
|
this.status = MessageStatus.normal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
class Message {
|
|
||||||
final String role; // 'user' 或 'assistant'
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
Message({required this.role, required this.message});
|
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
|
||||||
return {
|
|
||||||
'role': role,
|
|
||||||
'message': message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
lib/models/vehicle_cmd.dart
Normal file
36
lib/models/vehicle_cmd.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import '../enums/vehicle_command_type.dart';
|
||||||
|
|
||||||
|
class VehicleCommand {
|
||||||
|
final VehicleCommandType type;
|
||||||
|
final Map<String, dynamic>? params;
|
||||||
|
final String error;
|
||||||
|
|
||||||
|
VehicleCommand({required this.type, this.params, this.error = ''});
|
||||||
|
|
||||||
|
// 从字符串创建命令(用于从API响应解析)
|
||||||
|
factory VehicleCommand.fromString(
|
||||||
|
String commandStr, Map<String, dynamic>? params, String error) {
|
||||||
|
// 将字符串转换为枚举值
|
||||||
|
VehicleCommandType type;
|
||||||
|
try {
|
||||||
|
type = VehicleCommandType.values.firstWhere(
|
||||||
|
(e) => e.name == commandStr,
|
||||||
|
orElse: () => VehicleCommandType.unknown,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
print('Error parsing command string: $e');
|
||||||
|
// 默认为搜车指令,或者你可以选择抛出异常
|
||||||
|
type = VehicleCommandType.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return VehicleCommand(type: type, params: params, error: error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将命令转换为字符串(用于API请求)
|
||||||
|
String get commandString => type.name;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'VehicleCommand(command: ${type.name}, params: $params)';
|
||||||
|
}
|
||||||
|
}
|
||||||
11
lib/models/vehicle_cmd_response.dart
Normal file
11
lib/models/vehicle_cmd_response.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:ai_chat_assistant/models/vehicle_cmd.dart';
|
||||||
|
|
||||||
|
class VehicleCommandResponse {
|
||||||
|
final String? tips;
|
||||||
|
final List<VehicleCommand> commands;
|
||||||
|
|
||||||
|
VehicleCommandResponse({
|
||||||
|
this.tips,
|
||||||
|
required this.commands,
|
||||||
|
});
|
||||||
|
}
|
||||||
90
lib/models/vehicle_status_info.dart
Normal file
90
lib/models/vehicle_status_info.dart
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
class VehicleStatusInfo {
|
||||||
|
/// 对应车架号
|
||||||
|
String vin = "";
|
||||||
|
|
||||||
|
/// 剩余里程
|
||||||
|
double remainingMileage = 0;
|
||||||
|
|
||||||
|
/// 空调开关状态 开启true,false关闭
|
||||||
|
bool acState = false;
|
||||||
|
|
||||||
|
/// 空调温度 未拿到、不支持都返回0;原始值
|
||||||
|
double acTemp = 0;
|
||||||
|
|
||||||
|
/// AC开关 true打开,false关闭
|
||||||
|
bool acSwitch = false;
|
||||||
|
|
||||||
|
/// 左前锁状态 true解锁,false闭锁
|
||||||
|
bool leftFrontLockState = false;
|
||||||
|
|
||||||
|
/// 右前锁状态 true解锁,false闭锁
|
||||||
|
bool rightFrontLockState = false;
|
||||||
|
|
||||||
|
/// 左后锁状态 true解锁,false闭锁
|
||||||
|
bool leftRearLockState = false;
|
||||||
|
|
||||||
|
/// 右后锁状态 true解锁,false闭锁
|
||||||
|
bool rightRearLockState = false;
|
||||||
|
|
||||||
|
/// 左前门状态 true打开,false关闭
|
||||||
|
bool leftFrontDoorState = false;
|
||||||
|
|
||||||
|
/// 右前门状态 true打开,false关闭
|
||||||
|
bool rightFrontDoorState = false;
|
||||||
|
|
||||||
|
/// 左后门状态 true打开,false关闭
|
||||||
|
bool leftRearDoorState = false;
|
||||||
|
|
||||||
|
/// 右后门状态 true打开,false关闭
|
||||||
|
bool rightRearDoorState = false;
|
||||||
|
|
||||||
|
/// 左前窗状态 true打开,false关闭
|
||||||
|
bool leftFrontWindowState = false;
|
||||||
|
|
||||||
|
/// 右前窗状态 true打开,false关闭
|
||||||
|
bool rightFrontWindowState = false;
|
||||||
|
|
||||||
|
/// 左后窗状态 true打开,false关闭
|
||||||
|
bool leftRearWindowState = false;
|
||||||
|
|
||||||
|
/// 右后窗状态 true打开,false关闭
|
||||||
|
bool rightRearWindowState = false;
|
||||||
|
|
||||||
|
/// 后备箱状态 true打开,false关闭
|
||||||
|
bool trunkState = false;
|
||||||
|
|
||||||
|
/// 电量百分比
|
||||||
|
int soc = 0;
|
||||||
|
|
||||||
|
/// 车内温度
|
||||||
|
double temperatureInside = 0;
|
||||||
|
|
||||||
|
/// 方向盘加热状态 false:未加热 true:加热中
|
||||||
|
bool wheelHeat = false;
|
||||||
|
|
||||||
|
/// 主座椅加热状态 false:未加热 true:加热中
|
||||||
|
bool mainSeatHeat = false;
|
||||||
|
|
||||||
|
/// 副座椅加热档位 false:未加热 true:加热中
|
||||||
|
bool minorSeatHeat = false;
|
||||||
|
|
||||||
|
/// 是否行驶中,仅用于车控前置条件判断
|
||||||
|
bool isDriving = false;
|
||||||
|
|
||||||
|
/// 会员是否过期 true过期,false不过期
|
||||||
|
bool vipExpired = false;
|
||||||
|
|
||||||
|
/// 会员有效期,时间戳,单位毫秒
|
||||||
|
int vipTime = 0;
|
||||||
|
|
||||||
|
/// 距离过期还剩多少天
|
||||||
|
int vipRemainCount = 0;
|
||||||
|
|
||||||
|
/// 车况更新时间 ms
|
||||||
|
int statusUpdateTime = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'IGKVehicleStatusInfo{vin:$vin, LockState:($leftFrontLockState,$rightFrontLockState,$leftRearLockState,$rightRearLockState), DoorState:($leftFrontDoorState,$rightFrontDoorState,$leftRearDoorState,$rightRearDoorState), WindowState: ($leftFrontWindowState,$rightFrontWindowState,$leftRearWindowState,$rightRearWindowState), trunkState: $trunkState, soc: $soc, remainingMileage: $remainingMileage, acState: $acState, acTemp: $acTemp, acSwitch:$acSwitch, isDriving:$isDriving, vipExpired: $vipExpired, vipTime: $vipTime, vipRemainCount: $vipRemainCount}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../widgets/ai_avatar.dart';
|
|
||||||
import '../widgets/chat_bubble.dart';
|
|
||||||
import '../widgets/chat_input.dart';
|
|
||||||
import '../widgets/gradient_background.dart';
|
|
||||||
import '../models/chat_message.dart';
|
|
||||||
//import 'dart:convert';
|
|
||||||
import '../config/database_helper.dart';
|
|
||||||
import '../models/message_model.dart';
|
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
|
||||||
const ChatScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ChatScreenState extends State<ChatScreen> {
|
|
||||||
final List<ChatMessage> _messages = [];
|
|
||||||
int? _currentStreamingMessageIndex;
|
|
||||||
int _lastUserMessageCount = 0; // 添加这个变量跟踪用户消息数量
|
|
||||||
|
|
||||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
|
||||||
List<Message> _messagesDB = [];
|
|
||||||
|
|
||||||
void _handleSendMessage(String text) {
|
|
||||||
if (text.trim().isEmpty) return;
|
|
||||||
setState(() {
|
|
||||||
_messages.add(ChatMessage(
|
|
||||||
text: text,
|
|
||||||
isUser: true,
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
));
|
|
||||||
_lastUserMessageCount = _getUserMessageCount(); // 更新用户消息计数
|
|
||||||
_currentStreamingMessageIndex = null; // 重置流索引,强制创建新气泡
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理语音识别后的文本
|
|
||||||
void _handleAIResponse(String text) {
|
|
||||||
setState(() {
|
|
||||||
_messages.add(ChatMessage(
|
|
||||||
text: text,
|
|
||||||
isUser: true, // 用户的消息
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
));
|
|
||||||
_lastUserMessageCount = _getUserMessageCount(); // 更新用户消息计数
|
|
||||||
_currentStreamingMessageIndex = null; // 重置流索引,强制创建新气泡
|
|
||||||
|
|
||||||
_addMessage(Message(
|
|
||||||
role: 'user',
|
|
||||||
message: text,
|
|
||||||
));
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算用户消息总数
|
|
||||||
int _getUserMessageCount() {
|
|
||||||
return _messages.where((msg) => msg.isUser).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 AI 流式响应
|
|
||||||
void _handleAIStreamResponse(String text, bool isComplete) {
|
|
||||||
// 检查是否有新的用户消息,如果有,应该创建新的AI响应
|
|
||||||
final currentUserMessageCount = _getUserMessageCount();
|
|
||||||
final hasNewUserMessage = currentUserMessageCount > _lastUserMessageCount;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
if (hasNewUserMessage || _currentStreamingMessageIndex == null) {
|
|
||||||
// 有新用户消息或首次响应,创建新气泡
|
|
||||||
_messages.add(ChatMessage(
|
|
||||||
text: text,
|
|
||||||
isUser: false,
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
));
|
|
||||||
_currentStreamingMessageIndex = _messages.length - 1;
|
|
||||||
_lastUserMessageCount = currentUserMessageCount; // 更新追踪
|
|
||||||
} else {
|
|
||||||
// 更新现有气泡
|
|
||||||
if (_currentStreamingMessageIndex! < _messages.length) {
|
|
||||||
_messages[_currentStreamingMessageIndex!] = ChatMessage(
|
|
||||||
text: text,
|
|
||||||
isUser: false,
|
|
||||||
timestamp: _messages[_currentStreamingMessageIndex!].timestamp,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_addMessage(Message(
|
|
||||||
role: 'assistant',
|
|
||||||
message: text,
|
|
||||||
));
|
|
||||||
|
|
||||||
// 如果完成了,重置索引
|
|
||||||
if (isComplete) {
|
|
||||||
_currentStreamingMessageIndex = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadMessages() async {
|
|
||||||
List<Map<String, dynamic>> records = await _dbHelper.getMessages();
|
|
||||||
setState(() {
|
|
||||||
_messagesDB = records.map((record) => Message(
|
|
||||||
role: record['role'],
|
|
||||||
message: record['message'],
|
|
||||||
)).toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _addMessage(Message message) async {
|
|
||||||
await _dbHelper.insertMessage(message.toMap());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: GradientBackground(
|
|
||||||
child: SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Header with AI avatar
|
|
||||||
SizedBox(
|
|
||||||
height: 140,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
// 背景渐变已由外层GradientBackground提供
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// AI贴图
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(left: 16, top: 0),
|
|
||||||
child: AIAvatar(width: 120, height: 140),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
// 文字整体下移
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 48),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Hi, 我是众众!',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'您的专属看、选、买、用车助手',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Colors.white.withOpacity(0.8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 可选:右上角关闭按钮
|
|
||||||
const Spacer(),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16, right: 16),
|
|
||||||
child: Icon(Icons.close, color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// 底部分割线
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: Container(
|
|
||||||
height: 1,
|
|
||||||
color: Colors.white.withOpacity(0.15),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Chat messages
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
itemCount: _messages.length,
|
|
||||||
reverse: true,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final message = _messages[_messages.length - 1 - index];
|
|
||||||
return ChatBubble(message: message);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Input area
|
|
||||||
ChatInput(
|
|
||||||
onSendMessage: _handleSendMessage,
|
|
||||||
onAIResponse: _handleAIResponse,
|
|
||||||
onAIStreamResponse: _handleAIStreamResponse, // 添加新回调
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
88
lib/screens/full_screen.dart
Normal file
88
lib/screens/full_screen.dart
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../widgets/chat_box.dart';
|
||||||
|
import '../widgets/gradient_background.dart';
|
||||||
|
import '../services/message_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../widgets/chat_header.dart';
|
||||||
|
import '../widgets/chat_footer.dart';
|
||||||
|
|
||||||
|
class FullScreen extends StatefulWidget {
|
||||||
|
const FullScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FullScreen> createState() => _FullScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FullScreenState extends State<FullScreen> {
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
0.0,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: GradientBackground(
|
||||||
|
colors: const [
|
||||||
|
Color(0XFF3B0A3F),
|
||||||
|
Color(0xFF0E0E24),
|
||||||
|
Color(0xFF0C0B33),
|
||||||
|
],
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ChatHeader(
|
||||||
|
onClose: () {
|
||||||
|
Provider.of<MessageService>(context, listen: false)
|
||||||
|
.abortReply();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Consumer<MessageService>(
|
||||||
|
builder: (context, handler, child) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_scrollToBottom();
|
||||||
|
});
|
||||||
|
return ChatBox(
|
||||||
|
scrollController: _scrollController,
|
||||||
|
messages: handler.messages,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Consumer<MessageService>(
|
||||||
|
builder: (context, messageService, child) => ChatFooter(
|
||||||
|
onClear: () {
|
||||||
|
messageService.abortReply();
|
||||||
|
messageService.clearMessages();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
lib/screens/main_screen.dart
Normal file
41
lib/screens/main_screen.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../widgets/floating_icon.dart';
|
||||||
|
|
||||||
|
class MainScreen extends StatefulWidget {
|
||||||
|
const MainScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MainScreen> createState() => _MainScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainScreenState extends State<MainScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
image: DecorationImage(
|
||||||
|
image: AssetImage('assets/images/bg.jpg'),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FloatingIcon(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
lib/screens/part_screen.dart
Normal file
171
lib/screens/part_screen.dart
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:ui';
|
||||||
|
import '../widgets/chat_box.dart';
|
||||||
|
import '../screens/full_screen.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../services/message_service.dart';
|
||||||
|
import '../widgets/gradient_background.dart';
|
||||||
|
|
||||||
|
class PartScreen extends StatefulWidget {
|
||||||
|
final VoidCallback? onHide;
|
||||||
|
|
||||||
|
const PartScreen({super.key, this.onHide});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PartScreen> createState() => _PartScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PartScreenState extends State<PartScreen> {
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
0.0,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openFullScreen() async {
|
||||||
|
widget.onHide?.call();
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const FullScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final double minHeight = constraints.maxHeight * 0.18;
|
||||||
|
final double maxHeight = constraints.maxHeight * 0.4;
|
||||||
|
final double chatWidth = constraints.maxWidth * 0.94;
|
||||||
|
final int messageCount =
|
||||||
|
context.watch<MessageService>().messages.length;
|
||||||
|
double chatHeight;
|
||||||
|
if (messageCount <= 1) {
|
||||||
|
chatHeight = minHeight;
|
||||||
|
} else {
|
||||||
|
final height = minHeight + (messageCount * 78);
|
||||||
|
chatHeight = height < maxHeight ? height : maxHeight;
|
||||||
|
}
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: false,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: () {
|
||||||
|
Provider.of<MessageService>(context, listen: false)
|
||||||
|
.abortReply();
|
||||||
|
if (widget.onHide != null) widget.onHide!();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: constraints.maxHeight * 0.22),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Selector<MessageService, int>(
|
||||||
|
selector: (_, service) => service.messages.length,
|
||||||
|
builder: (context, messageCount, child) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_scrollToBottom();
|
||||||
|
});
|
||||||
|
return Consumer<MessageService>(
|
||||||
|
builder: (context, handler, child) {
|
||||||
|
final messages = handler.messages;
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
width: chatWidth,
|
||||||
|
height: chatHeight,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||||
|
child: GradientBackground(
|
||||||
|
colors: const [
|
||||||
|
Color(0XBF3B0A3F),
|
||||||
|
Color(0xBF0E0E24),
|
||||||
|
Color(0xBF0C0B33),
|
||||||
|
],
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 42,
|
||||||
|
left: 6,
|
||||||
|
right: 6,
|
||||||
|
bottom: 6),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: 12, bottom: 12),
|
||||||
|
child: ChatBox(
|
||||||
|
scrollController:
|
||||||
|
_scrollController,
|
||||||
|
messages: messages,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.open_in_full,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
onPressed: _openFullScreen,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
lib/services/audio_recorder_service.dart
Normal file
83
lib/services/audio_recorder_service.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:record/record.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
// 负责录音相关功能
|
||||||
|
class AudioRecorderService {
|
||||||
|
final AudioRecorder _recorder = AudioRecorder();
|
||||||
|
bool _isRecording = false;
|
||||||
|
String? _tempFilePath;
|
||||||
|
|
||||||
|
bool get isRecording => _isRecording;
|
||||||
|
|
||||||
|
Future<bool> hasPermission() => _recorder.hasPermission();
|
||||||
|
|
||||||
|
Future<void> checkPermission() async {
|
||||||
|
final hasPermission = await _recorder.hasPermission();
|
||||||
|
print('麦克风权限状态: $hasPermission');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> startRecording() async {
|
||||||
|
print('开始录音...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用文件录音可能更稳定
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
_tempFilePath =
|
||||||
|
'${tempDir.path}/temp_audio_${DateTime.now().millisecondsSinceEpoch}.opus';
|
||||||
|
|
||||||
|
print('录音文件路径: $_tempFilePath');
|
||||||
|
|
||||||
|
if (await _recorder.hasPermission()) {
|
||||||
|
await _recorder.start(
|
||||||
|
const RecordConfig(encoder: AudioEncoder.opus),
|
||||||
|
path: _tempFilePath!,
|
||||||
|
);
|
||||||
|
|
||||||
|
_isRecording = true;
|
||||||
|
print('录音已开始,使用OPUS格式,文件: $_tempFilePath');
|
||||||
|
} else {
|
||||||
|
print('没有麦克风权限,无法开始录音');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('录音开始出错: $e');
|
||||||
|
_isRecording = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>?> stopRecording() async {
|
||||||
|
if (!_isRecording) return null;
|
||||||
|
|
||||||
|
_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) {
|
||||||
|
return bytes;
|
||||||
|
} else {
|
||||||
|
print('录音数据为空');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('录音文件不存在: $path');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('录音路径为空');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('停止录音或发送过程出错: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() => _recorder.dispose();
|
||||||
|
}
|
||||||
123
lib/services/chat_sse_service.dart
Normal file
123
lib/services/chat_sse_service.dart
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// 负责SSE通信
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
class ChatSseService {
|
||||||
|
// 缓存用户ID和会话ID
|
||||||
|
String? _cachedUserId;
|
||||||
|
String? _cachedConversationId;
|
||||||
|
|
||||||
|
HttpClient? _currentClient;
|
||||||
|
HttpClientResponse? _currentResponse;
|
||||||
|
bool _isAborted = true;
|
||||||
|
|
||||||
|
String? get conversationId => _cachedConversationId;
|
||||||
|
|
||||||
|
void request({
|
||||||
|
required String messageId,
|
||||||
|
required String text,
|
||||||
|
required Function(String, String, String, bool) onStreamResponse,
|
||||||
|
}) async {
|
||||||
|
_isAborted = false;
|
||||||
|
if (_cachedUserId == null) {
|
||||||
|
_cachedUserId = _generateRandomUserId(6);
|
||||||
|
print('初始化用户ID: $_cachedUserId');
|
||||||
|
}
|
||||||
|
String responseText = '';
|
||||||
|
String tempText = '';
|
||||||
|
_currentClient = HttpClient();
|
||||||
|
try {
|
||||||
|
final chatUri = Uri.parse('http://143.64.185.20:18606/chat');
|
||||||
|
final request = await _currentClient!.postUrl(chatUri);
|
||||||
|
request.headers.set('Content-Type', 'application/json');
|
||||||
|
request.headers.set('Accept', 'text/event-stream');
|
||||||
|
final body = {
|
||||||
|
'message': text,
|
||||||
|
'user': _cachedUserId,
|
||||||
|
};
|
||||||
|
if (_cachedConversationId != null) {
|
||||||
|
body['conversation_id'] = _cachedConversationId;
|
||||||
|
}
|
||||||
|
request.add(utf8.encode(json.encode(body)));
|
||||||
|
_currentResponse = await request.close();
|
||||||
|
if (_currentResponse!.statusCode == 200) {
|
||||||
|
await for (final line in _currentResponse!
|
||||||
|
.transform(utf8.decoder)
|
||||||
|
.transform(const LineSplitter())) {
|
||||||
|
if (_isAborted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
final jsonStr = line.substring(5).trim();
|
||||||
|
if (jsonStr == '[DONE]' || jsonStr.contains('message_end')) {
|
||||||
|
onStreamResponse(messageId, responseText, tempText, true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final jsonData = json.decode(jsonStr);
|
||||||
|
if (jsonData.containsKey('conversation_id') &&
|
||||||
|
_cachedConversationId == null) {
|
||||||
|
_cachedConversationId = jsonData['conversation_id'];
|
||||||
|
}
|
||||||
|
if (jsonData['event'].toString().contains('message')) {
|
||||||
|
final textChunk =
|
||||||
|
jsonData.containsKey('answer') ? jsonData['answer'] : '';
|
||||||
|
responseText += textChunk;
|
||||||
|
tempText += textChunk;
|
||||||
|
int endIndex = _getCompleteTextEndIndex(tempText);
|
||||||
|
final completeText = tempText.substring(0, endIndex).trim();
|
||||||
|
tempText = tempText.substring(endIndex).trim();
|
||||||
|
onStreamResponse(messageId, responseText, completeText, false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('解析 SSE 数据出错: $e, 原始数据: $jsonStr');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('SSE 连接失败,状态码: ${_currentResponse!.statusCode}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// todo
|
||||||
|
} finally {
|
||||||
|
resetRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> abort() async {
|
||||||
|
_isAborted = true;
|
||||||
|
resetRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetRequest() {
|
||||||
|
_currentResponse?.detachSocket().then((socket) {
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
_currentClient?.close(force: true);
|
||||||
|
_currentClient = null;
|
||||||
|
_currentResponse = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _getCompleteTextEndIndex(String buffer) {
|
||||||
|
// 支持句号、问号、感叹号和换行符作为分割依据
|
||||||
|
final sentenceEnders = RegExp(r'[。!?\n]');
|
||||||
|
final matches = sentenceEnders.allMatches(buffer);
|
||||||
|
return matches.isEmpty ? 0 : matches.last.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:重置会话方法
|
||||||
|
void resetSession() {
|
||||||
|
_cachedUserId = null;
|
||||||
|
_cachedConversationId = null;
|
||||||
|
print('SSE会话已重置');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _generateRandomUserId(int length) {
|
||||||
|
const chars =
|
||||||
|
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
final random = Random();
|
||||||
|
return String.fromCharCodes(Iterable.generate(
|
||||||
|
length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
lib/services/classification_service.dart
Normal file
27
lib/services/classification_service.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
class TextClassificationService {
|
||||||
|
Future<int> classifyText(String text) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse('http://143.64.185.20:18606/classify');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: json.encode({'text': text}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return json.decode(response.body)['category'];
|
||||||
|
} else {
|
||||||
|
print(
|
||||||
|
'Classification failed: ${response.statusCode}, ${response.body}');
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error during text classification: $e');
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
lib/services/command_service.dart
Normal file
126
lib/services/command_service.dart
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import '../enums/vehicle_command_type.dart';
|
||||||
|
import '../models/vehicle_status_info.dart';
|
||||||
|
|
||||||
|
/// 命令处理回调函数定义
|
||||||
|
typedef CommandCallback = Future<(bool, Map<String, dynamic>? params)> Function(
|
||||||
|
VehicleCommandType type, Map<String, dynamic>? params);
|
||||||
|
|
||||||
|
/// 命令处理器类 - 负责处理来自AI的车辆控制命令
|
||||||
|
class CommandService {
|
||||||
|
/// 保存主应用注册的回调函数
|
||||||
|
static CommandCallback? onCommandReceived;
|
||||||
|
|
||||||
|
/// 注册命令处理回调
|
||||||
|
static void registerCallback(CommandCallback callback) {
|
||||||
|
onCommandReceived = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行车控命令
|
||||||
|
/// 返回命令执行是否成功
|
||||||
|
static Future<(bool, Map<String, dynamic>? params)> executeCommand(
|
||||||
|
VehicleCommandType type,
|
||||||
|
{Map<String, dynamic>? params}) async {
|
||||||
|
if (onCommandReceived != null) {
|
||||||
|
try {
|
||||||
|
return await onCommandReceived!(type, params);
|
||||||
|
} catch (e) {
|
||||||
|
print('执行命令出错: $e');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('警告: 命令处理回调未注册');
|
||||||
|
}
|
||||||
|
return (false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool checkState(
|
||||||
|
VehicleCommandType type, Map<String, dynamic>? params) {
|
||||||
|
// 获取车辆状态信息
|
||||||
|
VehicleStatusInfo vehicleStatusInfo = VehicleStatusInfo();
|
||||||
|
switch (type) {
|
||||||
|
// 车辆开锁
|
||||||
|
case VehicleCommandType.lock:
|
||||||
|
return vehicleStatusInfo.leftFrontLockState &&
|
||||||
|
vehicleStatusInfo.rightFrontLockState &&
|
||||||
|
vehicleStatusInfo.leftRearLockState &&
|
||||||
|
vehicleStatusInfo.rightRearLockState;
|
||||||
|
// 车辆关锁
|
||||||
|
case VehicleCommandType.unlock:
|
||||||
|
return !(vehicleStatusInfo.leftFrontLockState &&
|
||||||
|
vehicleStatusInfo.rightFrontLockState &&
|
||||||
|
vehicleStatusInfo.leftRearLockState &&
|
||||||
|
vehicleStatusInfo.rightRearLockState);
|
||||||
|
// 打开窗户
|
||||||
|
case VehicleCommandType.openWindow:
|
||||||
|
return vehicleStatusInfo.leftFrontWindowState &&
|
||||||
|
vehicleStatusInfo.rightFrontWindowState &&
|
||||||
|
vehicleStatusInfo.leftRearWindowState &&
|
||||||
|
vehicleStatusInfo.rightRearWindowState;
|
||||||
|
// 关闭窗户
|
||||||
|
case VehicleCommandType.closeWindow:
|
||||||
|
return !(vehicleStatusInfo.leftFrontWindowState &&
|
||||||
|
vehicleStatusInfo.rightFrontWindowState &&
|
||||||
|
vehicleStatusInfo.leftRearWindowState &&
|
||||||
|
vehicleStatusInfo.rightRearWindowState);
|
||||||
|
// 预约空调
|
||||||
|
case VehicleCommandType.appointAC:
|
||||||
|
// todo
|
||||||
|
return false;
|
||||||
|
// 打开空调
|
||||||
|
case VehicleCommandType.openAC:
|
||||||
|
return vehicleStatusInfo.acState && vehicleStatusInfo.acSwitch;
|
||||||
|
// 关闭空调
|
||||||
|
case VehicleCommandType.closeAC:
|
||||||
|
return !vehicleStatusInfo.acState && !vehicleStatusInfo.acSwitch;
|
||||||
|
// 极速降温
|
||||||
|
case VehicleCommandType.coolSharply:
|
||||||
|
// todo
|
||||||
|
return false;
|
||||||
|
// 一键备车
|
||||||
|
case VehicleCommandType.prepareCar:
|
||||||
|
return false;
|
||||||
|
// 一键融雪
|
||||||
|
case VehicleCommandType.meltSnow:
|
||||||
|
// todo
|
||||||
|
return false;
|
||||||
|
// 打开后备箱
|
||||||
|
case VehicleCommandType.openTrunk:
|
||||||
|
return vehicleStatusInfo.trunkState;
|
||||||
|
// 关闭后备箱
|
||||||
|
case VehicleCommandType.closeTrunk:
|
||||||
|
return !vehicleStatusInfo.trunkState;
|
||||||
|
// 车辆鸣笛
|
||||||
|
case VehicleCommandType.honk:
|
||||||
|
// todo
|
||||||
|
return false;
|
||||||
|
// 寻找车辆
|
||||||
|
case VehicleCommandType.locateCar:
|
||||||
|
// todo
|
||||||
|
return false;
|
||||||
|
// 修改空调温度
|
||||||
|
case VehicleCommandType.changeACTemp:
|
||||||
|
return vehicleStatusInfo.acState &&
|
||||||
|
vehicleStatusInfo.acSwitch &&
|
||||||
|
vehicleStatusInfo.acTemp == params?['temperature'];
|
||||||
|
// 开启方向盘加热
|
||||||
|
case VehicleCommandType.openWheelHeat:
|
||||||
|
return vehicleStatusInfo.wheelHeat;
|
||||||
|
// 关闭方向盘加热
|
||||||
|
case VehicleCommandType.closeWheelHeat:
|
||||||
|
return !vehicleStatusInfo.wheelHeat;
|
||||||
|
// 开启主座椅加热
|
||||||
|
case VehicleCommandType.openMainSeatHeat:
|
||||||
|
return vehicleStatusInfo.mainSeatHeat;
|
||||||
|
// 关闭主座椅加热
|
||||||
|
case VehicleCommandType.closeMainSeatHeat:
|
||||||
|
return !vehicleStatusInfo.mainSeatHeat;
|
||||||
|
// 开启副座椅加热
|
||||||
|
case VehicleCommandType.openMinorSeatHeat:
|
||||||
|
return vehicleStatusInfo.minorSeatHeat;
|
||||||
|
// 关闭副座椅加热
|
||||||
|
case VehicleCommandType.closeMinorSeatHeat:
|
||||||
|
return !vehicleStatusInfo.minorSeatHeat;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
lib/services/control_recognition_service.dart
Normal file
69
lib/services/control_recognition_service.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../models/vehicle_cmd.dart';
|
||||||
|
import '../models/vehicle_cmd_response.dart';
|
||||||
|
import 'vehicle_state_service.dart';
|
||||||
|
|
||||||
|
class VehicleCommandService {
|
||||||
|
// final VehicleStateService vehicleStateService = VehicleStateService();
|
||||||
|
|
||||||
|
Future<VehicleCommandResponse?> getCommandFromText(String text) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'http://143.64.185.20:18606/control');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: json.encode({'text': text}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final decoded = json.decode(response.body);
|
||||||
|
final tips = decoded['tips'];
|
||||||
|
final commandList = decoded['commands'];
|
||||||
|
List<VehicleCommand> vehicleCommandList = (commandList as List)
|
||||||
|
.map<VehicleCommand>((item) => VehicleCommand.fromString(
|
||||||
|
item['command'] as String,
|
||||||
|
item['params'] as Map<String, dynamic>?,
|
||||||
|
item['error'] ?? ''))
|
||||||
|
.toList();
|
||||||
|
return VehicleCommandResponse(tips: tips, commands: vehicleCommandList);
|
||||||
|
} else {
|
||||||
|
print(
|
||||||
|
'Vehicle command query failed: ${response.statusCode}, ${response.body}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error during vehicle command processing: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行车辆控制命令的方法
|
||||||
|
Future<bool> executeCommand(VehicleCommand command) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse('http://143.64.185.20:18607/executeCommand');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: json.encode({
|
||||||
|
'command': command.commandString, // 使用getter转换为字符串
|
||||||
|
'params': command.params,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
print('命令执行成功: ${command.commandString}');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
print('命令执行失败: ${response.statusCode}, ${response.body}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('命令执行出错: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
lib/services/location_service.dart
Normal file
20
lib/services/location_service.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:basic_intl/intl.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
class LocationService {
|
||||||
|
String lang = Intl.getCurrentLocale().startsWith('zh') ? 'zh' : 'en';
|
||||||
|
Future<String> search(double lon, double lat) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'http://143.64.185.20:18606/location_info?lon=${lon}&lat=${lat}&lang=${lang}');
|
||||||
|
final response = await http.get(uri);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return json.decode(response.body)['address'];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error during text classification: $e');
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
381
lib/services/message_service.dart
Normal file
381
lib/services/message_service.dart
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:ai_chat_assistant/utils/common_util.dart';
|
||||||
|
import 'package:basic_intl/intl.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../enums/vehicle_command_type.dart';
|
||||||
|
import '../enums/message_service_state.dart';
|
||||||
|
import '../enums/message_status.dart';
|
||||||
|
import '../models/chat_message.dart';
|
||||||
|
import '../models/vehicle_cmd.dart';
|
||||||
|
import '../services/chat_sse_service.dart';
|
||||||
|
import '../services/classification_service.dart';
|
||||||
|
import '../services/control_recognition_service.dart';
|
||||||
|
import '../services/audio_recorder_service.dart';
|
||||||
|
import '../services/voice_recognition_service.dart';
|
||||||
|
import '../services/tts_service.dart';
|
||||||
|
import 'command_service.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
|
||||||
|
class MessageService extends ChangeNotifier {
|
||||||
|
static final MessageService _instance = MessageService._internal();
|
||||||
|
|
||||||
|
factory MessageService() => _instance;
|
||||||
|
|
||||||
|
MessageService._internal();
|
||||||
|
|
||||||
|
final ChatSseService _chatSseService = ChatSseService();
|
||||||
|
final LocalTtsService _ttsService = LocalTtsService();
|
||||||
|
final AudioRecorderService _audioService = AudioRecorderService();
|
||||||
|
final VoiceRecognitionService _recognitionService = VoiceRecognitionService();
|
||||||
|
final TextClassificationService _classificationService =
|
||||||
|
TextClassificationService();
|
||||||
|
final VehicleCommandService _vehicleCommandService = VehicleCommandService();
|
||||||
|
|
||||||
|
final List<ChatMessage> _messages = [
|
||||||
|
ChatMessage(
|
||||||
|
id: Uuid().v1(),
|
||||||
|
text: Intl.getCurrentLocale().startsWith('zh')
|
||||||
|
? "您好,我是众众,请问有什么可以帮您?"
|
||||||
|
: "Hi, I'm Zhongzhong, may I help you ? ",
|
||||||
|
isUser: false,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
status: MessageStatus.normal)
|
||||||
|
];
|
||||||
|
|
||||||
|
List<ChatMessage> get messages => List.unmodifiable(_messages);
|
||||||
|
|
||||||
|
String? _latestUserMessageId;
|
||||||
|
String? _latestAssistantMessageId;
|
||||||
|
|
||||||
|
MessageServiceState _state = MessageServiceState.idle;
|
||||||
|
|
||||||
|
bool get isRecording => _state == MessageServiceState.recording;
|
||||||
|
|
||||||
|
bool get isRecognizing => _state == MessageServiceState.recognizing;
|
||||||
|
|
||||||
|
bool get isReplying => _state == MessageServiceState.replying;
|
||||||
|
|
||||||
|
bool get isBusy => _state != MessageServiceState.idle;
|
||||||
|
|
||||||
|
bool _isReplyAborted = true;
|
||||||
|
|
||||||
|
Future<void> startVoiceInput() async {
|
||||||
|
if (await Permission.microphone.status == PermissionStatus.denied) {
|
||||||
|
PermissionStatus status = await Permission.microphone.request();
|
||||||
|
if (status == PermissionStatus.permanentlyDenied) {
|
||||||
|
await Fluttertoast.showToast(
|
||||||
|
msg: Intl.getCurrentLocale().startsWith('zh')
|
||||||
|
? "请在设置中开启麦克风权限"
|
||||||
|
: "Please enable microphone in the settings");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_state != MessageServiceState.idle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
abortReply();
|
||||||
|
_latestUserMessageId = null;
|
||||||
|
_latestAssistantMessageId = null;
|
||||||
|
_isReplyAborted = false;
|
||||||
|
changeState(MessageServiceState.recording);
|
||||||
|
await _audioService.startRecording();
|
||||||
|
addMessage("", true, MessageStatus.listening);
|
||||||
|
_latestUserMessageId = messages.last.id;
|
||||||
|
} catch (e) {
|
||||||
|
print('录音开始出错: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void changeState(MessageServiceState state) {
|
||||||
|
_state = state;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addMessage(String text, bool isUser, MessageStatus status) {
|
||||||
|
_messages.add(ChatMessage(
|
||||||
|
id: Uuid().v1(),
|
||||||
|
text: text,
|
||||||
|
isUser: isUser,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
status: status));
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stopAndProcessVoiceInput() async {
|
||||||
|
if (_state != MessageServiceState.recording) {
|
||||||
|
changeState(MessageServiceState.idle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
changeState(MessageServiceState.recognizing);
|
||||||
|
final audioData = await _audioService.stopRecording();
|
||||||
|
replaceMessage(
|
||||||
|
id: _latestUserMessageId!,
|
||||||
|
text: "",
|
||||||
|
status: MessageStatus.recognizing);
|
||||||
|
if (audioData == null || audioData.isEmpty) {
|
||||||
|
removeMessageById(_latestUserMessageId!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final recognizedText =
|
||||||
|
await _recognitionService.recognizeSpeech(audioData);
|
||||||
|
if (recognizedText == null || recognizedText.isEmpty) {
|
||||||
|
removeMessageById(_latestUserMessageId!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceMessage(
|
||||||
|
id: _latestUserMessageId!,
|
||||||
|
text: recognizedText,
|
||||||
|
status: MessageStatus.normal);
|
||||||
|
changeState(MessageServiceState.replying);
|
||||||
|
await reply(recognizedText);
|
||||||
|
} catch (e) {
|
||||||
|
// todo
|
||||||
|
} finally {
|
||||||
|
changeState(MessageServiceState.idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int findMessageIndexById(String? id) {
|
||||||
|
return id == null ? -1 : _messages.indexWhere((msg) => msg.id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void replaceMessage({
|
||||||
|
required String id,
|
||||||
|
String? text,
|
||||||
|
MessageStatus? status,
|
||||||
|
}) {
|
||||||
|
final index = findMessageIndexById(id);
|
||||||
|
if (index == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final message = _messages[index];
|
||||||
|
_messages[index] = ChatMessage(
|
||||||
|
id: message.id,
|
||||||
|
text: text ?? message.text,
|
||||||
|
isUser: message.isUser,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
status: status ?? message.status,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reply(String text) async {
|
||||||
|
addMessage("", false, MessageStatus.thinking);
|
||||||
|
_latestAssistantMessageId = messages.last.id;
|
||||||
|
bool isChinese = CommonUtil.containChinese(text);
|
||||||
|
try {
|
||||||
|
if (_isReplyAborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final category = await _classificationService.classifyText(text);
|
||||||
|
switch (category) {
|
||||||
|
case -1:
|
||||||
|
await occurError(isChinese);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
await handleVehicleControl(text, isChinese);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
await answerWrongQuestion(isChinese);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await answerQuestion(text, isChinese);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await occurError(isChinese);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> handleVehicleControl(String text, bool isChinese) async {
|
||||||
|
if (_isReplyAborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final vehicleCommandResponse =
|
||||||
|
await _vehicleCommandService.getCommandFromText(text);
|
||||||
|
if (vehicleCommandResponse == null) {
|
||||||
|
if (_isReplyAborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String msg = isChinese
|
||||||
|
? "无法识别车辆控制命令,请重试"
|
||||||
|
: "Cannot recognize the vehicle control command, please try again";
|
||||||
|
replaceMessage(
|
||||||
|
id: _latestAssistantMessageId!,
|
||||||
|
text: msg,
|
||||||
|
status: MessageStatus.normal);
|
||||||
|
} else {
|
||||||
|
if (_isReplyAborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceMessage(
|
||||||
|
id: _latestAssistantMessageId!,
|
||||||
|
text: vehicleCommandResponse.tips!,
|
||||||
|
status: MessageStatus.executing);
|
||||||
|
if (!_isReplyAborted) {
|
||||||
|
_ttsService.pushTextForStreamTTS(vehicleCommandResponse.tips!);
|
||||||
|
_ttsService.markSSEStreamCompleted();
|
||||||
|
}
|
||||||
|
bool containOpenAC = false;
|
||||||
|
for (var command in vehicleCommandResponse.commands) {
|
||||||
|
if (_isReplyAborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.type == VehicleCommandType.unknown ||
|
||||||
|
command.error.isNotEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (command.type == VehicleCommandType.openAC) {
|
||||||
|
containOpenAC = true;
|
||||||
|
}
|
||||||
|
if (containOpenAC && command.type == VehicleCommandType.changeACTemp) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 2000),
|
||||||
|
() => processCommand(command, isChinese));
|
||||||
|
} else {
|
||||||
|
await processCommand(command, isChinese);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replaceMessage(
|
||||||
|
id: _latestAssistantMessageId!,
|
||||||
|
text: vehicleCommandResponse.tips!,
|
||||||
|
status: MessageStatus.normal);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> processCommand(VehicleCommand command, bool isChinese) async {
|
||||||
|
String msg;
|
||||||
|
MessageStatus status;
|
||||||
|
final (isSuccess, result) = await CommandService.executeCommand(
|
||||||
|
command.type,
|
||||||
|
params: command.params);
|
||||||
|
if (command.type == VehicleCommandType.locateCar) {
|
||||||
|
msg = isChinese
|
||||||
|
? "很抱歉,定位车辆位置失败"
|
||||||
|
: "Sorry, locate the vehicle unsuccessfully";
|
||||||
|
status = MessageStatus.failure;
|
||||||
|
if (isSuccess && result != null && result.isNotEmpty) {
|
||||||
|
String address = result['address'];
|
||||||
|
if (address.isNotEmpty) {
|
||||||
|
msg = isChinese
|
||||||
|
? "已为您找到车辆位置:\"$address\""
|
||||||
|
: "The vehicle location is \"$address\"";
|
||||||
|
status = MessageStatus.success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isSuccess) {
|
||||||
|
msg = isChinese
|
||||||
|
? "为您执行\"${command.type.chinese}\"成功"
|
||||||
|
: "Execute \"${command.type.english}\" successful";
|
||||||
|
status = MessageStatus.success;
|
||||||
|
} else {
|
||||||
|
msg = isChinese
|
||||||
|
? "很抱歉,\"${command.type.chinese}\"失败"
|
||||||
|
: "Sorry, execute \"${command.type.english}\" unsuccessfully";
|
||||||
|
status = MessageStatus.failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addMessage(msg, false, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> answerWrongQuestion(bool isChinese) async {
|
||||||
|
if (_isReplyAborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chineseAnswer = "尊敬的ID.UNYX车主:\n" +
|
||||||
|
"很抱歉,我暂时无法理解您的需求,请您更详细地描述,以便我能更好地为您服务。\n" +
|
||||||
|
"作为您的智能助手,我专注于以下服务:\n" +
|
||||||
|
"1. ID.UNYX车型功能咨询,例如:如何调节座椅记忆功能?\n" +
|
||||||
|
"2. 日常用车问题解答,例如:充电注意事项有哪些?\n" +
|
||||||
|
"3. 基础车辆控制协助,例如:帮我打开空调。";
|
||||||
|
const englishAnswer = "Dear ID.UNYX Owner,\n" +
|
||||||
|
"I'm sorry that unable to understand your request currently. Please describe it in more detail so that I can better assist you.\n" +
|
||||||
|
"As your intelligent assistant, I specialize in the following services:\n" +
|
||||||
|
"1. ID.UNYX model feature consultation, such as: How do I adjust the seat memory function?\n" +
|
||||||
|
"2. Daily vehicle usage inquiries, such as: What are the charging precautions?\n" +
|
||||||
|
"3. Basic vehicle control assistance, such as: Help me turn on the air conditioning.";
|
||||||
|
replaceMessage(
|
||||||
|
id: _latestAssistantMessageId!,
|
||||||
|
text: isChinese ? chineseAnswer : englishAnswer,
|
||||||
|
status: MessageStatus.normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> answerQuestion(String text, isChinese) async {
|
||||||
|
if (_isReplyAborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_chatSseService.request(
|
||||||
|
messageId: _latestAssistantMessageId!,
|
||||||
|
text: text,
|
||||||
|
onStreamResponse: handleStreamResponse,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> occurError(bool isChinese) async {
|
||||||
|
if (_isReplyAborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceMessage(
|
||||||
|
id: _latestAssistantMessageId!,
|
||||||
|
text: isChinese
|
||||||
|
? "很抱歉,众众暂时无法回答这个问题,请重试"
|
||||||
|
: "Sorry, I can not answer this question for the moment, please try again",
|
||||||
|
status: MessageStatus.normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleStreamResponse(String messageId, String responseText,
|
||||||
|
String completeText, bool isComplete) {
|
||||||
|
if (_isReplyAborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isComplete) {
|
||||||
|
if (completeText.isNotEmpty) {
|
||||||
|
_ttsService.pushTextForStreamTTS(completeText);
|
||||||
|
}
|
||||||
|
_ttsService.markSSEStreamCompleted();
|
||||||
|
replaceMessage(
|
||||||
|
id: messageId,
|
||||||
|
text: responseText,
|
||||||
|
status: MessageStatus.completed,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (completeText.isNotEmpty) {
|
||||||
|
_ttsService.pushTextForStreamTTS(completeText);
|
||||||
|
}
|
||||||
|
replaceMessage(
|
||||||
|
id: messageId, text: responseText, status: MessageStatus.thinking);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
abortReply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> abortReply() async {
|
||||||
|
_isReplyAborted = true;
|
||||||
|
_ttsService.stop();
|
||||||
|
_chatSseService.abort();
|
||||||
|
int index = findMessageIndexById(_latestAssistantMessageId);
|
||||||
|
if (index == -1 || messages[index].status != MessageStatus.thinking) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceMessage(
|
||||||
|
id: _latestAssistantMessageId!, status: MessageStatus.aborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeMessageById(String id) {
|
||||||
|
_messages.removeWhere((msg) => msg.id == id);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearMessages() {
|
||||||
|
_messages.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
lib/services/redis_service.dart
Normal file
33
lib/services/redis_service.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
class RedisService {
|
||||||
|
static final RedisService _instance = RedisService._internal();
|
||||||
|
|
||||||
|
factory RedisService() => _instance;
|
||||||
|
|
||||||
|
RedisService._internal();
|
||||||
|
|
||||||
|
Future<void> setKeyValue(String key, Object value) async {
|
||||||
|
final uri = Uri.parse('http://143.64.185.20:18606/redis/set');
|
||||||
|
await http.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: json.encode({'key': key, 'value': value}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getValue(String key) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse('http://143.64.185.20:18606/redis/get?key=$key');
|
||||||
|
final response = await http.get(uri);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return json.decode(response.body);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
608
lib/services/tts_service.dart
Normal file
608
lib/services/tts_service.dart
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'package:ai_chat_assistant/utils/common_util.dart';
|
||||||
|
|
||||||
|
// ...existing code...
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
|
|
||||||
|
class LocalTtsService {
|
||||||
|
final FlutterTts _flutterTts = FlutterTts();
|
||||||
|
bool _isPlaying = false;
|
||||||
|
|
||||||
|
// TTS 播放队列 - 存储文本段
|
||||||
|
final Queue<String> _ttsQueue = Queue<String>();
|
||||||
|
|
||||||
|
// TTS 事件回调
|
||||||
|
VoidCallback? _onStartHandler;
|
||||||
|
VoidCallback? _onCompletionHandler;
|
||||||
|
Function(String)? _onErrorHandler;
|
||||||
|
|
||||||
|
// 有序播放队列 - 确保播放顺序与文本顺序一致
|
||||||
|
List<TtsTask> _orderedTasks = []; // 有序任务队列
|
||||||
|
int _nextTaskIndex = 0; // 下一个要处理的任务索引
|
||||||
|
bool _isProcessingTasks = false; // 是否正在处理任务
|
||||||
|
bool _streamCompleted = false; // 流是否完成
|
||||||
|
|
||||||
|
LocalTtsService() {
|
||||||
|
_initializeTts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化TTS引擎
|
||||||
|
Future<void> _initializeTts() async {
|
||||||
|
try {
|
||||||
|
// 获取可用的TTS引擎列表
|
||||||
|
await _printAvailableEngines();
|
||||||
|
|
||||||
|
// 推荐并设置最佳引擎
|
||||||
|
// await setEngine("com.k2fsa.sherpa.onnx.tts.engine");
|
||||||
|
|
||||||
|
// 设置TTS参数
|
||||||
|
await _flutterTts.setLanguage("en-US"); // 默认英文
|
||||||
|
await _flutterTts.setSpeechRate(0.5); // 语速
|
||||||
|
await _flutterTts.setVolume(1.0); // 音量
|
||||||
|
await _flutterTts.setPitch(1.0); // 音调
|
||||||
|
|
||||||
|
// 设置TTS事件回调
|
||||||
|
_flutterTts.setStartHandler(() {
|
||||||
|
print('TTS开始播放');
|
||||||
|
_isPlaying = true;
|
||||||
|
_onStartHandler?.call();
|
||||||
|
});
|
||||||
|
|
||||||
|
_flutterTts.setCompletionHandler(() {
|
||||||
|
print('TTS播放完成');
|
||||||
|
_isPlaying = false;
|
||||||
|
_handleTtsPlaybackComplete();
|
||||||
|
});
|
||||||
|
|
||||||
|
_flutterTts.setErrorHandler((msg) {
|
||||||
|
print('TTS错误: $msg');
|
||||||
|
_isPlaying = false;
|
||||||
|
_onErrorHandler?.call(msg);
|
||||||
|
_handleTtsPlaybackComplete(); // 错误时也要处理下一个任务
|
||||||
|
});
|
||||||
|
|
||||||
|
print('TTS引擎初始化完成');
|
||||||
|
} catch (e) {
|
||||||
|
print('TTS引擎初始化失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置最佳TTS引擎
|
||||||
|
// 已移除TtsEngineManager相关代码,避免未定义错误。
|
||||||
|
|
||||||
|
/// 打印可用的TTS引擎
|
||||||
|
Future<void> _printAvailableEngines() async {
|
||||||
|
try {
|
||||||
|
// 获取可用的TTS引擎
|
||||||
|
dynamic engines = await _flutterTts.getEngines;
|
||||||
|
print('可用的TTS引擎: $engines');
|
||||||
|
|
||||||
|
// 获取可用的语言
|
||||||
|
dynamic languages = await _flutterTts.getLanguages;
|
||||||
|
print('支持的语言: $languages');
|
||||||
|
|
||||||
|
// 获取当前默认引擎
|
||||||
|
dynamic defaultEngine = await _flutterTts.getDefaultEngine;
|
||||||
|
print('当前默认引擎: $defaultEngine');
|
||||||
|
} catch (e) {
|
||||||
|
print('获取TTS引擎信息失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空所有队列和缓存
|
||||||
|
void clearAll() {
|
||||||
|
_ttsQueue.clear();
|
||||||
|
_orderedTasks.clear();
|
||||||
|
_nextTaskIndex = 0;
|
||||||
|
_isProcessingTasks = false;
|
||||||
|
_streamCompleted = false;
|
||||||
|
_isPlaying = false; // 重置播放状态
|
||||||
|
print('所有TTS队列和缓存已清空');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 推送文本到TTS队列进行流式处理(保证顺序)
|
||||||
|
void pushTextForStreamTTS(String text) {
|
||||||
|
if (text.trim().isEmpty) {
|
||||||
|
print('接收到空字符串,跳过推送到TTS队列');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String cleanedText = CommonUtil.cleanText(text, true);
|
||||||
|
if (cleanedText.isEmpty) {
|
||||||
|
print('清理后的文本为空,跳过推送到TTS队列');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建有序任务
|
||||||
|
int taskIndex = _orderedTasks.length;
|
||||||
|
TtsTask task = TtsTask(
|
||||||
|
index: taskIndex,
|
||||||
|
text: cleanedText,
|
||||||
|
status: TtsTaskStatus.ready,
|
||||||
|
);
|
||||||
|
|
||||||
|
_orderedTasks.add(task);
|
||||||
|
print('添加TTS任务 (索引: $taskIndex): $cleanedText');
|
||||||
|
|
||||||
|
// 尝试处理队列中的下一个任务
|
||||||
|
_processNextReadyTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理队列中下一个就绪的任务
|
||||||
|
void _processNextReadyTask() {
|
||||||
|
// 如果正在播放,则不处理下一个任务
|
||||||
|
if (_isPlaying) {
|
||||||
|
print('当前正在播放音频,等待播放完成');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 寻找下一个可以播放的任务
|
||||||
|
while (_nextTaskIndex < _orderedTasks.length) {
|
||||||
|
TtsTask task = _orderedTasks[_nextTaskIndex];
|
||||||
|
if (task.status == TtsTaskStatus.ready) {
|
||||||
|
print('按顺序播放TTS (索引: ${task.index}): ${task.text}');
|
||||||
|
_playTts(task.text);
|
||||||
|
return; // 等待当前音频播放完成
|
||||||
|
} else {
|
||||||
|
// 跳过已处理的任务
|
||||||
|
_nextTaskIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否所有任务都已处理完
|
||||||
|
if (_streamCompleted && _nextTaskIndex >= _orderedTasks.length) {
|
||||||
|
print('=== 所有TTS任务播放完成 ===');
|
||||||
|
_isProcessingTasks = false;
|
||||||
|
_onCompletionHandler?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 播放TTS
|
||||||
|
Future<void> _playTts(String text) async {
|
||||||
|
if (_isPlaying) {
|
||||||
|
print('已有TTS正在播放,跳过本次播放');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
print('开始播放TTS: $text');
|
||||||
|
// 检测语言并设置
|
||||||
|
bool isChinese = CommonUtil.containChinese(text);
|
||||||
|
String targetLanguage = isChinese ? "zh-CN" : "en-US";
|
||||||
|
// 检查语言是否可用,如果不可用则使用默认语言
|
||||||
|
bool isLanguageOk = await isLanguageAvailable(targetLanguage);
|
||||||
|
if (!isLanguageOk) {
|
||||||
|
print('语言 $targetLanguage 不可用,使用默认语言');
|
||||||
|
// 可以在这里设置备用语言或保持当前语言
|
||||||
|
} else {
|
||||||
|
await _flutterTts.setLanguage(targetLanguage);
|
||||||
|
print('设置TTS语言为: $targetLanguage');
|
||||||
|
}
|
||||||
|
// 播放TTS
|
||||||
|
await _flutterTts.speak(text);
|
||||||
|
} catch (e) {
|
||||||
|
print('播放TTS失败: $e');
|
||||||
|
// 播放失败,重置状态并继续下一个
|
||||||
|
_isPlaying = false;
|
||||||
|
_handleTtsPlaybackComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理TTS播放完成
|
||||||
|
void _handleTtsPlaybackComplete() {
|
||||||
|
if (!_isPlaying) {
|
||||||
|
print('TTS已停止播放,处理下一个任务');
|
||||||
|
// 标记当前任务为已完成
|
||||||
|
if (_nextTaskIndex < _orderedTasks.length &&
|
||||||
|
_orderedTasks[_nextTaskIndex].status == TtsTaskStatus.ready) {
|
||||||
|
_orderedTasks[_nextTaskIndex].status = TtsTaskStatus.completed;
|
||||||
|
_nextTaskIndex++;
|
||||||
|
}
|
||||||
|
// 处理下一个任务
|
||||||
|
_processNextReadyTask();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记流完成
|
||||||
|
void markSSEStreamCompleted() {
|
||||||
|
print('=== 标记SSE流完成 ===');
|
||||||
|
_streamCompleted = true;
|
||||||
|
|
||||||
|
// 尝试完成剩余任务
|
||||||
|
_processNextReadyTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止播放
|
||||||
|
void stopSSEPlayback() {
|
||||||
|
print("停止SSE播放");
|
||||||
|
// 立即停止TTS播放,无论当前是否在播报
|
||||||
|
_flutterTts.stop();
|
||||||
|
_isPlaying = false;
|
||||||
|
clearAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置事件处理器
|
||||||
|
void setStartHandler(VoidCallback handler) {
|
||||||
|
_onStartHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCompletionHandler(VoidCallback handler) {
|
||||||
|
_onCompletionHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setErrorHandler(Function(String) handler) {
|
||||||
|
_onErrorHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留原有的播放逻辑用于完整文本处理
|
||||||
|
Future<void> processCompleteText(String text) async {
|
||||||
|
if (text.trim().isEmpty) {
|
||||||
|
print("文本为空,跳过TTS处理");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
print("开始处理完整文本TTS,原始文本长度: ${text.length}字");
|
||||||
|
|
||||||
|
// 清理缓存和重置队列
|
||||||
|
clearAll();
|
||||||
|
|
||||||
|
// 确保停止当前播放
|
||||||
|
await _stopCurrentPlayback();
|
||||||
|
|
||||||
|
// 直接使用流式处理
|
||||||
|
await _processCompleteTextAsStream(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将完整文本作为流式处理
|
||||||
|
Future<void> _processCompleteTextAsStream(String text) async {
|
||||||
|
// 清理markdown和异常字符
|
||||||
|
String cleanedText = _cleanTextForTts(text);
|
||||||
|
print("清理后文本长度: ${cleanedText.length}字");
|
||||||
|
|
||||||
|
if (cleanedText.trim().isEmpty) {
|
||||||
|
print("清理后文本为空,跳过TTS");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分割成句子并逐个处理
|
||||||
|
List<String> sentences = _splitTextIntoSentences(cleanedText);
|
||||||
|
print("=== 文本分段完成,共 ${sentences.length} 个句子 ===");
|
||||||
|
|
||||||
|
// 推送每个句子到流式TTS处理
|
||||||
|
for (int i = 0; i < sentences.length; i++) {
|
||||||
|
final sentence = sentences[i].trim();
|
||||||
|
if (sentence.isEmpty) continue;
|
||||||
|
|
||||||
|
print("处理句子 ${i + 1}/${sentences.length}: $sentence");
|
||||||
|
pushTextForStreamTTS(sentence);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记流完成
|
||||||
|
markSSEStreamCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止当前播放的辅助方法
|
||||||
|
Future<void> _stopCurrentPlayback() async {
|
||||||
|
try {
|
||||||
|
print("停止当前播放");
|
||||||
|
|
||||||
|
if (_isPlaying) {
|
||||||
|
await _flutterTts.stop();
|
||||||
|
_isPlaying = false;
|
||||||
|
print("TTS.stop() 调用完成");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短暂延迟确保播放器状态稳定
|
||||||
|
await Future.delayed(Duration(milliseconds: 300));
|
||||||
|
print("播放器状态稳定延迟完成");
|
||||||
|
} catch (e) {
|
||||||
|
print('停止当前播放错误: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留原有的方法用于兼容性
|
||||||
|
List<String> _splitTextIntoSentences(String text) {
|
||||||
|
List<String> result = [];
|
||||||
|
|
||||||
|
if (text.length <= 50) {
|
||||||
|
result.add(text);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持中英文句子分割
|
||||||
|
List<String> sentenceEnders = [
|
||||||
|
'。', '!', '?', ';', '\n', // 中文
|
||||||
|
'.', '!', '?', ';' // 英文
|
||||||
|
];
|
||||||
|
|
||||||
|
String remainingText = text;
|
||||||
|
|
||||||
|
while (remainingText.isNotEmpty) {
|
||||||
|
if (remainingText.length <= 50) {
|
||||||
|
result.add(remainingText.trim());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在50字以内查找最佳分割点
|
||||||
|
String candidate = remainingText.substring(0, 50);
|
||||||
|
|
||||||
|
int lastSentenceEndIndex = -1;
|
||||||
|
for (int i = candidate.length - 1; i >= 0; i--) {
|
||||||
|
if (sentenceEnders.contains(candidate[i])) {
|
||||||
|
lastSentenceEndIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSentenceEndIndex != -1) {
|
||||||
|
// 找到句子结束符,以此为分割点
|
||||||
|
String chunk = candidate.substring(0, lastSentenceEndIndex + 1).trim();
|
||||||
|
if (chunk.isNotEmpty) {
|
||||||
|
result.add(chunk);
|
||||||
|
}
|
||||||
|
remainingText =
|
||||||
|
remainingText.substring(lastSentenceEndIndex + 1).trim();
|
||||||
|
} else {
|
||||||
|
// 没找到句子结束符,寻找逗号分割点(中英文逗号)
|
||||||
|
int lastCommaIndex = -1;
|
||||||
|
for (int i = candidate.length - 1; i >= 20; i--) {
|
||||||
|
if (candidate[i] == ',' || candidate[i] == ',') {
|
||||||
|
lastCommaIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastCommaIndex != -1) {
|
||||||
|
String chunk = candidate.substring(0, lastCommaIndex + 1).trim();
|
||||||
|
if (chunk.isNotEmpty) {
|
||||||
|
result.add(chunk);
|
||||||
|
}
|
||||||
|
remainingText = remainingText.substring(lastCommaIndex + 1).trim();
|
||||||
|
} else {
|
||||||
|
// 没找到合适的分割点,强制在50字处分割
|
||||||
|
result.add(candidate.trim());
|
||||||
|
remainingText = remainingText.substring(50).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.where((chunk) => chunk.trim().isNotEmpty).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cleanTextForTts(String text) {
|
||||||
|
String cleanText = text
|
||||||
|
// 修正markdown粗体格式,保留内容
|
||||||
|
.replaceAllMapped(RegExp(r'\*\*(.*?)\*\*'),
|
||||||
|
(m) => m.group(1) ?? '') // 粗体:**文字** -> 文字
|
||||||
|
.replaceAllMapped(
|
||||||
|
RegExp(r'\*(.*?)\*'), (m) => m.group(1) ?? '') // 斜体:*文字* -> 文字
|
||||||
|
.replaceAllMapped(
|
||||||
|
RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '') // 行内代码:`代码` -> 代码
|
||||||
|
.replaceAll(RegExp(r'```[^`]*```', multiLine: true), '') // 代码块:完全移除
|
||||||
|
.replaceAll(RegExp(r'```[\s\S]*?```'), '') // 代码块:完全移除(备用)
|
||||||
|
.replaceAll(
|
||||||
|
RegExp(r'^#{1,6}\s+(.*)$', multiLine: true), r'$1') // 标题:# 标题 -> 标题
|
||||||
|
.replaceAll(
|
||||||
|
RegExp(r'\[([^\]]+)\]\([^\)]+\)'), r'$1') // 链接:[文字](url) -> 文字
|
||||||
|
.replaceAll(
|
||||||
|
RegExp(r'^>\s*(.*)$', multiLine: true), r'$1') // 引用:> 文字 -> 文字
|
||||||
|
.replaceAll(RegExp(r'^[-*+]\s+(.*)$', multiLine: true),
|
||||||
|
r'$1') // 无序列表:- 项目 -> 项目
|
||||||
|
.replaceAll(RegExp(r'^\d+\.\s+(.*)$', multiLine: true),
|
||||||
|
r'$1') // 有序列表:1. 项目 -> 项目
|
||||||
|
.replaceAll(RegExp(r'^\s*[-*]{3,}\s*$', multiLine: true), '') // 分隔线:移除
|
||||||
|
.replaceAll(RegExp(r'\n\s*\n'), '\n') // 多个换行替换为单个换行
|
||||||
|
.replaceAll(RegExp(r'\n'), ' ') // 换行替换为空格
|
||||||
|
|
||||||
|
// 移除代码相关的变量和符号(但保留正常文字中的这些字符)
|
||||||
|
.replaceAll(RegExp(r'\$\d+(?=\s|$|[,。!?;:])'), '') // 移除独立的 $1, $2, $3 等
|
||||||
|
.replaceAll(RegExp(r'\$\{[^}]*\}'), '') // 移除 ${variable} 格式
|
||||||
|
.replaceAll(RegExp(r'(?<=\s|^)\$[a-zA-Z_]\w*(?=\s|$|[,。!?;:])'),
|
||||||
|
'') // 移除独立的 $variable 格式
|
||||||
|
|
||||||
|
// 移除HTML相关内容
|
||||||
|
.replaceAll(RegExp(r'\\[ntr]'), ' ') // 移除转义字符
|
||||||
|
.replaceAll(RegExp(r'&[a-zA-Z0-9]+;'), ' ') // 移除HTML实体
|
||||||
|
.replaceAll(RegExp(r'<[^>]*>'), '') // 秼除HTML标签
|
||||||
|
|
||||||
|
// 移除明显的代码结构(保留正常文字中的括号)
|
||||||
|
.replaceAll(RegExp(r'\{[^}]*\}(?=\s|$)'), '') // 移除独立的花括号内容
|
||||||
|
.replaceAll(
|
||||||
|
RegExp(r'(?<=\s|^)@[a-zA-Z_]\w*(?=\s|$)'), '') // 移除独立的 @mentions
|
||||||
|
|
||||||
|
// 移除纯编程符号行,但保留文字中的标点
|
||||||
|
.replaceAll(
|
||||||
|
RegExp(r'^[#%&*+=|\\/<>^~`\s]*$', multiLine: true), '') // 移除纯符号行
|
||||||
|
.replaceAll(
|
||||||
|
RegExp(r'(?<=\s)[#%&*+=|\\/<>^~`]+(?=\s)'), ' ') // 移除词间的编程符号
|
||||||
|
|
||||||
|
// 移除表情符号
|
||||||
|
.replaceAll(RegExp(r'[\u{1F600}-\u{1F64F}]', unicode: true), '') // 表情符号
|
||||||
|
.replaceAll(
|
||||||
|
RegExp(r'[\u{1F300}-\u{1F5FF}]', unicode: true), '') // 符号和象形文字
|
||||||
|
.replaceAll(
|
||||||
|
RegExp(r'[\u{1F680}-\u{1F6FF}]', unicode: true), '') // 运输和地图符号
|
||||||
|
.replaceAll(RegExp(r'[\u{1F1E0}-\u{1F1FF}]', unicode: true), '') // 旗帜
|
||||||
|
.replaceAll(RegExp(r'[\u{2600}-\u{26FF}]', unicode: true), '') // 杂项符号
|
||||||
|
|
||||||
|
// 移除特殊的非文字字符,但保留基本标点
|
||||||
|
.replaceAll(
|
||||||
|
RegExp(r'[^\u4e00-\u9fa5a-zA-Z0-9\s\.,!?;:,。!?;:、""'
|
||||||
|
'()()[\]【】《》〈〉「」『』\-—_\n]'),
|
||||||
|
' ');
|
||||||
|
|
||||||
|
// 清理多余的空格和标点
|
||||||
|
cleanText = cleanText
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ') // 多个空格替换为单个空格
|
||||||
|
.replaceAll(RegExp(r'[。]{2,}'), '。') // 多个句号替换为单个
|
||||||
|
.replaceAll(RegExp(r'[,]{2,}'), ',') // 多个逗号替换为单个
|
||||||
|
.replaceAll(RegExp(r'[!]{2,}'), '!') // 多个感叹号替换为单个
|
||||||
|
.replaceAll(RegExp(r'[?]{2,}'), '?') // 多个问号替换为单个
|
||||||
|
.replaceAll(RegExp(r'^\s+|\s+$'), '') // 移除首尾空格
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return cleanText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单次语音播放方法
|
||||||
|
Future<bool> speak(String text) async {
|
||||||
|
if (text.trim().isEmpty) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stop();
|
||||||
|
await processCompleteText(text);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
print('TTS 播放错误: $e');
|
||||||
|
_onErrorHandler?.call('TTS 播放错误: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止播放
|
||||||
|
Future<void> stop() async {
|
||||||
|
try {
|
||||||
|
await _stopCurrentPlayback();
|
||||||
|
clearAll();
|
||||||
|
} catch (e) {
|
||||||
|
print('停止音频播放错误: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pause() async {
|
||||||
|
try {
|
||||||
|
if (_isPlaying) {
|
||||||
|
await _flutterTts.pause();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('暂停音频播放错误: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resume() async {
|
||||||
|
try {
|
||||||
|
// flutter_tts没有resume方法,需要重新播放当前文本
|
||||||
|
// 这里可以根据需要实现
|
||||||
|
print('TTS不支持恢复,需要重新播放');
|
||||||
|
} catch (e) {
|
||||||
|
print('恢复音频播放错误: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isPlaying => _isPlaying;
|
||||||
|
|
||||||
|
bool get hasQueuedItems => _orderedTasks.isNotEmpty;
|
||||||
|
|
||||||
|
int get queueLength => _orderedTasks.length;
|
||||||
|
|
||||||
|
// 获取当前播放状态
|
||||||
|
bool get isCurrentlyPlaying => _isPlaying;
|
||||||
|
|
||||||
|
// 获取任务队列状态信息
|
||||||
|
Map<String, dynamic> get sseQueueStatus => {
|
||||||
|
'totalTasks': _orderedTasks.length,
|
||||||
|
'completedTasks': _nextTaskIndex,
|
||||||
|
'isProcessing': _isProcessingTasks,
|
||||||
|
'streamCompleted': _streamCompleted,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取队列状态信息(保留兼容性)
|
||||||
|
Map<String, dynamic> get queueStatus => {
|
||||||
|
'totalUrls': 0,
|
||||||
|
'currentIndex': 0,
|
||||||
|
'isQueuePlaying': false,
|
||||||
|
'isAllSegmentsProcessed': false,
|
||||||
|
'expectedSegments': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取有序任务数量(用于测试)
|
||||||
|
int getOrderedTasksCount() => _orderedTasks.length;
|
||||||
|
|
||||||
|
// 获取下一个任务索引(用于测试)
|
||||||
|
int getNextTaskIndex() => _nextTaskIndex;
|
||||||
|
|
||||||
|
// dispose方法
|
||||||
|
void dispose() {
|
||||||
|
clearAll();
|
||||||
|
// flutter_tts会自动清理资源
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取可用的TTS引擎列表
|
||||||
|
Future<List<dynamic>> getAvailableEngines() async {
|
||||||
|
try {
|
||||||
|
dynamic engines = await _flutterTts.getEngines;
|
||||||
|
return engines ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
print('获取TTS引擎列表失败: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置TTS引擎
|
||||||
|
Future<bool> setEngine(String engineName) async {
|
||||||
|
try {
|
||||||
|
int result = await _flutterTts.setEngine(engineName);
|
||||||
|
print('设置TTS引擎: $engineName, 结果: $result');
|
||||||
|
return result == 1; // 1表示成功
|
||||||
|
} catch (e) {
|
||||||
|
print('设置TTS引擎失败: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前默认引擎
|
||||||
|
Future<String?> getCurrentEngine() async {
|
||||||
|
try {
|
||||||
|
dynamic engine = await _flutterTts.getDefaultEngine;
|
||||||
|
return engine?.toString();
|
||||||
|
} catch (e) {
|
||||||
|
print('获取当前TTS引擎失败: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取支持的语言列表
|
||||||
|
Future<List<dynamic>> getSupportedLanguages() async {
|
||||||
|
try {
|
||||||
|
dynamic languages = await _flutterTts.getLanguages;
|
||||||
|
return languages ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
print('获取支持的语言列表失败: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查特定语言是否可用
|
||||||
|
Future<bool> isLanguageAvailable(String language) async {
|
||||||
|
try {
|
||||||
|
dynamic result = await _flutterTts.isLanguageAvailable(language);
|
||||||
|
return result == true;
|
||||||
|
} catch (e) {
|
||||||
|
print('检查语言可用性失败: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TTS任务类
|
||||||
|
class TtsTask {
|
||||||
|
final int index;
|
||||||
|
final String text;
|
||||||
|
TtsTaskStatus status;
|
||||||
|
|
||||||
|
TtsTask({
|
||||||
|
required this.index,
|
||||||
|
required this.text,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TTS任务状态枚举
|
||||||
|
enum TtsTaskStatus {
|
||||||
|
ready, // 就绪
|
||||||
|
completed, // 已完成
|
||||||
|
failed, // 失败
|
||||||
|
}
|
||||||
98
lib/services/vehicle_state_service.dart
Normal file
98
lib/services/vehicle_state_service.dart
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// import 'redis_service.dart';
|
||||||
|
// import 'package:flutter_ingeek_carkey/flutter_ingeek_carkey_platform_interface.dart';
|
||||||
|
// import 'package:app_car/src/app_ingeek_mdk/ingeek_mdk_instance.dart';
|
||||||
|
// import 'package:app_car/src/app_ingeek_mdk/ingeek_mdk_service.dart';
|
||||||
|
//
|
||||||
|
// class VehicleState {
|
||||||
|
// String vin;
|
||||||
|
// bool isStarted;
|
||||||
|
// bool isUnlock;
|
||||||
|
// bool isDoorUnlock;
|
||||||
|
// bool isWindowOpen;
|
||||||
|
// bool isTrunkOpen;
|
||||||
|
// int power;
|
||||||
|
// String mileage;
|
||||||
|
// String innerTemperature;
|
||||||
|
// bool climatisationOn;
|
||||||
|
// String climatisationState;
|
||||||
|
// double targetTemperature;
|
||||||
|
// bool wheelHeatOn;
|
||||||
|
// bool mainSeatHeatOn;
|
||||||
|
// bool minorSeatHeatOn;
|
||||||
|
//
|
||||||
|
// VehicleState({
|
||||||
|
// required this.vin,
|
||||||
|
// required this.isStarted,
|
||||||
|
// required this.isUnlock,
|
||||||
|
// required this.isDoorUnlock,
|
||||||
|
// required this.isWindowOpen,
|
||||||
|
// required this.isTrunkOpen,
|
||||||
|
// required this.power,
|
||||||
|
// required this.mileage,
|
||||||
|
// required this.innerTemperature,
|
||||||
|
// required this.climatisationOn,
|
||||||
|
// required this.climatisationState,
|
||||||
|
// required this.targetTemperature,
|
||||||
|
// required this.wheelHeatOn,
|
||||||
|
// required this.mainSeatHeatOn,
|
||||||
|
// required this.minorSeatHeatOn,
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// Map<String, dynamic> toJson() => {
|
||||||
|
// "vin": vin,
|
||||||
|
// "isStarted": isStarted,
|
||||||
|
// "isUnlock": isUnlock,
|
||||||
|
// "isDoorUnlock": isDoorUnlock,
|
||||||
|
// "isWindowOpen": isWindowOpen,
|
||||||
|
// "isTrunkOpen": isTrunkOpen,
|
||||||
|
// "power": power,
|
||||||
|
// "mileage": mileage,
|
||||||
|
// "innerTemperature": innerTemperature,
|
||||||
|
// "climatisationOn": climatisationOn,
|
||||||
|
// "climatisationState": climatisationState,
|
||||||
|
// "targetTemperature": targetTemperature,
|
||||||
|
// "wheelHeatOn": wheelHeatOn,
|
||||||
|
// "mainSeatHeatOn": mainSeatHeatOn,
|
||||||
|
// "minorSeatHeatOn": minorSeatHeatOn,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// class VehicleStateService {
|
||||||
|
// MDKObserverClient? _mdkClient;
|
||||||
|
// String vin = '';
|
||||||
|
//
|
||||||
|
// static final VehicleStateService _instance = VehicleStateService._internal();
|
||||||
|
//
|
||||||
|
// factory VehicleStateService() => _instance;
|
||||||
|
//
|
||||||
|
// VehicleStateService._internal() {
|
||||||
|
// _mdkClient = InGeekMdkInstance().addObserve(
|
||||||
|
// (IGKVehicleStatusInfo data) {
|
||||||
|
// vin = data.vin;
|
||||||
|
// final vehicleState = VehicleState(
|
||||||
|
// vin: data.vin,
|
||||||
|
// isStarted: InGeekMdkInstance().getCarIsStart(),
|
||||||
|
// isUnlock: InGeekMdkInstance().getMDKCarIsUnLock(),
|
||||||
|
// isDoorUnlock: InGeekMdkInstance().getMDKDoorIsUnLock(),
|
||||||
|
// isWindowOpen: InGeekMdkInstance().getMDKWindowIsOpen(),
|
||||||
|
// isTrunkOpen: InGeekMdkInstance().getMDKTrunkIsOpen(),
|
||||||
|
// power: InGeekMdkInstance().getMDKPower(),
|
||||||
|
// mileage: InGeekMdkInstance().getMDKMileage(),
|
||||||
|
// innerTemperature: InGeekMdkInstance().getMdkInnerTemperature(),
|
||||||
|
// climatisationOn: InGeekMdkInstance().getMdkClimatizationOnoff(),
|
||||||
|
// climatisationState: InGeekMdkInstance().getMdkClimatisationState(),
|
||||||
|
// targetTemperature: InGeekMdkInstance().getMdkTargetTemperature(),
|
||||||
|
// wheelHeatOn: InGeekMdkInstance().getMdkWheelHeatOn(),
|
||||||
|
// mainSeatHeatOn: InGeekMdkInstance().getMdkMainSeatHeatOn(),
|
||||||
|
// minorSeatHeatOn: InGeekMdkInstance().getMdkMinorSeatHeatOn());
|
||||||
|
// RedisService().setKeyValue(data.vin, vehicleState);
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// void dispose() {
|
||||||
|
// if (_mdkClient != null) {
|
||||||
|
// InGeekMdkInstance().removeObserve(_mdkClient!);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
61
lib/services/voice_recognition_service.dart
Normal file
61
lib/services/voice_recognition_service.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http_parser/http_parser.dart';
|
||||||
|
|
||||||
|
class VoiceRecognitionService {
|
||||||
|
Future<String?> recognizeSpeech(List<int> audioBytes,
|
||||||
|
{String lang = 'cn'}) 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'] = lang; // 根据参数设置语言
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移到类内部作为私有方法
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
lib/utils/common_util.dart
Normal file
60
lib/utils/common_util.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
class CommonUtil {
|
||||||
|
static const Color commonColor = Color(0xFF6F72F1);
|
||||||
|
|
||||||
|
static bool containChinese(String text) {
|
||||||
|
return RegExp(r'[\u4E00-\u9FFF]').hasMatch(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String cleanText(String text, bool forTts) {
|
||||||
|
String cleanedText = text
|
||||||
|
// 修正:使用 replaceAllMapped 确保安全提取内容
|
||||||
|
.replaceAllMapped(RegExp(r'\*\*(.*?)\*\*'), (m) => m.group(1) ?? '') // 粗体
|
||||||
|
.replaceAllMapped(RegExp(r'\*(.*?)\*'), (m) => m.group(1) ?? '') // 斜体
|
||||||
|
.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '') // 行内代码
|
||||||
|
|
||||||
|
// 修正:处理不完整的代码块
|
||||||
|
.replaceAll(RegExp(r'```[^`]*```', multiLine: true), '') // 完整代码块
|
||||||
|
.replaceAll(RegExp(r'```[^`]*$', multiLine: true), '') // 不完整代码块开始
|
||||||
|
.replaceAll(RegExp(r'^[^`]*```', multiLine: true), '') // 不完整代码块结束
|
||||||
|
|
||||||
|
// 新增:处理 markdown 表格
|
||||||
|
.replaceAll(RegExp(r'^\s*\|.*\|\s*$', multiLine: true), '') // 表格行:| 内容 | 内容 |
|
||||||
|
.replaceAll(RegExp(r'^\s*[\|\-\:\+\=\s]+\s*$', multiLine: true), '') // 表格分隔符行:|---|---|
|
||||||
|
.replaceAll(RegExp(r'\|'), ' ') // 清理残留的竖线
|
||||||
|
|
||||||
|
// 修正:使用 replaceAllMapped 避免 $1 问题
|
||||||
|
.replaceAllMapped(RegExp(r'^#{1,6}\s+(.*)$', multiLine: true),
|
||||||
|
(m) => m.group(1) ?? '')
|
||||||
|
.replaceAllMapped(RegExp(r'\[([^\]]+)\]\([^\)]+\)'), (m) => m.group(1) ?? '')
|
||||||
|
|
||||||
|
// 修正:处理不完整的链接
|
||||||
|
.replaceAll(RegExp(r'\[([^\]]*)\](?!\()'), r'$1') // 只有方括号的链接
|
||||||
|
.replaceAll(RegExp(r'\]\([^\)]*\)'), '') // 只有圆括号部分
|
||||||
|
|
||||||
|
.replaceAll(RegExp(r'!\[([^\]]*)\]\([^\)]+\)'), '') // 图片链接
|
||||||
|
.replaceAllMapped(RegExp(r'^>\s*(.*)$', multiLine: true), (m) => m.group(1) ?? '')
|
||||||
|
.replaceAllMapped(RegExp(r'^\s*[–—\-*+-]+\s*(.*)$', multiLine: true), (m) => m.group(1) ?? '')
|
||||||
|
.replaceAllMapped(RegExp(r'^\s*\d+\.\s+(.*)$', multiLine: true), (m) => m.group(1) ?? '')
|
||||||
|
.replaceAll(RegExp(r'^\s*[-*]{3,}\s*$', multiLine: true), '')
|
||||||
|
|
||||||
|
// 修正:清理残留的 markdown 符号
|
||||||
|
.replaceAll(RegExp(r'\*+'), '') // 清理残留星号
|
||||||
|
.replaceAll(RegExp(r'`+'), '') // 清理残留反引号
|
||||||
|
.replaceAll(RegExp(r'#+'), '') // 清理残留井号
|
||||||
|
|
||||||
|
.replaceAll(RegExp(r'\n\s*\n'), '\n')
|
||||||
|
.replaceAll(RegExp(r'\n'), ' ')
|
||||||
|
.replaceAll(RegExp(r'!\$\d'), '') // 清理占位符
|
||||||
|
.replaceAll(RegExp(r'\$\d+'), '') // 清理所有 $数字 占位符
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (forTts) {
|
||||||
|
cleanedText = cleanedText.replaceAllMapped(
|
||||||
|
RegExp(r'ID\.UNYX', caseSensitive: false), (m) => 'I D Unix');
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
lib/utils/tts_engine_manager.dart
Normal file
56
lib/utils/tts_engine_manager.dart
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
|
|
||||||
|
typedef TtsCompletionCallback = void Function();
|
||||||
|
typedef TtsErrorCallback = void Function(String error);
|
||||||
|
|
||||||
|
class TtsEngineManager {
|
||||||
|
static final TtsEngineManager _instance = TtsEngineManager._internal();
|
||||||
|
factory TtsEngineManager() => _instance;
|
||||||
|
TtsEngineManager._internal();
|
||||||
|
|
||||||
|
final FlutterTts _flutterTts = FlutterTts();
|
||||||
|
|
||||||
|
TtsCompletionCallback? onComplete;
|
||||||
|
TtsErrorCallback? onError;
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
Future<void> init({String language = 'zh-CN', double speechRate = 0.5}) async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
await _flutterTts.setLanguage(language);
|
||||||
|
await _flutterTts.setSpeechRate(speechRate);
|
||||||
|
_flutterTts.setCompletionHandler(() {
|
||||||
|
if (onComplete != null) onComplete!();
|
||||||
|
});
|
||||||
|
_flutterTts.setErrorHandler((msg) {
|
||||||
|
if (onError != null) onError!(msg);
|
||||||
|
});
|
||||||
|
_isInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> speak(String text, {String? language, double? speechRate}) async {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
await init(language: language ?? 'zh-CN', speechRate: speechRate ?? 0.5);
|
||||||
|
}
|
||||||
|
if (language != null) await _flutterTts.setLanguage(language);
|
||||||
|
if (speechRate != null) await _flutterTts.setSpeechRate(speechRate);
|
||||||
|
await _flutterTts.speak(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
await _flutterTts.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLanguage(String language) async {
|
||||||
|
await _flutterTts.setLanguage(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSpeechRate(double rate) async {
|
||||||
|
await _flutterTts.setSpeechRate(rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_flutterTts.stop();
|
||||||
|
_isInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class AIAvatar extends StatelessWidget {
|
class AssistantAvatar extends StatelessWidget {
|
||||||
final double width;
|
final double width;
|
||||||
final double height;
|
final double height;
|
||||||
|
|
||||||
const AIAvatar({
|
const AssistantAvatar({
|
||||||
super.key,
|
super.key,
|
||||||
this.width = 120,
|
this.width = 132,
|
||||||
this.height = 140,
|
this.height = 132,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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/material.dart';
|
||||||
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
|
import '../enums/message_status.dart';
|
||||||
import '../models/chat_message.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;
|
final ChatMessage message;
|
||||||
|
|
||||||
const ChatBubble({super.key, required this.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isThinking = message.status == MessageStatus.thinking;
|
||||||
return Align(
|
return Align(
|
||||||
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
|
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
child: Container(
|
child: Column(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
crossAxisAlignment:
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
message.isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: message.isUser
|
Container(
|
||||||
? const Color(0xFF6C63FF)
|
constraints: const BoxConstraints(
|
||||||
: Colors.white.withOpacity(0.1),
|
minWidth: 50,
|
||||||
borderRadius: BorderRadius.circular(16),
|
),
|
||||||
),
|
width: isThinking ? double.infinity : null,
|
||||||
child: Text(
|
child: isThinking
|
||||||
message.text,
|
? Container(
|
||||||
style: TextStyle(
|
padding: const EdgeInsets.symmetric(
|
||||||
color: message.isUser ? Colors.white : Colors.white,
|
horizontal: 12, vertical: 12),
|
||||||
fontSize: 16,
|
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 {
|
class GradientBackground extends StatelessWidget {
|
||||||
final Widget child;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: colors,
|
||||||
Color(0xFF451663),
|
|
||||||
Color(0xFF17042B),
|
|
||||||
Color(0xFF0B021D),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
borderRadius: borderRadius,
|
||||||
),
|
),
|
||||||
child: child,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
pubspec.yaml
104
pubspec.yaml
@@ -1,97 +1,29 @@
|
|||||||
name: app003
|
name: ai_chat_assistant
|
||||||
description: "A new Flutter project."
|
description: "ai_chat_assistant"
|
||||||
# The following line prevents the package from being accidentally published to
|
publish_to: 'none'
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
|
||||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|
||||||
|
|
||||||
# The following defines the version and build number for your application.
|
|
||||||
# A version number is three numbers separated by dots, like 1.2.43
|
|
||||||
# followed by an optional build number separated by a +.
|
|
||||||
# Both the version and the builder number may be overridden in flutter
|
|
||||||
# build by specifying --build-name and --build-number, respectively.
|
|
||||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
|
||||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
|
||||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
|
||||||
# Read more about iOS versioning at
|
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
|
||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.6.2
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
|
||||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
|
||||||
# dependencies can be manually updated by changing the version numbers below to
|
|
||||||
# the latest version available on pub.dev. To see which dependencies have newer
|
|
||||||
# versions available, run `flutter pub outdated`.
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
fluttertoast: ^8.2.12
|
||||||
# The following adds the Cupertino Icons font to your application.
|
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
|
||||||
cupertino_icons: ^1.0.8
|
|
||||||
fluttertoast: ^8.2.4
|
|
||||||
record: ^6.0.0
|
record: ^6.0.0
|
||||||
http: ^1.2.1
|
http: ^1.4.0
|
||||||
path_provider: ^2.1.2
|
path_provider: ^2.1.5
|
||||||
sqflite: ^2.4.2
|
flutter_markdown: ^0.7.7+1
|
||||||
path: ^1.9.1
|
audioplayers: ^5.2.1
|
||||||
|
uuid: ^3.0.5
|
||||||
|
permission_handler: ^10.3.0
|
||||||
|
provider: ^6.1.5
|
||||||
|
flutter_tts: ^4.2.0
|
||||||
|
basic_intl: ^0.2.0
|
||||||
|
# flutter_ingeek_carkey: 1.4.7
|
||||||
|
# app_car:
|
||||||
|
# path: ../app_car
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
|
||||||
sdk: flutter
|
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
|
||||||
# activated in the `analysis_options.yaml` file located at the root of your
|
|
||||||
# package. See that file for information about deactivating specific lint
|
|
||||||
# rules and activating additional ones.
|
|
||||||
flutter_lints: ^5.0.0
|
flutter_lints: ^5.0.0
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
|
||||||
|
|
||||||
# The following section is specific to Flutter packages.
|
|
||||||
flutter:
|
flutter:
|
||||||
|
|
||||||
# The following line ensures that the Material Icons font is
|
|
||||||
# included with your application, so that you can use the icons in
|
|
||||||
# the material Icons class.
|
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
|
||||||
# assets:
|
|
||||||
# - images/a_dot_burr.jpeg
|
|
||||||
# - images/a_dot_ham.jpeg
|
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
|
||||||
|
|
||||||
# For details regarding adding assets from package dependencies, see
|
|
||||||
# https://flutter.dev/to/asset-from-package
|
|
||||||
|
|
||||||
# To add custom fonts to your application, add a fonts section here,
|
|
||||||
# in this "flutter" section. Each entry in this list should have a
|
|
||||||
# "family" key with the font family name, and a "fonts" key with a
|
|
||||||
# list giving the asset and other descriptors for the font. For
|
|
||||||
# example:
|
|
||||||
# fonts:
|
|
||||||
# - family: Schyler
|
|
||||||
# fonts:
|
|
||||||
# - asset: fonts/Schyler-Regular.ttf
|
|
||||||
# - asset: fonts/Schyler-Italic.ttf
|
|
||||||
# style: italic
|
|
||||||
# - family: Trajan Pro
|
|
||||||
# fonts:
|
|
||||||
# - asset: fonts/TrajanPro.ttf
|
|
||||||
# - asset: fonts/TrajanPro_Bold.ttf
|
|
||||||
# weight: 700
|
|
||||||
#
|
|
||||||
# For details regarding fonts from package dependencies,
|
|
||||||
# see https://flutter.dev/to/font-from-package
|
|
||||||
Reference in New Issue
Block a user