This commit is contained in:
Chen Li
2025-08-12 13:36:42 +08:00
parent 8191bef32e
commit 130755f9e1
47 changed files with 3728 additions and 761 deletions

View File

@@ -6,7 +6,7 @@ plugins {
}
android {
namespace = "com.example.app003"
namespace = "com.example.ai_chat_assistant"
compileSdk = flutter.compileSdkVersion
ndkVersion = "29.0.13599879"
@@ -21,7 +21,7 @@ android {
defaultConfig {
// 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.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24

View File

@@ -4,4 +4,5 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.intent.action.TTS_SERVICE" />
</manifest>

View File

@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="app003"
android:label="ai_chat_assistant"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -41,5 +41,8 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.TTS_SERVICE"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,6 @@
package com.example.ai_chat_assistant;
import io.flutter.embedding.android.FlutterActivity;
public class MainActivity extends FlutterActivity {
}

View File

@@ -9,4 +9,6 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- 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.intent.action.TTS_SERVICE" />
</manifest>

View File

@@ -5,6 +5,7 @@ allprojects {
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/gradle-plugin/") }
maven { url = uri("https://storage.googleapis.com/download.flutter.io")}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
systemProp.https.proxyHost=127.0.0.1
systemProp.https.proxyPort=7890
#systemProp.https.proxyHost=127.0.0.1
#systemProp.https.proxyPort=7890

View File

@@ -1,18 +1,26 @@
import 'package:ai_chat_assistant/themes/AppTheme.dart';
import 'package:flutter/material.dart';
import 'config/theme.dart';
import 'screens/chat_screen.dart';
import 'services/command_service.dart';
import 'screens/main_screen.dart';
class AIChatApp extends StatelessWidget {
const AIChatApp({super.key});
class ChatAssistantApp extends StatelessWidget {
const ChatAssistantApp({super.key});
/// 初始化聊天助手
///
/// [commandCallback] 接收来自AI的车控命令并执行
static void initialize({required CommandCallback commandCallback}) {
CommandService.registerCallback(commandCallback);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'AI Chat Assistant',
title: 'ai_chat_assistant',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
home: const ChatScreen(),
home: const MainScreen(),
);
}
}

View File

@@ -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');
}
}

View File

@@ -0,0 +1,6 @@
enum MessageServiceState {
idle,
recording,
recognizing,
replying,
}

View 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;
}

View 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;
}

View File

@@ -1,6 +1,18 @@
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'app.dart';
import 'package:provider/provider.dart';
import 'services/message_service.dart';
void main() {
runApp(const AIChatApp());
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (!await Permission.microphone.isGranted) {
await Permission.microphone.request();
}
runApp(
ChangeNotifierProvider(
create: (_) => MessageService(),
child: const ChatAssistantApp(),
),
);
}

View File

@@ -1,11 +1,17 @@
import '../enums/message_status.dart';
class ChatMessage {
final String id;
final String text;
final bool isUser;
final DateTime timestamp;
MessageStatus status;
ChatMessage({
required this.id,
required this.text,
required this.isUser,
required this.timestamp,
this.status = MessageStatus.normal,
});
}

View File

@@ -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,
};
}
}

View 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)';
}
}

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

View File

@@ -0,0 +1,90 @@
class VehicleStatusInfo {
/// 对应车架号
String vin = "";
/// 剩余里程
double remainingMileage = 0;
/// 空调开关状态 开启truefalse关闭
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}';
}
}

View File

@@ -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, // 添加新回调
),
],
),
),
),
);
}
}

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

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

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

View 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();
}

View 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))));
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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 "";
}
}

View 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();
}
}

View 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;
}
}
}

View 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, // 失败
}

View 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!);
// }
// }
// }

View 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;
}
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
class AIAvatar extends StatelessWidget {
class AssistantAvatar extends StatelessWidget {
final double width;
final double height;
const AIAvatar({
const AssistantAvatar({
super.key,
this.width = 120,
this.height = 140,
this.width = 132,
this.height = 132,
});
@override

35
lib/widgets/chat_box.dart Normal file
View 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),
);
},
);
}
}

View File

@@ -1,32 +1,346 @@
import 'package:ai_chat_assistant/utils/common_util.dart';
import 'package:basic_intl/intl.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../enums/message_status.dart';
import '../models/chat_message.dart';
import 'package:provider/provider.dart';
import '../services/message_service.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
class ChatBubble extends StatelessWidget {
class ChatBubble extends StatefulWidget {
final ChatMessage message;
const ChatBubble({super.key, required this.message});
@override
State<ChatBubble> createState() => _ChatBubbleState();
}
class _ChatBubbleState extends State<ChatBubble> {
bool _liked = false;
bool _disliked = false;
ChatMessage get message => widget.message;
@override
Widget build(BuildContext context) {
final isThinking = message.status == MessageStatus.thinking;
return Align(
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: message.isUser
? const Color(0xFF6C63FF)
: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Text(
message.text,
style: TextStyle(
color: message.isUser ? Colors.white : Colors.white,
fontSize: 16,
child: Column(
crossAxisAlignment:
message.isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Container(
constraints: const BoxConstraints(
minWidth: 50,
),
width: isThinking ? double.infinity : null,
child: isThinking
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: message.isUser
? CommonUtil.commonColor
: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: _buildAssistantContent(isThinking),
)
: IntrinsicWidth(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: message.isUser
? CommonUtil.commonColor
: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: message.isUser
? _buildUserContent()
: _buildAssistantContent(isThinking),
),
),
),
),
],
),
);
}
Widget _buildUserContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.text.trim().isEmpty) ...[
_buildStatusRow(hasBottomMargin: false),
] else ...[
_buildStatusRow(hasBottomMargin: true),
Text(
message.text,
style: TextStyle(
color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
)
],
],
);
}
Widget _buildAssistantContent(bool isThinking) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatusRow(hasBottomMargin: message.text.trim().isNotEmpty),
if (message.text.trim().isNotEmpty) _buildMarkdownBody(),
_buildDisclaimer(isThinking),
],
);
}
Widget _buildStatusRow({required bool hasBottomMargin}) {
Widget icon;
Color color;
switch (message.status) {
case MessageStatus.listening:
case MessageStatus.recognizing:
case MessageStatus.thinking:
case MessageStatus.executing:
icon = const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue)),
);
color = Colors.white;
break;
case MessageStatus.completed:
case MessageStatus.success:
icon = const Icon(Icons.check_circle, size: 16, color: Colors.green);
color = Colors.green;
break;
case MessageStatus.failure:
icon = const Icon(Icons.error, size: 16, color: Colors.red);
color = Colors.red;
break;
case MessageStatus.aborted:
icon = const Icon(Icons.block, size: 16, color: Colors.grey);
color = Colors.grey;
break;
default:
return const SizedBox.shrink();
}
return _statusRow(
icon: icon, color: color, hasBottomMargin: hasBottomMargin);
}
Widget _statusRow({
required Widget icon,
required Color color,
required bool hasBottomMargin,
Color? backgroundColor,
BorderRadius? borderRadius,
}) {
return Container(
margin: hasBottomMargin ? const EdgeInsets.only(bottom: 6) : null,
decoration: backgroundColor != null
? BoxDecoration(
color: backgroundColor,
borderRadius: borderRadius ?? BorderRadius.circular(0))
: null,
padding: backgroundColor != null ? const EdgeInsets.all(6) : null,
child: Row(
mainAxisAlignment:
message.isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
icon,
const SizedBox(width: 6),
Text(
Intl.getCurrentLocale().startsWith('zh')
? message.status.chinese
: message.status.english,
style: TextStyle(
fontSize: 16, color: color, fontWeight: FontWeight.bold),
),
],
),
);
}
Widget _buildMarkdownBody() {
return MarkdownBody(
data: message.text,
styleSheet: _markdownStyleSheet,
sizedImageBuilder: (config) {
return Image.network(
config.uri.toString(),
width: config.width,
height: config.height,
errorBuilder: (context, error, stackTrace) {
return const SizedBox.shrink();
},
);
},
onTapLink: (text, href, title) {
// todo
},
);
}
static final MarkdownStyleSheet _markdownStyleSheet = MarkdownStyleSheet(
p: const TextStyle(fontSize: 16, color: Colors.white, height: 1.5),
h1: const TextStyle(
fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white),
h2: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
h3: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
h4: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
h5: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
h6: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
strong: const TextStyle(
color: Colors.white,
fontSize: 15,
height: 1.5,
fontWeight: FontWeight.bold),
em: const TextStyle(
color: Colors.white,
fontSize: 12,
height: 1.5,
fontStyle: FontStyle.italic),
code: const TextStyle(
fontSize: 16, backgroundColor: Color(0xFFF5F5F5), color: Colors.white),
codeblockDecoration: BoxDecoration(
color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(4)),
blockquote: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
blockquoteDecoration: BoxDecoration(
border: Border(left: BorderSide(color: Colors.grey, width: 4))),
listBullet: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
tableHead: const TextStyle(
color: Colors.white,
fontSize: 12,
height: 1.5,
fontWeight: FontWeight.bold),
tableBody: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
tableBorder: TableBorder.all(color: Colors.grey, width: 1),
tableCellsPadding: const EdgeInsets.all(8),
tableColumnWidth: const FlexColumnWidth(),
);
Widget _buildDisclaimer(bool isThinking) {
if (!isThinking && message.status != MessageStatus.completed) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 6),
const Divider(color: Colors.grey, height: 1),
const SizedBox(height: 6),
SizedBox(
height: 24,
child: isThinking
? Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
InkWell(
onTap: () {
Provider.of<MessageService>(context, listen: false)
.abortReply();
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.stop_circle_outlined,
size: 16, color: CommonUtil.commonColor),
const SizedBox(width: 4),
Text(
Intl.getCurrentLocale().startsWith('zh')
? '停止回答'
: 'Stop',
style: const TextStyle(
color: Colors.white, fontSize: 12)),
],
),
),
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
Intl.getCurrentLocale().startsWith('zh')
? '内容由AI生成具体以实际情况为准'
: 'Generated by AI, for reference only.',
style:
const TextStyle(fontSize: 12, color: Colors.grey),
),
),
Row(
children: [
InkWell(
onTap: () async {
await Clipboard.setData(ClipboardData(
text:
CommonUtil.cleanText(message.text, false)));
Fluttertoast.showToast(
msg: Intl.getCurrentLocale().startsWith('zh')
? '已复制到剪贴板'
: 'Copied to clipboard');
},
child: const Padding(
padding: EdgeInsets.only(left: 12),
child: Icon(Icons.content_copy,
size: 16, color: Colors.white),
),
),
InkWell(
onTap: () {
setState(() {
_liked = !_liked;
if (_liked) _disliked = false;
});
},
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Icon(
Icons.thumb_up_off_alt,
size: 16,
color: _liked
? CommonUtil.commonColor
: Colors.white,
),
),
),
InkWell(
onTap: () {
setState(() {
_disliked = !_disliked;
if (_disliked) _liked = false;
});
},
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Icon(
Icons.thumb_down_off_alt,
size: 16,
color: _disliked
? CommonUtil.commonColor
: Colors.white,
),
),
),
],
),
],
),
),
],
);
}
}

View 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: () {},
),
),
],
),
);
}
}

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

View File

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

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

View File

@@ -2,22 +2,25 @@ import 'package:flutter/material.dart';
class GradientBackground extends StatelessWidget {
final Widget child;
final List<Color> colors;
final BorderRadius? borderRadius;
const GradientBackground({super.key, required this.child});
const GradientBackground(
{super.key,
required this.child,
required this.colors,
this.borderRadius});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF451663),
Color(0xFF17042B),
Color(0xFF0B021D),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: colors,
),
borderRadius: borderRadius,
),
child: child,
);

View 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;
}
}

View File

@@ -1,97 +1,29 @@
name: app003
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# 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.
name: ai_chat_assistant
description: "ai_chat_assistant"
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.8.1
# 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`.
sdk: ^3.6.2
dependencies:
flutter:
sdk: flutter
# 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
fluttertoast: ^8.2.12
record: ^6.0.0
http: ^1.2.1
path_provider: ^2.1.2
sqflite: ^2.4.2
path: ^1.9.1
http: ^1.4.0
path_provider: ^2.1.5
flutter_markdown: ^0.7.7+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:
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
# 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:
# 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
assets:
- 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