Compare commits

15 Commits

Author SHA1 Message Date
guangfei.zhao
07980fea87 新增 logger 打印方法 2025-09-30 14:48:12 +08:00
guangfei.zhao
1729d0f266 fix: 修复LogInterceptor 导致message不能流式吐字的问题 2025-09-30 14:38:03 +08:00
guangfei.zhao
c29a3f665a fix: 修复asr 启动就初始化的问题
asrInstance.initialize 这个方法的作用是初始化阿里云 NUI SDK 的 ASR 实例。根据阿里云 SDK 的设计,这个初始化过程不仅仅是加载配置,它还会立即建立与云端的长连接(WebSocket),并进入一个“待命”状态。SDK 会请求打开音频设备。

延迟初始化,只在第一次需要时才进行初始化
2025-09-30 11:43:40 +08:00
guangfei.zhao
a7ed838c92 feat: 新增ai_chat_core,将core 和 widget 分离(未完成)
迁移了 models enums, utils, http 封装,还有一些extensions;service 只迁移了 sse service
2025-09-29 18:33:46 +08:00
guangfei.zhao
ba73d6d780 唤醒流程更新,修复重复唤醒的问题 2025-09-29 14:11:33 +08:00
guangfei.zhao
5be50b4dbb feat: 添加唤醒功能 2025-09-28 18:42:56 +08:00
guangfei.zhao
c6765ab29c 加入 cn 和 en 简单的本地模型 2025-09-28 11:20:37 +08:00
guangfei.zhao
f3ff75c437 新增plugin flutter_vosk_wakeword 2025-09-27 21:01:20 +08:00
guangfei.zhao
f5699fd144 feat: 新增语音唤醒
唤醒的逻辑跟长按的逻辑不一样,不能像之前那样长按图标来录音,结束长按来结束录音
2025-09-26 16:23:48 +08:00
guangfei.zhao
4a41c25502 更新为flutter_markdown_plus 2025-09-25 18:08:09 +08:00
guangfei.zhao
9662ff6a56 聊天气泡集合图片 2025-09-25 17:33:34 +08:00
guangfei.zhao
2bdb15db1d 添加聊天气泡设计图 2025-09-25 16:51:30 +08:00
guangfei.zhao
50226b27a1 添加消息持久化和服务模块分离的内容 2025-09-25 14:23:57 +08:00
guangfei.zhao
5e8b9af17b 添加AI_Chat_Assistant PRD(未完成) 2025-09-24 15:42:51 +08:00
guangfei.zhao
d3580a6a0b basic_intl 修改为 t_basic_intl 2025-09-24 15:42:30 +08:00
142 changed files with 4662 additions and 327 deletions

View File

@@ -1,6 +1,6 @@
# AI Chat Assistant Flutter Plugin
一个功能丰富的 AI 聊天助手 Flutter 插件,专为车载系统设计,支持语音识别、AI 对话、车控命令执行、语音合成等功能。
一个功能丰富的 AI 聊天助手 Flutter 插件支持语音识别、AI 对话、车控命令执行、语音合成等功能。
## 📱 功能特性
@@ -489,16 +489,6 @@ void main() {
}
```
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 📄 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
@@ -529,6 +519,5 @@ void main() {
- 🎵 改进语音识别和TTS服务
#### 技术栈更新
- 📦 Flutter SDK 升级到 ^3.5.0
- 🔧 添加 Meta 注解支持
- 🌐 保持与 basic_intl 国际化包的兼容

View File

@@ -1,2 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -35,13 +35,14 @@ public class AiAssistantPlugin implements FlutterPlugin, MethodCallHandler {
private static final String chineseVoice = "zhitian_emo";
private static final String englishVoice = "abby";
private final NativeNui streamInputTtsInstance = new NativeNui(Constants.ModeType.MODE_STREAM_INPUT_TTS);
private final NativeNui asrInstance = new NativeNui();
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
/// when the Flutter Engine is detached from the Activity
// asr 是否初始化
private boolean isAsrInitialized = false;
// asr property
private MethodChannel channel;
// asr instance
private final NativeNui asrInstance = new NativeNui();
private Handler asrHandler;
private AsrCallBack asrCallBack;
private Handler handler = new Handler(Looper.getMainLooper());
@@ -62,6 +63,21 @@ public class AiAssistantPlugin implements FlutterPlugin, MethodCallHandler {
}
});
void initAsrInstance() {
if (!isAsrInitialized) {
Log.i(TAG, "Initializing ASR for the first time...");
asrCallBack = new AsrCallBack(channel);
int initResult = asrInstance.initialize(asrCallBack, genAsrInitParams(),
Constants.LogLevel.LOG_LEVEL_NONE, false);
Log.i(TAG, "ASR initialization result: " + initResult);
if (initResult != Constants.NuiResultCode.SUCCESS) {
Log.i(TAG, "ASR initialization failed with error code: " + initResult);
return;
}
isAsrInitialized = true;
}
}
@Override
public void onAttachedToEngine(FlutterPluginBinding flutterPluginBinding) {
channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), CHANNEL);
@@ -69,9 +85,10 @@ public class AiAssistantPlugin implements FlutterPlugin, MethodCallHandler {
HandlerThread asrHandlerThread = new HandlerThread("process_thread");
asrHandlerThread.start();
asrHandler = new Handler(asrHandlerThread.getLooper());
asrCallBack = new AsrCallBack(channel);
asrInstance.initialize(asrCallBack, genAsrInitParams(),
Constants.LogLevel.LOG_LEVEL_NONE, false);
// 不能在这里初始化 asr, 初始化之后会占用麦克风
// asrCallBack = new AsrCallBack(channel);
// asrInstance.initialize(asrCallBack, genAsrInitParams(),
// Constants.LogLevel.LOG_LEVEL_NONE, false);
}
@Override
@@ -81,6 +98,16 @@ public class AiAssistantPlugin implements FlutterPlugin, MethodCallHandler {
ttsAudioTrack.releaseAudioTrack();
streamInputTtsInstance.stopStreamInputTts();
streamInputTtsInstance.release();
if (isAsrInitialized) {
asrInstance.release();
if (asrCallBack != null) {
asrCallBack.release();
asrCallBack = null;
}
isAsrInitialized = false;
}
// asrInstance.release();
// if (asrCallBack != null) {
// asrCallBack.release();
@@ -93,6 +120,7 @@ public class AiAssistantPlugin implements FlutterPlugin, MethodCallHandler {
Map<String, Object> args = call.arguments();
switch (call.method) {
case "startTts":
assert args != null;
Object isChinese = args.get("isChinese");
if (isChinese == null || isChinese.toString().isBlank()) {
return;
@@ -101,6 +129,7 @@ public class AiAssistantPlugin implements FlutterPlugin, MethodCallHandler {
result.success(isSuccess);
break;
case "sendTts":
assert args != null;
Object textArg = args.get("text");
if (textArg == null || textArg.toString().isBlank()) {
return;
@@ -140,6 +169,9 @@ public class AiAssistantPlugin implements FlutterPlugin, MethodCallHandler {
private void startAsr() {
asrHandler.post(() -> {
// 初始化
initAsrInstance();
// 启动
String setParamsString = genAsrParams();
Log.i(TAG, "nui set params " + setParamsString);
asrInstance.setParams(setParamsString);

View File

@@ -18,7 +18,7 @@ import io.flutter.plugin.common.MethodChannel;
public class AsrCallBack implements INativeNuiCallback {
private static final String TAG = "AliAsr";
private static final String TAG = "AliAsr-Callbask";
private final static int ASR_SAMPLE_RATE = 16000;
private final static int ASR_WAVE_FRAM_SIZE = 20 * 2 * 1 * ASR_SAMPLE_RATE / 1000; //20ms audio for 16k/16bit/mono
private AudioRecord asrAudioRecorder = null;
@@ -38,7 +38,7 @@ public class AsrCallBack implements INativeNuiCallback {
if (event == Constants.NuiEvent.EVENT_TRANSCRIBER_STARTED) {
asrText = "";
} else if (event == Constants.NuiEvent.EVENT_TRANSCRIBER_COMPLETE) {
// complete
} else if (event == Constants.NuiEvent.EVENT_ASR_PARTIAL_RESULT) {
JSONObject jsonObject = JSON.parseObject(asrResult.allResponse);
JSONObject payload = jsonObject.getJSONObject("payload");

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,686 @@
# AI_Chat_Assistant PRD
# AI 聊天助手需求设计文档(产品需求文档 PRD
### 文档介绍
| 项目 | 信息 |
| --- | --- |
| 产品名称 | AI Chat Assistant Flutter Plugin |
| 版本 | v1.0.0+1 |
| 文档版本 | v1.0 |
| 创建日期 | 2025-09-23 |
| 最近更新时间 | 2025-09-24 |
| 产品经理 | (待指定) |
| 技术负责人 | (待指定) |
| 目标平台 | Flutter (Android / iOS首期 Android 优先) |
| 发布形态 | Flutter 第三方插件(支持示例 App |
### 修订记录
| 版本 | 日期 | 作者 | 内容 |
| --- | --- | --- | --- |
| 1.0 | 2025-09-23 | - | 初版功能需求整理 |
## **1. 引言**
### **1.1 文档目的**
本文档旨在定义“AI Chat Assistant Flutter Plugin”项目的详细功能需求、非功能需求、技术架构及项目规划。它为设计、开发、测试及项目相关人员提供了统一的参考依据和验收标准。
### **1.2 适用范围**
本文档目标读者为AI Chat Assistant Flutter Plugin 的系统设计人员、开发人员、测试人员、产品经理以及项目管理人员。
### **1.3 名词解释**
| **序号** | **名词术语** | **名词解释** |
| --- | --- | --- |
| 1 | Flutter Plugin | 一种可复用的代码包用于为Flutter应用添加特定的原生平台功能。 |
| 2 | ASR | Automatic Speech Recognition自动语音识别将语音转换为文本。 |
| 3 | TTS | Text-To-Speech语音合成将文本转换为语音。 |
| 4 | SSE | Server-Sent Events一种服务器向客户端推送数据的技术。 |
| 5 | 车控命令 | 通过语音或界面触发的,用于控制车辆功能(如门锁、空调)的指令。 |
| 6 | 场景Scenario| 不同业务上下文(购车 / 用车) |
### **1.4 统一异常处理**
1. **网络异常**语音识别或AI对话过程中出现网络错误应有明确提示“网络连接失败请检查后重试”并提供重试选项。
2. **权限拒绝**:当未授予麦克风或音频录制权限时,应引导用户前往系统设置开启权限。
3. **语音识别失败**:返回空文本或低置信度,提示“抱歉,我没有听清,请再说一遍”。(补充:不缓存失败结果。)
4. **命令执行失败**车控执行失败需双通道反馈UI + TTS并显示失败原因或错误类型标签。补充通用错误尽量转化为用户友好语义。
## 2. 产品概述
### 2.1 产品简介
AI Chat Assistant 是一个集成语音识别、智能对话、车控指令解析与执行、语音反馈渲染于一体的 Flutter 插件。支持“车控助手”与“用车助手”双场景:
- 车控助手:支持 20+ 车辆控制命令(如空调、车窗、车门、座椅加热等)。
- 用车助手:基于用户手册 / 知识库进行问答。(包含 保养咨询、能耗优化建议等)
- 购车助手:基于厂家的经销政策以及车辆配置信息进行问答
### 2.2 产品流程图
现有核心流程:用户长按语音按钮 → 权限校验 → 录音 + 识别 → 文本分类 → 路由到问答/车控/错误反馈 → 结果播报与展示。
```mermaid
graph TD
A[用户点击语音按钮] --> B[startVoiceInput]
B --> C[检查麦克风权限]
C --> D[开始录音/ASR]
D --> E[实时显示识别结果]
E --> F[用户松开按钮]
F --> G[stopAndProcessVoiceInput]
G --> H[调用reply处理文本]
H --> I[文本分类]
I --> J{分类结果}
J -->|车控命令| K[handleVehicleControl]
J -->|普通问答| L[answerQuestion - ChatSseService]
J -->|错误问题| M[answerWrongQuestion]
K --> N[解析车控命令]
N --> O[执行TTS播报]
O --> P[执行车控命令]
P --> Q[生成执行反馈]
Q --> R[更新UI显示]
L --> S[SSE流式对话]
S --> T[实时TTS播报]
T --> R
```
### 2.3 产品目的
- 提供开箱即用的AI语音交互能力
- 支持自然语言车控命令识别与执行
- 轻量级架构,易于集成和定制
### 2.4 顶层功能结构
```mermaid
graph LR
ASR[语音采集/ASR] --> CLASS[文本/意图分类]
CLASS -->|车控| CMD[车控解析/执行]
CLASS -->|问答| QA[对话引擎 SSE/WS]
CLASS -->|兜底| FALL[引导重试/澄清]
QA --> TTS[TTS 播报]
CMD --> FEED[执行反馈]
TTS --> MSG[消息渲染]
FEED --> MSG
MSG --> STORE[(本地持久化)]
```
### 2.5 语音交互主流程
```mermaid
graph TD
START[长按/唤醒触发] --> PERM[权限校验]
PERM -->|通过| REC[开始录音]
PERM -->|拒绝| EXIT[权限引导提示]
REC --> ASR[实时增量识别]
ASR --> RELEASE[松手/结束]
RELEASE --> CLASSIFY[分类/意图识别]
CLASSIFY -->|车控| CONTROL[结构化命令]
CLASSIFY -->|问答| STREAM[流式对话输出]
CLASSIFY -->|无匹配| GUIDE[兜底提示]
CONTROL --> EXEC[调用车控处理器]
EXEC --> RESULT[结果反馈]
STREAM --> MERGE[增量合并/渲染]
RESULT --> RENDER[消息渲染 + TTS]
MERGE --> RENDER
RENDER --> STORE[(持久化过滤保存)]
```
## 3. 功能需求
### 3.1 用户页面展示
#### **3.1.1 浮动图标组件ChatFloatingIcon**
- **用户场景**
在主应用界面上提供一个常驻的、可随意拖拽的AI助手入口作为全局统一入口维持轻量可达性不干扰主业务操作。
- **功能描述**
一个可拖拽的浮动按钮,点击展开全屏聊天界面,长按直接启动语音输入(只有聊天窗口)。
- **需求说明**
- **拖拽吸附**:支持在屏幕内自由拖拽,松开时自动吸附到最近的屏幕边缘。
- **交互响应**
- **点击** 进入全屏的聊天页面,全屏模式为一个新的页面
- **长按**:变为聆听模式,直接开始语音录制,出现聊天窗口
- **位置自适应**:聊天窗口的位置需要根据浮动按钮的位置变化,如果浮动按钮位置过于靠上,则聊天窗口显示在浮动按钮的下方,默认显示在浮动按钮的上方
- **状态指示**:通过动画(如呼吸效果)表示待机、聆听、思考等不同状态。
默认为待机状态,长按会进入聆听状态,思考中的状态会在聊天显示思考中的状态聊天气泡
- **补充说明**:长按阈值建议 300ms误触策略短按抬起不触发录音。
![image.png](images/db1d92f4-107d-46c9-8154-fc58cb471acc.png)
![image.png](images/image.png)
#### **3.1.2 浮动聊天窗口ChatPopup**
- **用户场景**
用户与AI助手进行可视化的文本和语音交互。
- **功能描述**
提供弹出式聊天窗口,展示对话记录并提供输入方式,不承担复杂历史翻阅与批量操作
- **需求说明**
长按浮动按钮即可出现浮动聊天窗口,长按状态下展示聆听中,放开后则聆听结束,变为思考中状态。
思考中状态可以手动点击 **停止回答** 可以终止AI对话直到回答完成则不可点击停止回答。
![Screenshot_20250923_162311.jpg](images/Screenshot_20250923_162311.jpg)
![image.png](images/image%201.png)
#### **3.1.3 全屏聊天界面ChatFullscreen**
- **用户场景**
用户与AI助手进行可视化的文本和语音交互适用于长对话、多轮引用、查看历史与多类型气泡的交互。
- **功能描述**
提供全屏聊天界面,展示对话记录并提供输入方式。全屏的聊天页面功能更加丰富,多类型气泡、快捷指令区、功能按钮区、历史分页加载
- **需求说明**
- 全屏聊天模式下会保留之前浮动聊天窗口的聊天记录
- 不同的场景为不同的AI助手现有两种场景购车助手和用车助手(根据入口来判断)
- 顶部会有可配置的功能按钮区域(用户服务/购车服务)
- 功能按钮下方会有快捷对话的区域,可以点击换一换来更换快捷指令,支持“换一换”刷新策略(本地轮换或服务侧下发)
- 聊天页面底部会有功能栏,分别为**删除当前对话****按住说话****切换文本输入**
- 如果存在本地对话记录,点击 **显示历史对话记录** 会加载之前的5条对话记录同样可以通过上拉来加载历史对话记录
![image.png](images/image%202.png)
### 3.2 AI对话场景
#### **3.2.1 购车助手场景**
- **用户场景**
用户未绑定车辆的情况下爱车页面变为购车页面购车页面也会有AI助手未绑定车辆用户获取车型配置、优惠、推荐。
- **功能描述**
购车页面的AI助手没有聆听模式只能点击进入全屏的聊天页面首次的回复词也会有变化功能按钮区域和快捷回复区域都会跟用车助手不同
- **需求说明**
- 浮动按钮点击可进入全屏的AI助手页面顶部的功能按钮为热门车型推荐同样是icon+text的按钮点击之后会快捷输入车型的名称AI助手会回复车型的相关信息同时快捷对话的区域会按照对话来进行变化
- 购车助手同样可以通过文本的语义来进行推荐车型,以及解读购车的优惠信息;
- 如果在购车助手场景下,本身并没有绑定车辆,如果有车控的语音输入,则返回:您还未绑定车辆,无法使用车控指令;
- 如果是用车助手场景,包含购车助手的全部功能,同样可以回复相应车型来获取配置信息以及购车政策。
![image.png](images/image%203.png)
![image.png](images/image%204.png)
#### **3.2.2 用车助手场景**
- **用户场景**
用户在绑定车辆的情况下进入爱车页面使用AI助手则我用车助手场景
- **功能描述**
用车助手场景下的AI助手不同于购车场景可以按照文本的输入分为三种普通问答车控指令错误问题反馈
- **需求说明**
- 用车场景下的功能按钮为官方推荐,内容包括智能场景推荐、数据埋点和运营位,其中智能场景推荐包括售后维保提醒,智能场景建议;数据埋点包括能耗报告,行为分析,用户习惯操作;运营位则是推荐的车型,用户复购建议;
- 常见功能按钮 → 发送一条“功能集合”消息气泡(含多个按钮)。
- 点击 更多工具按钮跟常见功能按钮一样,会返回一条聊天记录,里面会包含多个按钮,点击按钮可以进行快捷回复;
- 快捷回复区域是可以变化的,会根据对话的最后一个回复进行变化,快捷回复区域会跟在最后一个对话的底部;例如刚进入会跟在 功能按钮的底部,但是新的聊天记录出现后,就会自动拼接到聊天气泡的底部(聊天气泡类型分为多种,后面会详细介绍);
![image.png](images/image%202.png)
#### **3.2.3 用户唤醒场景**
- **用户场景**
在开通语音唔醒功能之后,购车页面和爱车页面都可以使用语音唤醒,免手动触发,提高便利性
- **功能描述**
用户可以通过语音唤醒功能在不需要点击按钮的情况下激活AI助手提高使用便捷性和用户体验。唤醒词“你好众众”唤醒后直接进入聆听模式并高亮反馈。
- **需求说明**
- 设置页开启后常驻监听(低功耗策略)。
- 唤醒检测在本地执行,未唤醒音频不上传。
- 唤醒成功:音效 + 动画反馈。
- 低电量模式:可自动降敏或暂停。
- 权限缺失:首次引导开启麦克风权限。
### 3.3 聊天模块
#### **3.3.1 聊天气泡类型**
- **用户场景**
用户与AI助手对话过程中会展示不同类型的聊天气泡以适应各种交互需求。
- **功能描述**
系统支持多种聊天气泡类型,包括文本气泡、卡片气泡、按钮气泡、链接气泡和富媒体气泡等,以满足不同场景下的交互需求。
- **需求说明**
- 文本气泡展示普通文字对话内容完整支持Markdown格式
Markdown支持包括普通文本格式化、表格布局以及图片嵌入提供更丰富的内容展示方式
- 卡片气泡:用于展示结构化信息,如车辆信息、维保记录等;
- 按钮气泡:包含可交互按钮,用于快速回复或执行操作;
- 富媒体气泡:支持图片、视频、语音等多媒体内容展示;
富媒体气泡可能适用于显示特定的信息,或者是视频的教程,轮播的图片
- 链接气泡用于显示一个链接包含IconTitleDescription以及一个可以点击的链接
点击链接可以进入webview链接气泡主要是用于详细介绍某些活动或者查看详情
- 业务气泡:用于特定业务的定制化气泡,会根据对话的业务场景,显示不同的气泡。
业务气泡不同于其他的气泡,可以在用户询问金融方案后显示返回的计算结果,在结果后面还有一个按钮,可以进入金融计算器的页面;同时在用户说出我希望订购某款车型时,会出现车型的信息,并且有一个立即订购的按钮。业务气泡是根据返回的数据来判断具体的业务场景,混合显示的气泡类型。
![按钮气泡](images/button_bubble_image.png)
![快捷回复](images/quick_reply_image.png)
![业务气泡1](images/image%203.png)
![业务气泡2](images/bussine_image.png)
![聊天气泡](images/bubble_image_list.png)
#### **3.3.2 普通问答流程**
- **用户场景**
用户与AI助手对话过程中AI助手会把语音转换为文本通过文本的语义来判断场景是普通问答或者是车控指令
- **功能描述**
普通问答现在只有用车助手这个场景,后续增加新的场景:购车助手;需要先通过文本语义判断是哪个场景,然后根据相应场景来回复问题。
- **需求说明**
- 场景识别由后端完成,前端仅消费分类结果
- SSE按流片段累积合并为 Markdown
不管是购车场景还是用车场景或者是开放式对话都有一个问答的流程场景的区分不是前端来判断。现在流程是将语音转化的文本通过接口发给后台服务然后后台会根据body里面的text和conversation_id 来返回一个文件流客户端这边会把文件流的全部数据接受完成后合并为一个markdown语法的文本。
```mermaid
graph TD
A[文本数据] --> B{Chat api}
B --> C[文件流] --> D{SSE Service} --> E[Markdown文本]
E --> F{TTS} --> G[播放语音]
E --> H{Message} --> I[显示]
```
现在有两个新的调整方案一个是Socket连接另外一个方案是在现有的流程基础上新增消息类型。
1. Socket方案
每次进入AI助手会话会创建一个随机的taskId然后每次对话都会有一个conversationId使用taskId 连接Socket成功后就可以通过Socket发送语音或者文本消息。然后Socket 会返回三种不同的消息类型,`it消息 (识别文本)``gb消息 (回复文本)``tts消息 (语音数据)`
流程图如下:
```mermaid
sequenceDiagram
participant C as 客户端
participant S as 服务器
Note over C, S: 第一步建立WebSocket连接
C->>S: WebSocket Handshake (携带认证信息)
Note over C, S: 第二步:客户端发送语音数据
C->>S: 发送消息: {taskId, conversationId, audioData}
Note over C, S: 第三步:服务器异步返回多条消息
par 并行处理与推送
S-->>C: it消息 (识别文本)
and
S-->>C: gb消息 (回复文本)
and
S-->>C: tts消息 (语音数据)
end
Note over C, S: 连接保持,可继续发送新语音或关闭
```
2. HTTP方案
HTTP方案是保持原有流程的情况下进行扩展chat API 不再是返回一个数据流而是返回一个json数据json数据里面会有不同的消息返回对应上面不同的气泡类型消息类似分为`stream``text``media``bussines``stream`消息就是之前的数据流的消息,通过`messageId`就可以获取到数据;`text`消息是普通的文本消息,可以实现上面的链接气泡,按钮气泡;`media`消息则可以实现负责的图文混排,或者是一个单纯的视频教程;`bussines`消息主要以业务相关的,会根据业务类型显示不一样的样式,内容和按钮也会有变化。
```json
{
"conversationId": 1,
"taskId": "task_123",
"code": 200,
"scenario": "buying_assistant",
"messageList": [
{
"messageId": "messageId_xxxxx",
"type": "stream"
},
{
"messageId": "messageId_xxxxx",
"type": "text",
"content": "xxxxx"
},
{
"messageId": "messageId_xxxxx",
"type": "media"
},
{
"messageId": "messageId_xxxxx",
"type": "bussines",
"bussineType": "vehicle_commendation"
},
]
}
```
#### **3.3.3 车控指令流程**
- **用户场景**
用户与AI助手对话过程中AI助手会把语音转换为文本通过文本的语义来判断场景是普通问答或者是车控指令
- **功能描述**
如果根据分类结果,将语音或者文本信息归类为车控指令,则会进入车控指令的流程。识别语音指令中的车控意图,解析参数,并调用相应的车辆控制接口。
- **需求说明**
- **命令识别**支持识别20+种车控命令类型。
- **参数解析**:能从指令中解析出关键参数(如温度值、座椅位置)。
- **执行反馈**命令执行成功或失败都必须有明确的TTS和UI反馈。
- **可扩展性**:提供抽象类`VehicleCommandHandler`,允许开发者根据具体车型实现自定义控制逻辑。
- **支持命令示例**
- **车门控制**:上锁、解锁
- **车窗控制**:开启、关闭
- **空调控制**:开启、关闭、温度调节、极速降温
- **座椅控制**:主/副驾驶座椅加热、通风
- **后备箱控制**:开启、关闭
- **特殊功能**:鸣笛、车辆定位、一键备车、一键融雪
#### **3.3.4 聊天信息持久化**
- **用户场景**
用户在与AI助手进行对话时会产生多条聊天记录。为保护用户隐私和提升体验这些聊天记录默认仅保存在本地设备不会上传至云端。
- **功能描述**
系统支持本地化持久化存储普通问答类型的聊天记录。所有文本类消息将以结构化数据形式保存至本地数据库推荐使用SQLite或Hive等Flutter主流轻量级数据库便于后续检索和管理。
- **需求说明**
- **本地存储**所有与AI助手的聊天记录包括文本、语音、气泡类型、时间戳等需保存在本地设备支持持久化
- **存储方式**采用本地数据库如SQLite、Hive等Flutter常用方案或文件存储保证数据安全和高效读写
- **读取与展示**:用户重新进入聊天界面时,不会自动加载历史聊天记录,点击显示历史记录之后才会加载聊天记录,按时间顺序展示;
- **数据结构**每条聊天记录需包含消息ID、会话ID、消息类型文本/语音/卡片/按钮/富媒体)、内容、时间戳、发送方(用户/AI、气泡类型等
- **删除与清理**支持用户手动删除单条或全部聊天记录支持定期自动清理如超过30天自动删除
- **持久化逻辑**在Socket和HTTP两种消息方案下收到消息体后系统会优先将stream和text类型消息持久化保存其他类型消息仅用于即时展示不做持久化处理。
有了消息的持久化AI助手聊天消息的显示逻辑也会跟着变化按照上面的`Socket`方案和`HTTP`方案,收到消息体之后首先会保存到本地,特别是`HTTP`方案中会有多条消息返回持久化只会保存stream和text消息其他消息不会保存。
```mermaid
graph TD
A[收到AI助手消息] --> B[显示消息到聊天界面]
B --> C{消息类型判断}
C -->|stream/text| D[持久化保存到本地数据库]
C -->|其他类型| E[仅显示,不持久化]
D --> F[历史消息管理]
F --> G[用户点击显示历史记录]
G --> H[加载历史消息并展示]
```
## 4. 非功能需求
### 4.1 服务模块
#### **4.1.1 模块设计目标**
服务模块负责“输入 → 解析 → 对话/车控 → 反馈 → 持久化”全链路的能力封装向上提供简单一致的编程接口向下屏蔽多种后端形态HTTP / SSE与原生能力录音、TTS、权限、ASR
设计目标:
- 解耦:各服务单一职责,可独立替换;
- 可扩展:新协议 / 新命令 / 新气泡类型通过注册或策略扩展;
- 可裁剪:按需选择(如不需要车控,可不接入 command 子模块)。
#### **4.1.2 模块目录设计**
拟将现有核心服务抽离为独立基础包例如ai_chat_core供 UI 插件依赖。
建议目录结构:
```
packages/
ai_chat_core/
lib/
ai_chat_core.dart # 对外统一入口
src/ # 代码目录
models/ # models
message.dart # 消息和消息块
message_chunk.dart
vehicle_command.dart # 车控
vehicle_command_response.dart
command_result.dart
enums/ # enums
message_type.dart
message_status.dart
command_type.dart
service_state.dart # 服务状态
input/
audio_recorder_service.dart # 录音以及保存文件
voice_recognition_service.dart # 目前项目未使用
wake_word_service.dart # 预留(可选)
nlp/ # 自然语言处理相关
text_classification_service.dart
vehicle_command_service.dart
dialog/ #对话相关的
chat_channel/
chat_sse_service.dart # SSE
chat_ws_service.dart # 预留 WebSocket可选
chat_http_service.dart # 批量 HTTP 方案(可选)
message_service.dart # 核心编排/状态更新
tts/ # TTS
tts_service.dart
tts_task_queue.dart
command/ # 车控相关
command_service.dart # Command Register Callback已更新为抽象类的方式
vehicle_command_handler.dart # 自定义实现车控命令
persistence/ # 持久化
persistence_service.dart # 提供持久化服务
dao/ # dao
message_dao.dart # 消息体的 dao 对象
infra/ # infra 的服务
config_service.dart
redis_service.dart
location_service.dart
vehicle_state_service.dart # 预留
logger.dart
error_mapper.dart
utils/ # 工具类
markdown_cleaner.dart
id_generator.dart
throttle.dart
```
对外暴露ai_chat_core.dart仅导出
- models / enums
- MessageService主入口
- VehicleCommandHandler 抽象
- 配置初始化initCore / setConfig
#### **4.1.3 核心服务说明**
| 服务 | 角色 | 主要职责 | 关键点 |
| ---- | ---- | -------- | ------ |
| MessageService | 总编排 | 状态机、消息生命周期、调用链调度 | 统一入口 |
| AudioRecorderService | 输入 | 录音启动/停止、静音检测 | 录制参数配置 |
| VoiceRecognitionService | 输入处理 | 语音转文本(在线识别 / 中英判别) | 失败降级提示 |
| WakeWordService | 输入唤醒 | 本地唤醒词检测 | 低功耗策略 |
| TextClassificationService | 语义分类 | 业务场景 / 指令 / 问答判断 | 分类缓存 |
| VehicleCommandService | 语义→结构 | 解析多条车控命令列表 + 参数 | 容错解析 |
| CommandService | 执行派发 | 调用 Host 注入的 VehicleCommandHandler | 失败重试 |
| ChatSseService | 对话通道 | SSE 流接收与分片回调 | 首 Token 优化 |
| ChatWsService预留 | 对话通道 | WebSocket 统一多类型消息 | 心跳+重连 |
| ChatHttpService预留 | 对话通道 | 一次性批量消息 | 并行补拉 |
| TtsService + TtsTaskQueue | 输出播报 | 分句 / 排队 / 抢占 / 语言切换 | 顺序一致性 |
| PersistenceService | 持久化 | 消息落地 / 分页 / 过期清理 | schema 迁移 |
| RedisService | 辅助缓存 | 临时状态(可选) | 可替换 |
| LocationService | 扩展 | 位置信息(用于上下文) | 可选注入 |
| VehicleStateService | 扩展 | 实时车况(未来支持) | 监听通道 |
| ConfigService | 基础设施 | 端点 / 超时 / 重试策略统一 | 环境隔离 |
| Logger | 基础设施 | 分级日志 + 调试开关 | 生产开关 |
| MarkdownCleaner | 工具 | 清理/裁剪 Markdown 用于 TTS | 多语言规则 |
| ErrorMapper | 工具 | 后端错误码 → 用户友好文案 | 本地化适配 |
#### **4.1.4 服务分层**
- 链路图
```mermaid
sequenceDiagram
autonumber
participant UI as UI层
participant MS as MessageService
participant AR as AudioRecorder
participant ASR as VoiceRecognition
participant CLS as TextClassification
participant VCS as VehicleCommandService
participant DLG as ChatSse/WS/HTTP
participant CMD as CommandService
participant HND as VehicleCommandHandler(Host)
participant TTS as TtsService
participant PERS as PersistenceService
UI->>MS: startVoiceInput()
MS->>AR: start()
AR-->>MS: audioChunks / silenceEnd
MS->>AR: stop()
MS->>ASR: recognize(audio)
ASR-->>MS: recognizedText
MS->>CLS: classify(text)
CLS-->>MS: category / scenario
alt 车控指令
MS->>VCS: parseCommands(text)
VCS-->>MS: commands[]
loop each command
MS->>CMD: execute(command)
CMD->>HND: executeCommand(command)
HND-->>CMD: result(success/fail)
CMD-->>MS: executionResult
MS->>TTS: enqueue("执行结果反馈")
MS->>PERS: save(control_feedback)
end
else 普通问答
MS->>DLG: requestStream(text, convId)
DLG-->>MS: streamChunk/ finalMessage
MS->>TTS: enqueue(chunk/segments)
MS->>PERS: save(text / merged)
else 错误/兜底
MS->>TTS: enqueue(提示语)
MS->>PERS: save(system_message)
end
TTS-->>UI: 播放中事件
PERS-->>MS: persist OK
MS-->>UI: 更新消息列表
```
- 逻辑分层
```mermaid
graph TD
A[UI层] --> B[MessageService]
B --> C1[输入层 AudioRecorder / WakeWord]
B --> C2[语义层 Classification / VehicleCommand]
B --> C3[对话通道层 SSE / WS / HTTP]
B --> C4[执行层 CommandService + Host Handler]
B --> C5[输出层 TtsService]
B --> C6[存储层 PersistenceService]
B --> C7[基础设施 Config / Logger / Redis / Location]
```
#### **4.1.5 代码示例**
- 初始化:
```dart
await AiChatCore.init(
config: CoreConfig(
endpoints: Endpoints(
classify: "...",
sse: "...",
ws: "...",
controlParse: "...",
controlExec: "...",
voice: "...",
),
timeouts: Timeouts(connectMs: 8000, receiveMs: 20000),
enablePersistence: true,
persistenceMode: PersistenceMode.hive,
retryPolicy: RetryPolicy(maxAttempts: 2, backoffBaseMs: 400),
),
commandHandler: MyVehicleCommandHandler(),
logger: CustomLogger(),
);
```
- 统一公共接口:
```dart
abstract class AiChatCore {
static Future<void> init({required CoreConfig config, required VehicleCommandHandler commandHandler, Logger? logger});
static MessageService get messageService;
static PersistenceService? get persistence;
}
class MessageService {
Stream<List<Message>> get messageStream;
Future<void> startVoiceInput();
Future<void> stopAndProcessVoiceInput();
Future<void> sendText(String text);
Future<void> abortAnswer();
Future<void> loadHistory({required int page, required int pageSize});
}
```
#### **4.1.6 编译与分发说明path 子包 vs 单包)**
- 当前结构插件ai_chat_assistant+ 仓库内 path 依赖packages/ai_chat_core、packages/basic_intl
- 下游集成pubspec仅需
```yaml
ai_chat_assistant:
git:
url: <repo_url>
ref: <branch_or_tag>
```
- Flutter 构建行为:
1. 克隆仓库 → 解析主插件 pubspec → 解析 path 子包 → 汇总全部 Dart 源码。
2. DebugJITRelease统一 AOT 编译为一个 snapshot无包边界差异
- 性能与产物差异:将子包“内联”进 lib/ 与保持 path 组织在编译后无本质差异tree shaking 后输出等价)。
- 适用场景对比:
| 目标 | 保持多包 | 合并单包 |
| ---- | -------- | -------- |
| 代码隔离 / 复用 | 优 | 弱 |
| 发布到 pub.dev | 需逐包发布 | 更简单 |
| 测试隔离性 | 优 | 一般 |
| 用户认知成本 | 稍高 | 低 |
- 暂不需发布:保持 path 结构即可;若未来开放生态 → 发布次序ai_chat_core → basic_intl → ai_chat_assistant。
#### **4.1.7 AAR 与闭源分发说明**
| 诉求 | 可行性 | 说明 |
| ---- | ------ | ---- |
| 给“另一个 Flutter App”仅提供预编译 AAR 使用 | 不推荐/不可行 | Flutter 仍需源文件参与整体编译流程 |
| 给纯原生 Android App 内嵌使用 | 可用 `flutter build aar` | 适用于“原生 App 集成 Flutter 模块”,不是插件分发 |
| Dart 逻辑闭源 | 官方未支持 | 只能转移核心到 native/FFI 或服务端 |
结论:对 Flutter 使用方,直接 Git 依赖即可,无需 AAR。
---
## 参考文件
> [AI Assistant in One App.pdf](https://telekom-my.sharepoint.de/:b:/r/personal/guangfei_zhao_t-systems_com/Documents/Dokumente/AI%20Assistant%20in%20One%20App.pdf?csf=1&web=1&e=On4V2o)
> [Figma 设计稿](https://www.figma.com/design/04VXNLNrLdVDHhKBuOr06o/AI_Chat_Assistant?node-id=2-4607&t=qofYGMFzBLQ7cKus-1)
> [分子送检系统需求规格书.docx](images/%E5%88%86%E5%AD%90%E9%80%81%E6%A3%80%E7%B3%BB%E7%BB%9F%E9%9C%80%E6%B1%82%E8%A7%84%E6%A0%BC%E4%B9%A6.docx)

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 KiB

BIN
docs/images/image 1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
docs/images/image 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
docs/images/image 3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

BIN
docs/images/image 4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
docs/images/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

View File

@@ -30,6 +30,10 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name="com.tsystems.flutter_vosk_wakeword.WakewordService"
android:exported="false"
android:foregroundServiceType="microphone" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

File diff suppressed because one or more lines are too long

View File

@@ -31,3 +31,7 @@ plugins {
}
include(":app")
// 显式包含 flutter_vosk_wakeword 插件的 android 模块
include(":flutter_vosk_wakeword")
project(":flutter_vosk_wakeword").projectDir = File(settings.rootDir, "../../packages/flutter_vosk_wakeword/android")

View File

@@ -3,6 +3,8 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:ai_chat_assistant/ai_chat_assistant.dart';
import 'pages/home.dart';
class VehicleCommandClent extends VehicleCommandHandler {
@override
AIChatCommandCubit? get commandCubit => super.commandCubit;
@@ -13,7 +15,6 @@ class VehicleCommandClent extends VehicleCommandHandler {
print('执行车控命令: ${command.type}, 参数: ${command.params}');
if (commandCubit != null) {
commandCubit?.emit(AIChatCommandState(
commandId: command.commandId,
commandType: command.type,
@@ -64,6 +65,10 @@ void main() async {
AIChatAssistantManager.instance.setupCommandHandle(commandHandler: VehicleCommandClent());
runApp(const MyApp());
// AIChatAssistantManager.instance.setWakeWordDetection(true);
// AIChatAssistantManager.instance.startVoskWakeword();
}
class MyApp extends StatelessWidget {
@@ -77,18 +82,7 @@ class MyApp extends StatelessWidget {
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const ExampleHomePage(),
);
}
}
class ExampleHomePage extends StatelessWidget {
const ExampleHomePage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: ChatAssistantApp(),
home: HomePage(),
);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:ai_chat_assistant/ai_chat_assistant.dart';
import 'setting.dart';
class ChatAssistantPage extends StatelessWidget {
const ChatAssistantPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: ChatAssistantApp(),
);
}
}
class HomePage extends StatefulWidget {
HomePage({super.key});
final items = [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
];
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _selectedIndex = 0;
final List<Widget> _pages = <Widget>[
ChatAssistantPage(),
SettingPage(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _pages.elementAt(_selectedIndex),
bottomNavigationBar: BottomNavigationBar(
items: widget.items,
selectedItemColor: Colors.blue,
unselectedItemColor: Colors.grey,
currentIndex: _selectedIndex,
onTap: _onItemTapped,
),
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class SettingPage extends StatefulWidget {
const SettingPage({super.key});
@override
State<SettingPage> createState() => _SettingPageState();
}
class _SettingPageState extends State<SettingPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('设置'),
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('设置页面'),
SizedBox(height: 20),
Text('这里可以添加更多设置选项'),
],
),
),
);
}
}

View File

@@ -8,6 +8,13 @@ packages:
relative: true
source: path
version: "1.0.0+1"
ai_chat_core:
dependency: transitive
description:
path: "../packages/ai_chat_core"
relative: true
source: path
version: "0.1.0"
args:
dependency: transitive
description:
@@ -80,13 +87,6 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.0"
basic_intl:
dependency: transitive
description:
path: "../packages/basic_intl"
relative: true
source: path
version: "0.2.0"
boolean_selector:
dependency: transitive
description:
@@ -180,6 +180,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.7+1"
flutter_markdown_plus:
dependency: transitive
description:
name: flutter_markdown_plus
sha256: fe74214c5ac2f850d93efda290dcde3f18006e90a87caa9e3e6c13222a5db4de
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.3"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -193,6 +201,21 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.2.3"
flutter_voice_processor:
dependency: transitive
description:
name: flutter_voice_processor
sha256: "8ae3fc196d6060a13392e4ca557f7a2f4a6b87898ae519787f6929f986538572"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
flutter_vosk_wakeword:
dependency: transitive
description:
path: "../packages/flutter_vosk_wakeword"
relative: true
source: path
version: "0.0.1"
flutter_web_plugins:
dependency: transitive
description: flutter
@@ -422,6 +445,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
porcupine_flutter:
dependency: transitive
description:
name: porcupine_flutter
sha256: "5618b20a00c44aab80763a70cab993037dd73d924dd2a24154c36dc13bdf5c8e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.5"
provider:
dependency: transitive
description:
@@ -539,6 +570,13 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.3.0+3"
t_basic_intl:
dependency: transitive
description:
path: "../packages/basic_intl"
relative: true
source: path
version: "0.2.0"
term_glyph:
dependency: transitive
description:
@@ -604,5 +642,5 @@ packages:
source: hosted
version: "1.1.0"
sdks:
dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.0"
dart: ">=3.6.2 <4.0.0"
flutter: ">=3.27.1"

View File

@@ -42,52 +42,11 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^5.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# overrides_dependencies:
# flutter_vosk_wakeword:
# path: ../packages/flutter_vosk_wakeword
# 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
# 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

View File

@@ -16,14 +16,7 @@ export 'services/message_service.dart';
export 'services/command_service.dart';
// types && enums && models
export 'models/vehicle_cmd.dart';
export 'models/vehicle_cmd_response.dart';
export 'models/chat_message.dart';
export 'models/vehicle_status_info.dart';
export 'enums/vehicle_command_type.dart';
export 'enums/message_status.dart';
export 'enums/message_service_state.dart';
export 'package:ai_chat_core/ai_chat_core.dart';
// utils
export 'utils/assets_util.dart';

View File

@@ -1,5 +1,3 @@
import '../enums/vehicle_command_type.dart';
import '../services/command_service.dart';
import 'easy_bloc.dart';
import 'command_state.dart';

View File

@@ -1,6 +1,7 @@
import '../enums/vehicle_command_type.dart';
// AI Chat Command States
import 'package:ai_chat_core/ai_chat_core.dart';
enum AIChatCommandStatus {
idle,
executing,

View File

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

View File

@@ -1,9 +1,16 @@
import 'dart:async';
import 'dart:io';
import 'package:ai_chat_core/ai_chat_core.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_vosk_wakeword/flutter_vosk_wakeword.dart';
import 'package:path_provider/path_provider.dart';
import 'package:porcupine_flutter/porcupine_error.dart';
import 'package:porcupine_flutter/porcupine_manager.dart';
import 'bloc/ai_chat_cubit.dart';
import 'bloc/command_state.dart';
import 'models/vehicle_cmd.dart';
/// 车辆命令处理器抽象类
abstract class VehicleCommandHandler {
@@ -20,7 +27,7 @@ class AIChatAssistantManager {
AIChatAssistantManager._internal() {
// 初始化代码
debugPrint('AIChatAssistant 单例创建');
Logger.i('AIChatAssistant 单例创建');
_commandCubit = AIChatCommandCubit();
}
@@ -44,9 +51,133 @@ class AIChatAssistantManager {
return _commandCubit!.stream;
}
// --- Porcupine 唤醒词属性 ---
PorcupineManager? _porcupineManager;
final StreamController<void> _wakeWordController = StreamController<void>.broadcast();
bool _isWakeWordEnabled = false;
bool _wakeWordDetected = false;
// PicoVoice AccessKey
final String _picoAccessKey = "CRkyWZ3DRFXVogXEhE2eK2/ZN1q9YEYweTnjVWlve86nKAid5i1zsQ==";
// 资产逻辑路径 (用于 rootBundle.load)
static const String _keywordAssetPath = "packages/ai_chat_assistant/assets/zhongzhong_zh_android.ppn";
static const String _modelAssetPath = "packages/ai_chat_assistant/assets/porcupine_params_zh.pv";
// 真实文件系统路径 (Porcupine 使用)
String? _keywordFilePath;
String? _modelFilePath;
/// 监听唤醒词事件的 Stream
Stream<void> get onWakeWordDetected => _wakeWordController.stream;
/// 当前是否检测到了唤醒词
bool get isWakeWordDetected => _wakeWordDetected;
/// 将 Flutter asset 写入设备文件系统,返回其真实路径
Future<String> _extractAsset(String assetPath, String fileName) async {
final dir = await getApplicationSupportDirectory();
final file = File('${dir.path}/$fileName');
if (!file.existsSync()) {
final data = await rootBundle.load(assetPath);
await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
Logger.i('Asset "$assetPath" extracted to "${file.path}"');
}
return file.path;
}
Future<void> startVoskWakeword() async {
FlutterVoskWakeword.instance.initialize(
wakeWords: ['你好众众', '你好', '众众', '测试', '哈喽', '唤醒'], // 唤醒词列表
);
Logger.i('Starting Vosk Wakeword detection...');
FlutterVoskWakeword.instance.start();
FlutterVoskWakeword.instance.recognitionStream.listen((event) {
Logger.i('Vosk Wakeword detected: ${event.text}');
_wakeWordDetected = true;
// 通知所有监听者
_wakeWordController.add(null);
// 监听到之后就停止,这个时候已经弹出了对话框
// 对话框消失就可以继续监听 recognitionStream 的订阅不会断开
FlutterVoskWakeword.instance.stop();
});
}
Future<void> stopVoskWakeword() async {
if (_wakeWordDetected) {
_wakeWordDetected = false;
}
if (FlutterVoskWakeword.instance != null) {
FlutterVoskWakeword.instance.stop();
}
}
/// 开启或关闭唤醒词检测
Future<void> setWakeWordDetection(bool enable) async {
if (enable == _isWakeWordEnabled && _porcupineManager != null) {
return; // 状态未改变
}
_isWakeWordEnabled = enable;
if (enable) {
try {
// 确保关键词和模型文件已提取到文件系统
_keywordFilePath ??= await _extractAsset(_keywordAssetPath, "zhongzhong_zh_android.ppn");
_modelFilePath ??= await _extractAsset(_modelAssetPath, "porcupine_params_zh.pv");
_porcupineManager = await PorcupineManager.fromKeywordPaths(
_picoAccessKey,
[_keywordFilePath!],
_wakeWordDetectedCallback,
modelPath: _modelFilePath, // 中文模型必须指定 modelPath
errorCallback: _porcupineErrorCallback,
sensitivities: [0.6], // 可以调整灵敏度
);
await _porcupineManager?.start();
Logger.i("Porcupine manager started for wake-word detection.");
} on PorcupineException catch (e) {
Logger.e("Failed to init Porcupine: ${e.message}");
_isWakeWordEnabled = false;
}
} else {
await _porcupineManager?.stop();
await _porcupineManager?.delete();
_porcupineManager = null;
_wakeWordDetected = false; // 关闭时重置状态
Logger.i("Porcupine manager stopped and deleted.");
}
}
// 唤醒词检测回调
void _wakeWordDetectedCallback(int keywordIndex) {
// 我们只用了一个关键词,所以索引总是 0
if (keywordIndex == 0) {
_wakeWordDetected = true;
Logger.i("Wake-word '众众' detected!");
// 通知所有监听者
_wakeWordController.add(null);
// 可选:一段时间后自动重置状态
Future.delayed(const Duration(seconds: 2), () {
_wakeWordDetected = false;
});
}
}
void _porcupineErrorCallback(PorcupineException error) {
Logger.e("Porcupine error: ${error.message}");
}
/// 释放资源
void dispose() {
_commandCubit?.close();
_commandCubit = null;
_wakeWordController.close();
setWakeWordDetection(false); // 确保 Porcupine 停止并释放
FlutterVoskWakeword.instance.dispose();
Logger.i('AIChatAssistant 单例销毁');
}
}

View File

@@ -1,7 +1,6 @@
import 'package:ai_chat_assistant/models/chat_message.dart';
import 'package:basic_intl/intl.dart';
import 'package:ai_chat_core/ai_chat_core.dart';
import 'package:t_basic_intl/intl.dart';
import 'package:flutter/material.dart';
import '../enums/message_status.dart';
import '../widgets/chat_box.dart';
import '../widgets/gradient_background.dart';
import '../services/message_service.dart';

View File

@@ -37,8 +37,7 @@ class _MainScreenState extends State<MainScreen> {
),
),
// FloatingIcon(),
ChatPopup(
)
ChatPopup()
],
),
);

View File

@@ -1,9 +1,11 @@
import 'package:ai_chat_core/ai_chat_core.dart';
import 'package:record/record.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:async';
import 'dart:io';
// 负责录音相关功能
@Deprecated('AudioRecorderService Deprecated')
class AudioRecorderService {
final AudioRecorder _recorder = AudioRecorder();
bool _isRecording = false;
@@ -15,11 +17,11 @@ class AudioRecorderService {
Future<void> checkPermission() async {
final hasPermission = await _recorder.hasPermission();
print('麦克风权限状态: $hasPermission');
Logger.i('麦克风权限状态: $hasPermission');
}
Future<void> startRecording() async {
print('开始录音...');
Logger.i('开始录音...');
try {
// 使用文件录音可能更稳定
@@ -27,7 +29,7 @@ class AudioRecorderService {
_tempFilePath =
'${tempDir.path}/temp_audio_${DateTime.now().millisecondsSinceEpoch}.opus';
print('录音文件路径: $_tempFilePath');
Logger.i('录音文件路径: $_tempFilePath');
if (await _recorder.hasPermission()) {
await _recorder.start(
@@ -36,12 +38,12 @@ class AudioRecorderService {
);
_isRecording = true;
print('录音已开始使用OPUS格式文件: $_tempFilePath');
Logger.i('录音已开始使用OPUS格式文件: $_tempFilePath');
} else {
print('没有麦克风权限,无法开始录音');
Logger.e('没有麦克风权限,无法开始录音');
}
} catch (e) {
print('录音开始出错: $e');
Logger.e('录音开始出错: $e');
_isRecording = false;
}
}
@@ -51,30 +53,30 @@ class AudioRecorderService {
_isRecording = false;
print('停止录音...');
Logger.i('停止录音...');
try {
final path = await _recorder.stop();
print('录音已停止,文件路径: $path');
Logger.i('录音已停止,文件路径: $path');
if (path != null) {
final file = File(path);
if (await file.exists()) {
final bytes = await file.readAsBytes();
print('读取到录音数据: ${bytes.length} 字节');
Logger.i('读取到录音数据: ${bytes.length} 字节');
if (bytes.isNotEmpty) {
return bytes;
} else {
print('录音数据为空');
Logger.e('录音数据为空');
}
} else {
print('录音文件不存在: $path');
Logger.e('录音文件不存在: $path');
}
} else {
print('录音路径为空');
Logger.e('录音路径为空');
}
} catch (e) {
print('停止录音或发送过程出错: $e');
Logger.e('停止录音或发送过程出错: $e');
}
return null;
}

View File

@@ -1,19 +1,23 @@
// 负责SSE通信
import 'dart:convert';
import 'dart:io';
import 'dart:io' as IO;
import 'dart:math';
import 'package:ai_chat_assistant/utils/tts_util.dart';
import 'package:ai_chat_assistant/ai_chat_assistant.dart';
import '../utils/tts_util.dart';
import '../utils/common_util.dart';
@Deprecated('ChatSseService Deprecated, Use ai_chat_core/ChatSseService instead')
/// SSE 服务类,处理与后端的流式通信
class ChatSseService {
// 缓存用户ID和会话ID
String? _cachedUserId;
String? _cachedConversationId;
HttpClient? _currentClient;
HttpClientResponse? _currentResponse;
IO.HttpClient? _currentClient;
IO.HttpClientResponse? _currentResponse;
bool _isAborted = true;
bool _isFirstData = true;
bool _isTtsStarted = false;
@@ -28,11 +32,11 @@ class ChatSseService {
required bool isChinese,
required Function(String, String, bool) onStreamResponse,
}) async {
print("----------------------SSE Start");
Logger.i("----------------------SSE Start");
_isAborted = false;
if (_cachedUserId == null) {
_cachedUserId = _generateRandomUserId(6);
print('初始化用户ID: $_cachedUserId');
Logger.i('初始化用户ID: $_cachedUserId');
}
String responseText = '';
StringBuffer buffer = StringBuffer();
@@ -78,7 +82,7 @@ class ChatSseService {
// 只在达到完整句子时调用 TtsUtil.send
for (final s in sentences) {
String ttsStr=CommonUtil.cleanText(s, true);
// print("发送数据到TTS: $ttsStr");
// Logger.i("发送数据到TTS: $ttsStr");
TtsUtil.send(ttsStr);
}
// 缓存剩余不完整部分
@@ -88,7 +92,7 @@ class ChatSseService {
buffer.write(remain);
}
}
_currentClient = HttpClient();
_currentClient = IO.HttpClient();
try {
final chatUri = Uri.parse('http://143.64.185.20:18606/chat');
final request = await _currentClient!.postUrl(chatUri);
@@ -112,6 +116,7 @@ class ChatSseService {
break;
}
if (line.startsWith('data:')) {
Logger.i('SSE line: $line');
final jsonStr = line.substring(5).trim();
if (jsonStr == '[DONE]' || jsonStr.contains('message_end')) {
TtsUtil.complete();
@@ -120,6 +125,7 @@ class ChatSseService {
}
try {
final jsonData = json.decode(jsonStr);
Logger.i('SSE jsonData: $jsonData');
if (jsonData.containsKey('conversation_id') &&
_cachedConversationId == null) {
_cachedConversationId = jsonData['conversation_id'];
@@ -136,12 +142,12 @@ class ChatSseService {
onStreamResponse(messageId, responseText, false);
}
} catch (e) {
print('解析 SSE 数据出错: $e, 原始数据: $jsonStr');
Logger.e('解析 SSE 数据出错: $e, 原始数据: $jsonStr');
}
}
}
} else {
print('SSE 连接失败,状态码: ${_currentResponse!.statusCode}');
Logger.e('SSE 连接失败,状态码: ${_currentResponse!.statusCode}');
}
} catch (e) {
// todo
@@ -151,7 +157,7 @@ class ChatSseService {
}
Future<void> startTts(bool isChinese) async {
print("----------------------TTS Start");
Logger.i("----------------------TTS Start");
if (!_isTtsStarted) {
if (await TtsUtil.start(isChinese) == true) {
_isTtsStarted = true;
@@ -188,7 +194,7 @@ class ChatSseService {
void resetSession() {
_cachedUserId = null;
_cachedConversationId = null;
print('SSE会话已重置');
Logger.i('SSE会话已重置');
}
String _generateRandomUserId(int length) {

View File

@@ -1,27 +0,0 @@
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

@@ -1,5 +1,5 @@
import '../enums/vehicle_command_type.dart';
import '../models/vehicle_status_info.dart';
import 'package:ai_chat_core/ai_chat_core.dart';
/// 命令处理回调函数定义
typedef CommandCallback = Future<(bool, Map<String, dynamic>? params)> Function(
@@ -24,10 +24,10 @@ class CommandService {
try {
return await onCommandReceived!(type, params);
} catch (e) {
print('执行命令出错: $e');
Logger.e('执行命令出错: $e');
}
} else {
print('警告: 命令处理回调未注册');
Logger.w('警告: 命令处理回调未注册');
}
return (false, null);
}

View File

@@ -1,8 +1,6 @@
import 'dart:convert';
import 'package:ai_chat_core/ai_chat_core.dart';
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 {
@@ -30,12 +28,12 @@ class VehicleCommandService {
.toList();
return VehicleCommandResponse(tips: tips, commands: vehicleCommandList);
} else {
print(
Logger.e(
'Vehicle command query failed: ${response.statusCode}, ${response.body}');
return null;
}
} catch (e) {
print('Error during vehicle command processing: $e');
Logger.e('Error during vehicle command processing: $e');
return null;
}
}
@@ -52,10 +50,10 @@ class VehicleCommandService {
if (response.statusCode == 200) {
return response.body;
} else {
print("请求控制回复失败: ${response.statusCode}");
Logger.e("请求控制回复失败: ${response.statusCode}");
}
} catch (e) {
print('请求控制回复异常: $e');
Logger.e('请求控制回复异常: $e');
}
return reply;
}

View File

@@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:basic_intl/intl.dart';
import 'package:ai_chat_core/ai_chat_core.dart';
import 'package:t_basic_intl/intl.dart';
import 'package:http/http.dart' as http;
class LocationService {
@@ -13,7 +14,7 @@ class LocationService {
return json.decode(response.body)['address'];
}
} catch (e) {
print('Error during text classification: $e');
Logger.e('Error during text classification: $e');
}
return "";
}

View File

@@ -3,19 +3,21 @@ import 'dart:async';
import 'package:ai_chat_assistant/ai_chat_assistant.dart';
import 'package:ai_chat_assistant/utils/common_util.dart';
import 'package:ai_chat_assistant/utils/tts_util.dart';
import 'package:basic_intl/intl.dart';
import 'package:t_basic_intl/intl.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:uuid/uuid.dart';
import '../services/chat_sse_service.dart';
import '../services/classification_service.dart';
import '../services/chat_sse_service.dart' as Service;
import '../services/control_recognition_service.dart';
// import '../services/audio_recorder_service.dart';
// import '../services/voice_recognition_service.dart';
import 'command_service.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'platform_tts_service.dart';
const aliSdkChannelName = 'com.example.ai_chat_assistant/ali_sdk';
// 用单例的模式创建
class MessageService extends ChangeNotifier {
static const MethodChannel _asrChannel = MethodChannel('com.example.ai_chat_assistant/ali_sdk');
@@ -57,10 +59,15 @@ class MessageService extends ChangeNotifier {
Future<dynamic> _handleMethodCall(MethodCall call) async {
switch (call.method) {
case "onAsrResult":
{
debugPrint("ASR 结果: ${call.arguments}");
replaceMessage(
id: _latestUserMessageId!, text: call.arguments, status: MessageStatus.normal);
break;
}
case "onAsrStop":
{
debugPrint("ASR 停止: ${call.arguments}");
int index = findMessageIndexById(_latestUserMessageId!);
if (index == -1) {
return;
@@ -76,8 +83,11 @@ class MessageService extends ChangeNotifier {
break;
}
}
}
final ChatSseService _chatSseService = ChatSseService(PlatformTtsService(aliSdkChannelName));
// final Service.ChatSseService _chatSseService = Service.ChatSseService();
final ChatSseService _chatSseService = ChatSseService();
// final LocalTtsService _ttsService = LocalTtsService();
// final AudioRecorderService _audioService = AudioRecorderService();
// final VoiceRecognitionService _recognitionService = VoiceRecognitionService();
@@ -130,7 +140,7 @@ class MessageService extends ChangeNotifier {
_latestUserMessageId = addMessage("", true, MessageStatus.listening);
_asrChannel.invokeMethod("startAsr");
} catch (e) {
print('录音开始出错: $e');
Logger.e('录音开始出错: $e');
}
}
@@ -180,7 +190,7 @@ class MessageService extends ChangeNotifier {
changeState(MessageServiceState.replying);
await reply(recognizedText);
} catch (e) {
print(e);
Logger.e(e.toString());
} finally {
changeState(MessageServiceState.idle);
_asrCompleter = null;

View File

@@ -0,0 +1,36 @@
import 'package:ai_chat_core/ai_chat_core.dart';
import 'package:flutter/services.dart';
/// TtsService 的平台特定实现,通过 MethodChannel 调用原生代码
class PlatformTtsService implements TtsService {
@override
final String channelName;
final MethodChannel _channel;
PlatformTtsService(this.channelName): _channel = MethodChannel(channelName);
Future<T?> _execute<T>(String method, [Map<Object, Object>? arguments]) {
return _channel.invokeMethod(method, arguments);
}
@override
Future<bool> start(bool isChinese) async {
return await _execute('startTts', {'isChinese': isChinese}) ?? false;
}
@override
Future<void> send(String text) async {
await _execute('sendTts', {'text': text});
}
@override
Future<void> complete() async {
await _execute('completeTts');
}
@override
Future<void> stop() async {
await _execute('stopTts');
}
}

View File

@@ -1,11 +1,12 @@
import 'dart:async';
import 'dart:collection';
import 'package:ai_chat_assistant/utils/common_util.dart';
import 'package:ai_chat_core/ai_chat_core.dart';
// ...existing code...
import 'package:flutter/material.dart';
import 'package:flutter_tts/flutter_tts.dart';
@Deprecated('Use TtsService interface and its implementations instead')
class LocalTtsService {
final FlutterTts _flutterTts = FlutterTts();
bool _isPlaying = false;
@@ -45,27 +46,27 @@ class LocalTtsService {
// 设置TTS事件回调
_flutterTts.setStartHandler(() {
print('TTS开始播放');
Logger.i('TTS开始播放');
_isPlaying = true;
_onStartHandler?.call();
});
_flutterTts.setCompletionHandler(() {
print('TTS播放完成');
Logger.i('TTS播放完成');
_isPlaying = false;
_handleTtsPlaybackComplete();
});
_flutterTts.setErrorHandler((msg) {
print('TTS错误: $msg');
Logger.e('TTS错误: $msg');
_isPlaying = false;
_onErrorHandler?.call(msg);
_handleTtsPlaybackComplete(); // 错误时也要处理下一个任务
});
print('TTS引擎初始化完成');
Logger.i('TTS引擎初始化完成');
} catch (e) {
print('TTS引擎初始化失败: $e');
Logger.e('TTS引擎初始化失败: $e');
}
}
@@ -77,17 +78,17 @@ class LocalTtsService {
try {
// 获取可用的TTS引擎
dynamic engines = await _flutterTts.getEngines;
print('可用的TTS引擎: $engines');
Logger.i('可用的TTS引擎: $engines');
// 获取可用的语言
dynamic languages = await _flutterTts.getLanguages;
print('支持的语言: $languages');
Logger.i('支持的语言: $languages');
// 获取当前默认引擎
dynamic defaultEngine = await _flutterTts.getDefaultEngine;
print('当前默认引擎: $defaultEngine');
Logger.i('当前默认引擎: $defaultEngine');
} catch (e) {
print('获取TTS引擎信息失败: $e');
Logger.e('获取TTS引擎信息失败: $e');
}
}
@@ -99,19 +100,19 @@ class LocalTtsService {
_isProcessingTasks = false;
_streamCompleted = false;
_isPlaying = false; // 重置播放状态
print('所有TTS队列和缓存已清空');
Logger.i('所有TTS队列和缓存已清空');
}
/// 推送文本到TTS队列进行流式处理保证顺序
void pushTextForStreamTTS(String text) {
if (text.trim().isEmpty) {
print('接收到空字符串跳过推送到TTS队列');
Logger.i('接收到空字符串跳过推送到TTS队列');
return;
}
String cleanedText = CommonUtil.cleanText(text, true);
if (cleanedText.isEmpty) {
print('清理后的文本为空跳过推送到TTS队列');
Logger.i('清理后的文本为空跳过推送到TTS队列');
return;
}
@@ -124,7 +125,7 @@ class LocalTtsService {
);
_orderedTasks.add(task);
print('添加TTS任务 (索引: $taskIndex): $cleanedText');
Logger.i('添加TTS任务 (索引: $taskIndex): $cleanedText');
// 尝试处理队列中的下一个任务
_processNextReadyTask();
@@ -134,7 +135,7 @@ class LocalTtsService {
void _processNextReadyTask() {
// 如果正在播放,则不处理下一个任务
if (_isPlaying) {
print('当前正在播放音频,等待播放完成');
Logger.i('当前正在播放音频,等待播放完成');
return;
}
@@ -142,7 +143,7 @@ class LocalTtsService {
while (_nextTaskIndex < _orderedTasks.length) {
TtsTask task = _orderedTasks[_nextTaskIndex];
if (task.status == TtsTaskStatus.ready) {
print('按顺序播放TTS (索引: ${task.index}): ${task.text}');
Logger.i('按顺序播放TTS (索引: ${task.index}): ${task.text}');
_playTts(task.text);
return; // 等待当前音频播放完成
} else {
@@ -154,7 +155,7 @@ class LocalTtsService {
// 检查是否所有任务都已处理完
if (_streamCompleted && _nextTaskIndex >= _orderedTasks.length) {
print('=== 所有TTS任务播放完成 ===');
Logger.i('=== 所有TTS任务播放完成 ===');
_isProcessingTasks = false;
_onCompletionHandler?.call();
}
@@ -163,28 +164,28 @@ class LocalTtsService {
/// 播放TTS
Future<void> _playTts(String text) async {
if (_isPlaying) {
print('已有TTS正在播放跳过本次播放');
Logger.i('已有TTS正在播放跳过本次播放');
return;
}
try {
print('开始播放TTS: $text');
Logger.i('开始播放TTS: $text');
// 检测语言并设置
bool isChinese = CommonUtil.containChinese(text);
String targetLanguage = isChinese ? "zh-CN" : "en-US";
// 检查语言是否可用,如果不可用则使用默认语言
bool isLanguageOk = await isLanguageAvailable(targetLanguage);
if (!isLanguageOk) {
print('语言 $targetLanguage 不可用,使用默认语言');
Logger.i('语言 $targetLanguage 不可用,使用默认语言');
// 可以在这里设置备用语言或保持当前语言
} else {
await _flutterTts.setLanguage(targetLanguage);
print('设置TTS语言为: $targetLanguage');
Logger.i('设置TTS语言为: $targetLanguage');
}
// 播放TTS
await _flutterTts.speak(text);
} catch (e) {
print('播放TTS失败: $e');
Logger.i('播放TTS失败: $e');
// 播放失败,重置状态并继续下一个
_isPlaying = false;
_handleTtsPlaybackComplete();
@@ -194,7 +195,7 @@ class LocalTtsService {
/// 处理TTS播放完成
void _handleTtsPlaybackComplete() {
if (!_isPlaying) {
print('TTS已停止播放处理下一个任务');
Logger.i('TTS已停止播放处理下一个任务');
// 标记当前任务为已完成
if (_nextTaskIndex < _orderedTasks.length &&
_orderedTasks[_nextTaskIndex].status == TtsTaskStatus.ready) {
@@ -208,7 +209,7 @@ class LocalTtsService {
/// 标记流完成
void markSSEStreamCompleted() {
print('=== 标记SSE流完成 ===');
Logger.i('=== 标记SSE流完成 ===');
_streamCompleted = true;
// 尝试完成剩余任务
@@ -217,7 +218,7 @@ class LocalTtsService {
/// 停止播放
void stopSSEPlayback() {
print("停止SSE播放");
Logger.i("停止SSE播放");
// 立即停止TTS播放无论当前是否在播报
_flutterTts.stop();
_isPlaying = false;
@@ -240,11 +241,11 @@ class LocalTtsService {
// 保留原有的播放逻辑用于完整文本处理
Future<void> processCompleteText(String text) async {
if (text.trim().isEmpty) {
print("文本为空跳过TTS处理");
Logger.i("文本为空跳过TTS处理");
return;
}
print("开始处理完整文本TTS原始文本长度: ${text.length}");
Logger.i("开始处理完整文本TTS原始文本长度: ${text.length}");
// 清理缓存和重置队列
clearAll();
@@ -260,23 +261,23 @@ class LocalTtsService {
Future<void> _processCompleteTextAsStream(String text) async {
// 清理markdown和异常字符
String cleanedText = _cleanTextForTts(text);
print("清理后文本长度: ${cleanedText.length}");
Logger.i("清理后文本长度: ${cleanedText.length}");
if (cleanedText.trim().isEmpty) {
print("清理后文本为空跳过TTS");
Logger.i("清理后文本为空跳过TTS");
return;
}
// 分割成句子并逐个处理
List<String> sentences = _splitTextIntoSentences(cleanedText);
print("=== 文本分段完成,共 ${sentences.length} 个句子 ===");
Logger.i("=== 文本分段完成,共 ${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");
Logger.i("处理句子 ${i + 1}/${sentences.length}: $sentence");
pushTextForStreamTTS(sentence);
}
@@ -287,19 +288,19 @@ class LocalTtsService {
// 停止当前播放的辅助方法
Future<void> _stopCurrentPlayback() async {
try {
print("停止当前播放");
Logger.i("停止当前播放");
if (_isPlaying) {
await _flutterTts.stop();
_isPlaying = false;
print("TTS.stop() 调用完成");
Logger.i("TTS.stop() 调用完成");
}
// 短暂延迟确保播放器状态稳定
await Future.delayed(Duration(milliseconds: 300));
print("播放器状态稳定延迟完成");
Logger.i("播放器状态稳定延迟完成");
} catch (e) {
print('停止当前播放错误: $e');
Logger.i('停止当前播放错误: $e');
}
}
@@ -456,7 +457,7 @@ class LocalTtsService {
await processCompleteText(text);
return true;
} catch (e) {
print('TTS 播放错误: $e');
Logger.i('TTS 播放错误: $e');
_onErrorHandler?.call('TTS 播放错误: $e');
return false;
}
@@ -468,7 +469,7 @@ class LocalTtsService {
await _stopCurrentPlayback();
clearAll();
} catch (e) {
print('停止音频播放错误: $e');
Logger.i('停止音频播放错误: $e');
}
}
@@ -478,7 +479,7 @@ class LocalTtsService {
await _flutterTts.pause();
}
} catch (e) {
print('暂停音频播放错误: $e');
Logger.i('暂停音频播放错误: $e');
}
}
@@ -486,9 +487,9 @@ class LocalTtsService {
try {
// flutter_tts没有resume方法需要重新播放当前文本
// 这里可以根据需要实现
print('TTS不支持恢复需要重新播放');
Logger.i('TTS不支持恢复需要重新播放');
} catch (e) {
print('恢复音频播放错误: $e');
Logger.i('恢复音频播放错误: $e');
}
}
@@ -536,7 +537,7 @@ class LocalTtsService {
dynamic engines = await _flutterTts.getEngines;
return engines ?? [];
} catch (e) {
print('获取TTS引擎列表失败: $e');
Logger.i('获取TTS引擎列表失败: $e');
return [];
}
}
@@ -545,10 +546,10 @@ class LocalTtsService {
Future<bool> setEngine(String engineName) async {
try {
int result = await _flutterTts.setEngine(engineName);
print('设置TTS引擎: $engineName, 结果: $result');
Logger.i('设置TTS引擎: $engineName, 结果: $result');
return result == 1; // 1表示成功
} catch (e) {
print('设置TTS引擎失败: $e');
Logger.i('设置TTS引擎失败: $e');
return false;
}
}
@@ -559,7 +560,7 @@ class LocalTtsService {
dynamic engine = await _flutterTts.getDefaultEngine;
return engine?.toString();
} catch (e) {
print('获取当前TTS引擎失败: $e');
Logger.i('获取当前TTS引擎失败: $e');
return null;
}
}
@@ -570,7 +571,7 @@ class LocalTtsService {
dynamic languages = await _flutterTts.getLanguages;
return languages ?? [];
} catch (e) {
print('获取支持的语言列表失败: $e');
Logger.i('获取支持的语言列表失败: $e');
return [];
}
}
@@ -581,7 +582,7 @@ class LocalTtsService {
dynamic result = await _flutterTts.isLanguageAvailable(language);
return result == true;
} catch (e) {
print('检查语言可用性失败: $e');
Logger.i('检查语言可用性失败: $e');
return false;
}
}

View File

@@ -1,14 +1,16 @@
import 'package:ai_chat_core/ai_chat_core.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:http_parser/http_parser.dart';
@Deprecated('VoiceRecognitionService is deprecated, please use the new implementation if available.')
class VoiceRecognitionService {
Future<String?> recognizeSpeech(List<int> audioBytes,
{String lang = 'cn'}) async {
try {
print('准备发送OPUS音频数据大小: ${audioBytes.length} 字节');
Logger.i('准备发送OPUS音频数据大小: ${audioBytes.length} 字节');
final uri = Uri.parse('http://143.64.185.20:18606/voice');
print('发送到: $uri');
Logger.i('发送到: $uri');
final request = http.MultipartRequest('POST', uri);
request.files.add(
@@ -21,26 +23,26 @@ class VoiceRecognitionService {
);
request.fields['lang'] = lang; // 根据参数设置语言
print('发送请求...');
Logger.i('发送请求...');
final streamResponse = await request.send();
print('收到响应,状态码: ${streamResponse.statusCode}');
Logger.i('收到响应,状态码: ${streamResponse.statusCode}');
final response = await http.Response.fromStream(streamResponse);
if (response.statusCode == 200) {
print('响应内容: ${response.body}');
Logger.i('响应内容: ${response.body}');
final text = _parseTextFromJson(response.body);
if (text != null) {
print('解析出文本: $text');
Logger.i('解析出文本: $text');
return text; // 返回识别的文本
} else {
print('解析文本失败');
Logger.i('解析文本失败');
}
} else {
print('请求失败,状态码: ${response.statusCode},响应: ${response.body}');
Logger.i('请求失败,状态码: ${response.statusCode},响应: ${response.body}');
}
} catch (e) {
print('发送录音到服务器时出错: $e');
Logger.i('发送录音到服务器时出错: $e');
}
return null;
}
@@ -54,7 +56,7 @@ class VoiceRecognitionService {
}
return null;
} catch (e) {
print('JSON解析错误: $e, 原始数据: $body');
Logger.i('JSON解析错误: $e, 原始数据: $body');
return null;
}
}

View File

@@ -1,4 +1,4 @@
import 'package:basic_intl/intl.dart';
import 'package:t_basic_intl/intl.dart';
import 'package:flutter/material.dart';
class AppTheme {

View File

@@ -13,8 +13,11 @@ class ChatWindowContent extends StatefulWidget {
final double? maxHeight;
final double? chatWidth;
final VoidCallback? onCloseWindow;
const ChatWindowContent({
super.key,
this.onCloseWindow,
this.animationController,
this.minHeight,
this.maxHeight,
@@ -55,6 +58,9 @@ class _ChatWindowContentState extends State<ChatWindowContent> {
}
void _openFullScreen() async {
if (widget.onCloseWindow != null) {
widget.onCloseWindow!();
}
final messageService = context.read<MessageService>();
Navigator.of(context).push(

View File

@@ -1,5 +1,5 @@
import 'package:ai_chat_core/ai_chat_core.dart';
import 'package:flutter/material.dart';
import '../models/chat_message.dart';
import 'chat_bubble.dart';
class ChatBox extends StatelessWidget {

View File

@@ -1,10 +1,9 @@
import 'package:ai_chat_assistant/utils/common_util.dart';
import 'package:ai_chat_assistant/widgets/rotating_image.dart';
import 'package:basic_intl/intl.dart';
import 'package:ai_chat_core/ai_chat_core.dart';
import 'package:t_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:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:provider/provider.dart';
import '../services/message_service.dart';
import 'package:flutter/services.dart';
@@ -183,16 +182,24 @@ class _ChatBubbleState extends State<ChatBubble> {
return MarkdownBody(
data: message.text,
styleSheet: _markdownStyleSheet,
sizedImageBuilder: (config) {
imageBuilder: (uri, title, alt) {
return Image.network(
config.uri.toString(),
width: config.width,
height: config.height,
uri.toString(),
errorBuilder: (context, error, stackTrace) {
return const SizedBox.shrink();
},
);
},
// 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
},

View File

@@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
import '../services/message_service.dart';
import '../pages/full_screen.dart';
import 'floating_icon_with_wave.dart';
import 'package:basic_intl/intl.dart';
import 'package:t_basic_intl/intl.dart';
import '../utils/assets_util.dart';
class ChatFloatingIcon extends StatefulWidget {

View File

@@ -1,4 +1,4 @@
import 'package:basic_intl/intl.dart';
import 'package:t_basic_intl/intl.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';

View File

@@ -1,4 +1,4 @@
import 'package:basic_intl/intl.dart';
import 'package:t_basic_intl/intl.dart';
import 'package:flutter/material.dart';
import 'assistant_avatar.dart';

View File

@@ -1,5 +1,8 @@
import 'dart:async';
import 'dart:ui';
import 'package:ai_chat_assistant/manager.dart';
import 'package:flutter/material.dart';
import 'package:flutter_vosk_wakeword/flutter_vosk_wakeword.dart';
import 'package:provider/provider.dart';
import '../pages/full_screen.dart';
import '../services/message_service.dart';
@@ -30,6 +33,7 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
final double _iconSizeDefault = 80.0;
bool _isShowingPopup = false;
late AnimationController _partScreenAnimationController;
StreamSubscription? _wakeWordSubscription;
@override
void initState() {
@@ -38,16 +42,28 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
duration: const Duration(milliseconds: 250),
vsync: this,
);
// 订阅唤醒词事件
_wakeWordSubscription = AIChatAssistantManager.instance.onWakeWordDetected.listen((_) async {
await FlutterVoskWakeword.instance.stop();
// 在这里处理唤醒事件,例如显示聊天窗口
debugPrint("唤醒词被检测到执行UI操作");
final messageService = MessageService.instance;
_insertOverlay();
await messageService.startVoiceInput();
});
}
@override
void dispose() {
_partScreenAnimationController.dispose();
_wakeWordSubscription?.cancel();
super.dispose();
}
void _insertOverlay() {
Future<void> _insertOverlay() async {
if (_isShowingPopup) return;
await FlutterVoskWakeword.instance.stop(); // 停止唤醒词监听,避免误触发
setState(() {
_isShowingPopup = true;
});
@@ -56,6 +72,7 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
void _removeOverlay() {
if (!_isShowingPopup) return;
FlutterVoskWakeword.instance.start(); // 重新开始监听唤醒词
_partScreenAnimationController.reverse().then((_) {
if (mounted) {
setState(() {
@@ -65,6 +82,20 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
});
}
_openFullScreenPage() async {
final messageService = MessageService.instance;
_removeOverlay();
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider.value(
value: messageService, // 传递同一个单例实例
child: const FullScreenPage(),
),
),
);
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
@@ -96,9 +127,10 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
messageService.abortReply();
messageService.initializeEmpty();
},
child: SizedBox.expand()
),
);
child: SizedBox.expand(
child: Container(
color: Colors.black45.withValues(alpha: 0.1),
))));
}
Widget _buildPopupContent(BoxConstraints constraints) {
@@ -112,6 +144,7 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
right: position.right,
bottom: position.bottom,
child: ChatWindowContent(
onCloseWindow: _removeOverlay,
animationController: _partScreenAnimationController,
minHeight: minHeight,
maxHeight: maxHeight,
@@ -131,20 +164,9 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
// 先执行外部传入的 onTap如果有
if (widget.child!.onTap != null) {
widget.child!.onTap!();
} else {
// 默认行为:关闭弹窗并打开全屏
final messageService = context.read<MessageService>();
_removeOverlay();
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider.value(
value: messageService,
child: const FullScreenPage(),
),
),
);
}
// 默认行为:关闭弹窗并打开全屏
await _openFullScreenPage();
},
onLongPress: () async {
debugPrint('⏳ FloatingIcon onLongPress triggered! (using child properties)');
@@ -195,17 +217,7 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
icon: widget.icon,
onTap: () async {
debugPrint('🖱️ FloatingIcon onTap triggered!');
final messageService = context.read<MessageService>();
_removeOverlay();
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider.value(
value: messageService, // 传递同一个单例实例
child: const FullScreenPage(),
),
),
);
await _openFullScreenPage();
},
onLongPress: () async {
debugPrint('⏳ FloatingIcon onLongPress triggered!');

View File

@@ -5,7 +5,7 @@ import '../pages/full_screen.dart';
import '../screens/part_screen.dart';
import 'floating_icon_with_wave.dart';
import 'dart:async';
import 'package:basic_intl/intl.dart';
import 'package:t_basic_intl/intl.dart';
import '../utils/assets_util.dart';
class FloatingIcon extends StatefulWidget {

View File

@@ -0,0 +1,94 @@
/*
建议目录结构:
```
packages/
ai_chat_core/
lib/
ai_chat_core.dart # 对外统一入口
src/ # 代码目录
models/ # models
message.dart # 消息和消息块
message_chunk.dart
vehicle_command.dart # 车控
vehicle_command_response.dart
command_result.dart
enums/ # enums
message_type.dart
message_status.dart
command_type.dart
service_state.dart # 服务状态
input/
audio_recorder_service.dart # 录音以及保存文件
voice_recognition_service.dart # 目前项目未使用
wake_word_service.dart # 预留(可选)
nlp/ # 自然语言处理相关
text_classification_service.dart
vehicle_command_service.dart
dialog/ #对话相关的
chat_channel/
chat_sse_service.dart # SSE
chat_ws_service.dart # 预留 WebSocket可选
chat_http_service.dart # 批量 HTTP 方案(可选)
message_service.dart # 核心编排/状态更新
tts/ # TTS
tts_service.dart
tts_task_queue.dart
command/ # 车控相关
command_service.dart # Command Register Callback已更新为抽象类的方式
vehicle_command_handler.dart # 自定义实现车控命令
persistence/ # 持久化
persistence_service.dart # 提供持久化服务
dao/ # dao
message_dao.dart # 消息体的 dao 对象
infra/ # infra 的服务
config_service.dart
redis_service.dart
location_service.dart
vehicle_state_service.dart # 预留
logger.dart
error_mapper.dart
utils/ # 工具类
markdown_cleaner.dart
id_generator.dart
throttle.dart
```
对外暴露ai_chat_core.dart仅导出
- models / enums
- MessageService主入口
- VehicleCommandHandler 抽象
- 配置初始化initCore / setConfig
*/
// ignore: unnecessary_library_name
library ai_chat_core;
// enums
export 'src/enums/message_status.dart';
export 'src/enums/message_service_state.dart';
export 'src/enums/vehicle_command_type.dart';
// models
export 'src/models/chat_message.dart';
export 'src/models/vehicle_status_info.dart';
export 'src/models/vehicle_cmd.dart';
export 'src/models/vehicle_cmd_response.dart';
// extensions
export 'src/extensions/string_extension.dart';
export 'src/extensions/color_extension.dart';
// utils
export 'src/utils/markdown_cleaner.dart';
export 'src/utils/logger.dart';
// services
export 'src/services/chat_sse_service.dart';
export 'src/services/tts_service.dart';
export 'src/nlp/text_classification_service.dart';
// http client
export 'src/network/api_config.dart';
export 'src/network/http_client.dart';

View File

@@ -0,0 +1,11 @@
// assistant_api.dart
class AssistantAPI {
static const String baseURL = 'http://143.64.185.20:18606';
// chat
static const String chatEndpoint = '/chat';
// text classification
static const String classifyEndpoint = '/classify';
// tts
static const String ttsEndpoint = '/tts';
}

View File

@@ -0,0 +1,11 @@
/// 消息服务状态
enum MessageServiceState {
// 空闲
idle,
// 录音中
recording,
// 识别中
recognizing,
// 回复中
replying,
}

View File

@@ -1,3 +1,4 @@
// Message状态枚举
enum MessageStatus {
normal('普通消息', 'Normal'),
listening('聆听中', 'Listening'),

View File

@@ -1,25 +1,48 @@
//
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);

View File

@@ -0,0 +1,8 @@
import 'dart:ui';
import 'package:flutter/material.dart';
extension ColorExtension on Color {
static const Color commonColor = Color(0xFF6F72F1);
}

View File

@@ -0,0 +1,55 @@
extension StringExtension on String {
bool get isChinese {
final chineseRegex = RegExp(r'^[\u4e00-\u9fa5]+$');
return chineseRegex.hasMatch(this);
}
bool get isEmail {
final emailRegex = RegExp(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');
return emailRegex.hasMatch(this);
}
bool get isPhoneNumber {
final phoneRegex = RegExp(r'^\+?[0-9]{7,15}$');
return phoneRegex.hasMatch(this);
}
bool get isValidUrl {
final urlRegex = RegExp(
r'^(https?:\/\/)?' // 协议
r'(([a-zA-Z0-9_-]+\.)+[a-zA-Z]{2,6})' // 域名
r'(\/[a-zA-Z0-9._%+-]*)*\/?' // 路径
r'(\?[a-zA-Z0-9=&%+-]*)?' // 查询参数
r'(#[a-zA-Z0-9_-]*)?$'); // 锚点
return urlRegex.hasMatch(this);
}
String capitalize() {
if (isEmpty) return this;
return this[0].toUpperCase() + substring(1);
}
String toSnakeCase() {
return replaceAllMapped(
RegExp(r'[A-Z]'), (Match match) => '_${match.group(0)!.toLowerCase()}')
.replaceFirst('_', '');
}
String toKebabCase() {
return replaceAllMapped(
RegExp(r'[A-Z]'), (Match match) => '-${match.group(0)!.toLowerCase()}')
.replaceFirst('-', '');
}
String toTitleCase() {
return split(' ')
.map((word) =>
word.isEmpty ? word : word[0].toUpperCase() + word.substring(1).toLowerCase())
.join(' ');
}
String reverse() {
return split('').reversed.join();
}
}

View File

@@ -1,4 +1,5 @@
import '../enums/vehicle_command_type.dart';
import '../utils/logger.dart';
///
class VehicleCommand {
@@ -22,8 +23,6 @@ class VehicleCommand {
orElse: () => VehicleCommandType.unknown,
);
} catch (e) {
print('Error parsing command string: $e');
//
type = VehicleCommandType.unknown;
}

View File

@@ -1,4 +1,4 @@
import 'package:ai_chat_assistant/models/vehicle_cmd.dart';
import 'vehicle_cmd.dart';
class VehicleCommandResponse {
final String? tips;

View File

@@ -0,0 +1,23 @@
import '../apis/assistant_api.dart';
// API配置文件
class ApiConfig {
// API基础URL
static const String baseURL = AssistantAPI.baseURL;
/// 请求超时时间
static const Duration timeout = Duration(seconds: 30);
/// 默认请求头
static const Map<String, String> defaultHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
/// 重试次数
static const int maxRetries = 2;
/// 每次重试间隔(毫秒)
static const int retryDelayMs = 1000;
// 私有构造函数,防止实例化
ApiConfig._();
}

View File

@@ -0,0 +1,19 @@
import 'package:http/http.dart' as http;
import 'api_config.dart';
import 'interceptor.dart';
class HeaderInterceptor extends Interceptor {
@override
Future<http.StreamedResponse> onRequest(InterceptorChain chain) async {
chain.request.headers.addAll(ApiConfig.defaultHeaders);
return await chain.next(chain.request);
}
@override
Future<http.StreamedResponse> onResponse(http.StreamedResponse response) async {
return response;
}
@override
Future<void> onError(Object error) async {}
}

View File

@@ -0,0 +1,198 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../utils/logger.dart';
import 'api_config.dart';
import 'header_interceptor.dart';
import 'http_exception.dart';
import 'interceptor.dart';
import 'log_interceptor.dart';
class HttpClient extends http.BaseClient {
final http.Client _inner;
final List<Interceptor> _interceptors;
static List<Interceptor> defaultInterceptors = [
HeaderInterceptor(),
];
HttpClient({
http.Client? inner,
List<Interceptor>? interceptors,
}) : _inner = inner ?? http.Client(),
_interceptors = interceptors ?? defaultInterceptors;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
// http.BaseRequest modifiedRequest = request;
// final requestUrl = request.url;
// // 检查 URL 是否为相对路径 (没有 scheme 和 host)
// if (requestUrl.scheme.isEmpty && requestUrl.host.isEmpty) {
// // 使用 ApiConfig.baseURL 来构建完整的 URL
// final fullUri = Uri.parse(ApiConfig.baseURL).resolveUri(requestUrl);
// // 根据原始请求类型,创建一个带有新 URL 的新请求对象
// if (request is http.Request) {
// final newRequest = http.Request(request.method, fullUri)
// ..headers.addAll(request.headers)
// ..bodyBytes = request.bodyBytes
// ..encoding = request.encoding
// ..followRedirects = request.followRedirects
// ..maxRedirects = request.maxRedirects
// ..persistentConnection = request.persistentConnection;
// modifiedRequest = newRequest;
// } else if (request is http.StreamedRequest) {
// // 对于流式请求,我们需要重新创建一个 StreamedRequest
// final newRequest = http.StreamedRequest(request.method, fullUri)
// ..headers.addAll(request.headers)
// ..contentLength = request.contentLength
// ..followRedirects = request.followRedirects
// ..maxRedirects = request.maxRedirects
// ..persistentConnection = request.persistentConnection;
// request.finalize().listen(
// newRequest.sink.add,
// onError: newRequest.sink.addError,
// onDone: newRequest.sink.close,
// );
// modifiedRequest = newRequest;
// } else if (request is http.MultipartRequest) {
// // 对于 MultipartRequest我们也需要重新创建一个实例
// final newRequest = http.MultipartRequest(request.method, fullUri)
// ..headers.addAll(request.headers)
// ..fields.addAll(request.fields)
// ..files.addAll(request.files)
// ..followRedirects = request.followRedirects
// ..maxRedirects = request.maxRedirects
// ..persistentConnection = request.persistentConnection;
// modifiedRequest = newRequest;
// } else {
// // 如果是其他类型的请求,可以根据需要进行处理
// }
// }
int retries = 0;
while (true) {
try {
// 1请求拦截器链
var response = await _executeRequestChain(request);
// 响应拦截器链
response = await _executeResponseChain(response);
// 响应校验
if (response.statusCode >= 200 && response.statusCode < 300) {
return response;
} else {
// 服务器返回错误码,包装成 HttpException 并抛出
// 为了获取 body我们需要读取流
final bodyBytes = await response.stream.toBytes();
final bodyString = utf8.decode(bodyBytes, allowMalformed: true);
final data = bodyString.isNotEmpty ? json.decode(bodyString) : null;
throw HttpException(
code: response.statusCode,
message: 'Request failed with status: ${response.statusCode}',
data: data,
);
}
} catch (e) {
// 错误拦截器链
await _executeErrorChain(e);
// 如果是可重试的错误 (例如网络问题),并且未达到最大次数
if (e is SocketException && retries < ApiConfig.maxRetries) {
retries++;
Logger.w('⚠️ Network error, retrying... ($retries/${ApiConfig.maxRetries})');
await Future.delayed(const Duration(milliseconds: ApiConfig.retryDelayMs));
continue; // 继续下一次循环
}
// 如果是 HttpException (服务器错误) 或其他不可重试的错误,或已达最大重试次数,则统一处理后抛出
throw _handleError(e);
}
}
}
/// 递归执行请求拦截器链
Future<http.StreamedResponse> _executeRequestChain(http.BaseRequest request, [int index = 0]) {
if (index >= _interceptors.length) {
// 拦截器链执行完毕,发出真正的网络请求
return _inner.send(request);
}
// 获取当前拦截器
final interceptor = _interceptors[index];
// ignore: prefer_function_declarations_over_variables
Next nextStep = (req) => _executeRequestChain(req, index + 1);
// 创建 InterceptorChain 实例,传入 request 和“下一步”的逻辑
final chain = InterceptorChain(request, nextStep);
// 调用当前拦截器的 onRequest 方法
return interceptor.onRequest(chain);
}
/// 顺序执行响应拦截器链
Future<http.StreamedResponse> _executeResponseChain(http.StreamedResponse response) async {
var res = response;
for (final interceptor in _interceptors) {
res = await interceptor.onResponse(res);
}
return res;
}
/// 逆序执行错误拦截器链
Future<void> _executeErrorChain(Object error) async {
for (final interceptor in _interceptors.reversed) {
await interceptor.onError(error);
}
}
/// 它负责将各种原始错误标准化为 HttpException
HttpException _handleError(Object e) {
if (e is HttpException) {
return e; // 如果已经是 HttpException直接返回
} else if (e is SocketException) {
return HttpException(message: '网络连接错误,请检查网络设置');
} else if (e is TimeoutException) {
return HttpException(message: '请求超时,请稍后重试');
} else {
return HttpException(message: '发生未知错误: $e');
}
}
/// 串行请求
Future<List<dynamic>> runSequential(List<Future<dynamic> Function()> tasks) async {
final results = <dynamic>[];
for (final task in tasks) {
try {
final res = await task();
results.add(res);
} catch (e) {
results.add(e);
}
}
return results;
}
/// 并行请求
Future<List<dynamic>> runParallel(List<Future<dynamic>> tasks) async {
final results = <dynamic>[];
final completer = Completer<List<dynamic>>();
int completed = 0;
for (final task in tasks) {
task.then((result) {
results.add(result);
}).catchError((error) {
results.add(error);
}).whenComplete(() {
completed++;
if (completed == tasks.length) {
completer.complete(results);
}
});
}
return completer.future;
}
}

View File

@@ -0,0 +1,17 @@
class HttpException implements Exception {
final int? code;
final String message;
final dynamic data;
HttpException({this.code, required this.message, this.data});
@override
String toString() {
if (code != null) {
return 'HttpException: $message (code: $code)';
}
return 'HttpException: $message';
}
}

View File

@@ -0,0 +1,32 @@
import 'package:http/http.dart' as http;
typedef Next = Future<http.StreamedResponse> Function(http.BaseRequest request);
/// 拦截器处理器,用于将请求/响应/错误传递给链中的下一个环节
class InterceptorChain {
final http.BaseRequest request;
final Next _next;
// 构造函数
InterceptorChain(this.request, this._next);
/// 调用链中的下一个环节
Future<http.StreamedResponse> next(http.BaseRequest req) {
// 调用传入的 _next 函数
return _next(req);
}
}
/// 拦截器抽象基类
abstract class Interceptor {
/// 请求被发送前调用
/// 你可以在这里修改请求,例如添加 headers然后必须调用 `chain.next(request)`
Future<http.StreamedResponse> onRequest(InterceptorChain chain);
/// 收到响应后调用
/// 你可以在这里读取和转换响应,然后返回一个新的响应
Future<http.StreamedResponse> onResponse(http.StreamedResponse response);
/// 发生错误时调用
/// 你可以在这里处理错误,例如重试,或者转换错误类型
Future<void> onError(Object error);
}

View File

@@ -0,0 +1,45 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../utils/logger.dart';
import 'interceptor.dart';
class LogInterceptor extends Interceptor {
@override
Future<http.StreamedResponse> onRequest(InterceptorChain chain) async {
final request = chain.request;
Logger.i('➡️ [${request.method}] ${request.url}');
Logger.i('Headers: ${request.headers}');
return await chain.next(request);
}
@override
Future<http.StreamedResponse> onResponse(http.StreamedResponse response) async {
Logger.i('⬅️ [${response.statusCode}] ${response.request?.url}');
final streamTransformer = StreamTransformer<List<int>, List<int>>.fromHandlers(
handleData: (data, sink) {
// 打印流经的数据
Logger.i('📦 SSE Chunk: ${utf8.decode(data, allowMalformed: true)}');
// 将数据原封不动地传递给下一个监听者
sink.add(data);
},
);
return http.StreamedResponse(
response.stream.transform(streamTransformer), // 应用转换器
response.statusCode,
headers: response.headers,
request: response.request,
reasonPhrase: response.reasonPhrase,
contentLength: response.contentLength,
isRedirect: response.isRedirect,
persistentConnection: response.persistentConnection,
);
}
@override
Future<void> onError(Object error) async {
Logger.e('http Error: $error');
}
}

View File

@@ -0,0 +1,37 @@
import 'dart:convert';
import '../utils/logger.dart';
import '../network/http_client.dart';
import '../network/api_config.dart';
/// Text Classification Service
class TextClassificationService {
Future<int> classifyText(String text) async {
try {
final uri = Uri.parse('${ApiConfig.baseURL}/classify');
HttpClient client = HttpClient();
final response = await client.post(
uri,
body: json.encode({'text': text}),
);
if (response.statusCode == 200) {
var map = json.decode(response.body);
if (map != null && map['class'] != null) {
return map['category'] ?? -1;
} else {
Logger.i('Invalid response format: ${response.body}');
return -1;
}
} else {
Logger.i('Classification failed: ${response.statusCode}, ${response.body}');
return -1;
}
} catch (e) {
Logger.e('Error during text classification: $e');
return -1;
}
}
}

View File

@@ -0,0 +1,14 @@
class MessageDAO {
// 保存消息
Future<void> saveMessage(String message) async {
}
// 获取消息
Future<List<String>> getMessages() async {
return [];
}
// 删除消息
Future<void> deleteMessage(String messageId) async {
}
}

View File

@@ -0,0 +1,235 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import '../apis/assistant_api.dart';
import '../network/api_config.dart';
import '../network/http_client.dart';
import 'tts_service.dart';
import '../utils/markdown_cleaner.dart';
import '../utils/logger.dart';
class ChatSseService {
// 依赖注入 TtsService
final TtsService _ttsService;
// 缓存用户ID和会话ID
String? _cachedUserId;
String? _cachedConversationId;
// 使用我们封装的 HttpClient
HttpClient? _currentClient;
http.StreamedResponse? _currentResponse;
StreamSubscription? _streamSubscription;
bool _isAborted = true;
bool _isTtsStarted = false;
String? get conversationId => _cachedConversationId;
Future<void>? _startTtsFuture;
// 构造函数,接收 TtsService 实例
ChatSseService(this._ttsService);
void request({
required String messageId,
required String text,
required bool isChinese,
required Function(String, String, bool) onStreamResponse,
}) async {
Logger.i("----------------------SSE Start");
_isAborted = false;
if (_cachedUserId == null) {
_cachedUserId = _generateRandomUserId(6);
Logger.i('初始化用户ID: $_cachedUserId');
}
String responseText = '';
StringBuffer buffer = StringBuffer();
final enEnders = RegExp(r'[.!?]');
final zhEnders = RegExp(r'[。!?]');
// 【逻辑保留】processBuffer 的逻辑与你原来完全一致
Future<void> processBuffer(bool isChinese) async {
String txt = buffer.toString();
// 先过滤 markdown 图片语法
int imgStart = txt.indexOf('![');
while (imgStart != -1) {
int imgEnd = txt.indexOf(')', imgStart);
if (imgEnd == -1) {
// 图片语法未闭合,缓存剩余部分
buffer.clear();
buffer.write(txt.substring(imgStart));
return;
}
// 移除图片语法
txt = txt.substring(0, imgStart) + txt.substring(imgEnd + 1);
imgStart = txt.indexOf('![');
}
// 彻底移除 markdown 有序/无序列表序号(如 1.、2.、-、*、+
txt = txt.replaceAll(RegExp(r'(^|\n)[ \t]*[0-9]+\.[ \t]*'), '\n');
txt = txt.replaceAll(RegExp(r'(^|\n)[ \t]*[-\*\+][ \t]+'), '\n');
// 分句符
RegExp enders = isChinese ? zhEnders : enEnders;
int lastEnd = 0;
// 本地缓存句子
List<String> sentences = [];
for (final match in enders.allMatches(txt)) {
int endIndex = match.end;
String sentence = txt.substring(lastEnd, endIndex).trim();
if (sentence.isNotEmpty) {
sentences.add(sentence);
}
lastEnd = endIndex;
}
// 只在达到完整句子时调用 TtsUtil.send
for (final s in sentences) {
// 使用 MarkdownCleaner 替代 CommonUtil
String ttsStr = MarkdownCleaner.cleanText(s, true);
Logger.i("发送数据到TTS: $ttsStr");
_ttsService.send(ttsStr);
}
// 缓存剩余不完整部分
String remain = txt.substring(lastEnd).trim();
buffer.clear();
if (remain.isNotEmpty) {
buffer.write(remain);
}
}
_currentClient = HttpClient();
try {
final chatUri = Uri.parse('${ApiConfig.baseURL}${AssistantAPI.chatEndpoint}');
final request = http.Request('POST', chatUri)
..headers['Content-Type'] = 'application/json'
..headers['Accept'] = 'text/event-stream';
final body = {'message': text, 'user': _cachedUserId};
if (_cachedConversationId != null) {
body['conversation_id'] = _cachedConversationId;
}
request.body = json.encode(body);
_currentResponse = await _currentClient!.send(request);
if (_currentResponse!.statusCode == 200) {
_startTtsFuture = startTts(isChinese);
_streamSubscription = _currentResponse!.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
if (_isAborted) return;
if (line.startsWith('data:')) {
Logger.i('SSE line: $line');
final jsonStr = line.substring(5).trim();
if (jsonStr == '[DONE]' || jsonStr.contains('message_end')) {
_ttsService.complete();
onStreamResponse(messageId, responseText, true);
return;
}
try {
final jsonData = json.decode(jsonStr);
Logger.i('SSE jsonData: $jsonData');
if (jsonData.containsKey('conversation_id') &&
_cachedConversationId == null) {
_cachedConversationId = jsonData['conversation_id'];
}
if (jsonData['event'].toString().contains('message')) {
final textChunk =
jsonData.containsKey('answer') ? jsonData['answer'] : '';
if (_isAborted) {
return;
}
buffer.write(textChunk);
processBuffer(isChinese); // 注意:这里不再需要 await
responseText += textChunk;
onStreamResponse(messageId, responseText, false);
}
} catch (e) {
Logger.e('解析 SSE 数据出错: $e, 原始数据: $jsonStr');
}
}
},
onDone: () {
Logger.i('SSE stream closed.');
if (!_isAborted) {
// 【已迁移】调用注入的 _ttsService
_ttsService.complete();
}
// onDone 意味着流正常结束,也需要重置资源
resetRequest();
},
onError: (error) {
Logger.e('SSE stream error: $error');
// onError 意味着流异常结束,需要重置资源
resetRequest();
},
cancelOnError: true,
);
} else {
Logger.e('SSE 连接失败,状态码: ${_currentResponse!.statusCode}');
// 【逻辑保留】连接失败也需要重置资源
resetRequest();
}
} catch (e) {
Logger.e('SSE request failed: $e');
// 【逻辑保留】捕获到任何其他异常时,也需要重置资源
resetRequest();
}
}
Future<void> startTts(bool isChinese) async {
Logger.i("----------------------TTS Start");
if (!_isTtsStarted) {
// 【已迁移】调用注入的 _ttsService
if (await _ttsService.start(isChinese) == true) {
_isTtsStarted = true;
}
}
}
Future<void> abort() async {
_isAborted = true;
// 【已迁移】调用注入的 _ttsService
_ttsService.stop();
resetRequest();
}
void resetRequest() {
// 【逻辑保留】重置资源,适配新的 HttpClient 和 StreamSubscription
_streamSubscription?.cancel();
_streamSubscription = null;
// package:http 的 client 只需要调用 close()
_currentClient?.close();
_currentClient = null;
_currentResponse = null;
_isTtsStarted = false;
_startTtsFuture = null;
}
// 【逻辑保留】_getCompleteTextEndIndex 方法保持不变
int _getCompleteTextEndIndex(String buffer) {
// 支持句号、问号、感叹号和换行符作为分割依据
final sentenceEnders = RegExp(r'[,.!?:,。!?:\n]');
final matches = sentenceEnders.allMatches(buffer);
return matches.isEmpty ? 0 : matches.last.end;
}
// 【逻辑保留】resetSession 方法保持不变
void resetSession() {
_cachedUserId = null;
_cachedConversationId = null;
Logger.i('SSE会话已重置');
}
// 【逻辑保留】_generateRandomUserId 方法保持不变
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,21 @@
import 'package:flutter/services.dart';
/// TTS 服务的抽象接口
abstract class TtsService {
final String channelName;
TtsService(this.channelName);
/// 启动TTS
Future<bool> start(bool isChinese);
/// 发送文本到TTS
Future<void> send(String text);
/// 通知TTS所有文本已发送完毕
Future<void> complete();
/// 停止TTS
Future<void> stop();
}

View File

@@ -0,0 +1,8 @@
// 常量定义
class Constants {
// 最大消息长度
static const int maxMessageLength = 1000;
// 默认分页大小
static const int defaultPageSize = 20;
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/foundation.dart';
/// 一个简单的日志记录器,仅在非 Release 模式下打印日志。
class Logger {
// 私有构造函数,防止外部实例化
Logger._();
static String _timestamp() {
return DateTime.now().toIso8601String();
}
/// 记录调试信息 (Debug)
static void d(String message, {String? tag}) {
if (!kReleaseMode) {
debugPrint('${_timestamp()} [${tag ?? 'DEBUG'}] $message');
}
}
/// 记录一般信息 (Info)
static void i(String message, {String? tag}) {
if (!kReleaseMode) {
debugPrint('${_timestamp()} [${tag ?? 'INFO'}] $message');
}
}
/// 记录警告信息 (Warning)
static void w(String message, {String? tag, Object? error}) {
if (!kReleaseMode) {
debugPrint('${_timestamp()} [${tag ?? 'WARN'}] $message ${error ?? ''}');
}
}
/// 记录错误信息 (Error)
static void e(String message, {String? tag, Object? error, StackTrace? stackTrace}) {
if (!kReleaseMode) {
debugPrint('==================== ERROR ====================');
debugPrint('${_timestamp()} [${tag ?? 'ERROR'}] $message');
if (error != null) {
debugPrint('Error: $error');
}
if (stackTrace != null) {
debugPrint('StackTrace: $stackTrace');
}
debugPrint('=============================================');
}
}
}

View File

@@ -0,0 +1,46 @@
class MarkdownCleaner {
/// 清理文本中的 Markdown 语法
static String cleanText(String text, bool forTts) {
String cleanedText = text
.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), '') // 不完整代码块结束
.replaceAll(RegExp(r'^\s*\|.*\|\s*$', multiLine: true), '') // 表格行
.replaceAll(RegExp(r'^\s*[\|\-\:\+\=\s]+\s*$', multiLine: true), '') // 表格分隔符
.replaceAll(RegExp(r'\|'), ' ') // 清理残留的竖线
.replaceAllMapped(RegExp(r'^#{1,6}\s+(.*)$', multiLine: true), (m) => m.group(1) ?? '')
.replaceAll(RegExp(r'\[([^\]]+)\]\([^\)]+\)'), '') // 完整超链接
.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), '')
.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) {
if (cleanedText.contains("ID.UNYX")) {
cleanedText = cleanedText.replaceAllMapped(
RegExp(r'ID\.UNYX', caseSensitive: false), (m) => 'ID Unix');
} else {
cleanedText = cleanedText.replaceAllMapped(
RegExp(r'UNYX', caseSensitive: false), (m) => 'Unix');
}
cleanedText = cleanedText.replaceAllMapped(
RegExp(r'400-023-4567', caseSensitive: false), (m) => '4 0 0 - 0 2 3 - 4 5 6 7');
}
return cleanedText;
}
}

View File

@@ -0,0 +1,237 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.11.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.1"
characters:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
clock:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
collection:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.0"
crypto:
dependency: transitive
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.6"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.0.7"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.8"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.1"
matcher:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.15.0"
path:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.12.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.3"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
url: "https://pub.flutter-io.cn"
source: hosted
version: "14.3.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.5.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

View File

@@ -0,0 +1,18 @@
name: ai_chat_core
description: Core functionalities for the AI Chat Assistant package.
version: 0.1.0
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
http: ^1.5.0
uuid: ^3.0.5
dev_dependencies:
flutter_test:
sdk: flutter

View File

@@ -1,4 +1,4 @@
name: basic_intl
name: t_basic_intl
description: Basic internationalization utilities for ai_chat_assistant
version: 0.2.0
@@ -12,5 +12,3 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
flutter:

View File

@@ -0,0 +1,33 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
build/

View File

@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863"
channel: "stable"
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
- platform: android
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
- platform: ios
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.

View File

@@ -0,0 +1 @@
TODO: Add your license here.

View File

@@ -0,0 +1,15 @@
# flutter_vosk_wakeword
A new Flutter plugin project.
## Getting Started
This project is a starting point for a Flutter
[plug-in package](https://flutter.dev/to/develop-plugins),
a specialized package that includes platform-specific implementation code for
Android and/or iOS.
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx

View File

@@ -0,0 +1,75 @@
group = "com.tsystems.flutter_vosk_wakeword"
version = "1.0-SNAPSHOT"
buildscript {
ext.kotlin_version = "1.8.22"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.1.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
android {
namespace = "com.tsystems.flutter_vosk_wakeword"
compileSdk = 35
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11
}
sourceSets {
main.java.srcDirs += "src/main/kotlin"
test.java.srcDirs += "src/test/kotlin"
main {
assets.srcDirs += ["models/src/main/assets"]
}
}
defaultConfig {
minSdk = 21
}
dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")
}
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
outputs.upToDateWhen {false}
showStandardStreams = true
}
}
}
}
dependencies {
implementation 'com.alphacephei:vosk-android:0.3.70'
// implementation project(":models")
}

View File

@@ -0,0 +1,32 @@
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
android {
// 为这个模块设置一个独立的命名空间
namespace "com.tsystems.models"
compileSdk = 35
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11
}
defaultConfig {
minSdk = 21
}
sourceSets {
// 这是最关键的一步告诉Gradle这个模块的assets资源在哪里
// 我们直接指向模块的根目录Gradle会自动寻找 src/main/assets
main.assets.srcDirs = ['src/main/assets']
}
// 由于这个模块只包含资源我们可以禁用一些不需要的Java/Kotlin编译任务
libraryVariants.all { variant ->
variant.generateBuildConfigProvider.get().enabled = false
}
}

View File

@@ -0,0 +1,6 @@
Chinese Vosk model for mobile
CER results
23.54% speechio_02
38.29% speechio_06

View File

@@ -0,0 +1,8 @@
--use-energy=false
--sample-frequency=16000
--num-mel-bins=40
--num-ceps=40
--low-freq=40
--high-freq=-200
--allow-upsample=true
--allow-downsample=true

View File

@@ -0,0 +1,10 @@
--min-active=200
--max-active=5000
--beam=12.0
--lattice-beam=4.0
--acoustic-scale=1.0
--frame-subsampling-factor=3
--endpoint.silence-phones=1:2:3:4:5:6:7:8:9:10
--endpoint.rule2.min-trailing-silence=0.5
--endpoint.rule3.min-trailing-silence=1.0
--endpoint.rule4.min-trailing-silence=2.0

View File

@@ -0,0 +1,39 @@
11845
11846
11847
11848
11849
11850
11851
11852
11853
11854
11855
11856
11857
11858
11859
11860
11861
11862
11863
11864
11865
11866
11867
11868
11869
11870
11871
11872
11873
11874
11875
11876
11877
11878
11879
11880
11881
11882
11883

View File

@@ -0,0 +1,646 @@
1 nonword
2 begin
3 end
4 internal
5 singleton
6 nonword
7 begin
8 end
9 internal
10 singleton
11 begin
12 end
13 internal
14 singleton
15 begin
16 end
17 internal
18 singleton
19 begin
20 end
21 internal
22 singleton
23 begin
24 end
25 internal
26 singleton
27 begin
28 end
29 internal
30 singleton
31 begin
32 end
33 internal
34 singleton
35 begin
36 end
37 internal
38 singleton
39 begin
40 end
41 internal
42 singleton
43 begin
44 end
45 internal
46 singleton
47 begin
48 end
49 internal
50 singleton
51 begin
52 end
53 internal
54 singleton
55 begin
56 end
57 internal
58 singleton
59 begin
60 end
61 internal
62 singleton
63 begin
64 end
65 internal
66 singleton
67 begin
68 end
69 internal
70 singleton
71 begin
72 end
73 internal
74 singleton
75 begin
76 end
77 internal
78 singleton
79 begin
80 end
81 internal
82 singleton
83 begin
84 end
85 internal
86 singleton
87 begin
88 end
89 internal
90 singleton
91 begin
92 end
93 internal
94 singleton
95 begin
96 end
97 internal
98 singleton
99 begin
100 end
101 internal
102 singleton
103 begin
104 end
105 internal
106 singleton
107 begin
108 end
109 internal
110 singleton
111 begin
112 end
113 internal
114 singleton
115 begin
116 end
117 internal
118 singleton
119 begin
120 end
121 internal
122 singleton
123 begin
124 end
125 internal
126 singleton
127 begin
128 end
129 internal
130 singleton
131 begin
132 end
133 internal
134 singleton
135 begin
136 end
137 internal
138 singleton
139 begin
140 end
141 internal
142 singleton
143 begin
144 end
145 internal
146 singleton
147 begin
148 end
149 internal
150 singleton
151 begin
152 end
153 internal
154 singleton
155 begin
156 end
157 internal
158 singleton
159 begin
160 end
161 internal
162 singleton
163 begin
164 end
165 internal
166 singleton
167 begin
168 end
169 internal
170 singleton
171 begin
172 end
173 internal
174 singleton
175 begin
176 end
177 internal
178 singleton
179 begin
180 end
181 internal
182 singleton
183 begin
184 end
185 internal
186 singleton
187 begin
188 end
189 internal
190 singleton
191 begin
192 end
193 internal
194 singleton
195 begin
196 end
197 internal
198 singleton
199 begin
200 end
201 internal
202 singleton
203 begin
204 end
205 internal
206 singleton
207 begin
208 end
209 internal
210 singleton
211 begin
212 end
213 internal
214 singleton
215 begin
216 end
217 internal
218 singleton
219 begin
220 end
221 internal
222 singleton
223 begin
224 end
225 internal
226 singleton
227 begin
228 end
229 internal
230 singleton
231 begin
232 end
233 internal
234 singleton
235 begin
236 end
237 internal
238 singleton
239 begin
240 end
241 internal
242 singleton
243 begin
244 end
245 internal
246 singleton
247 begin
248 end
249 internal
250 singleton
251 begin
252 end
253 internal
254 singleton
255 begin
256 end
257 internal
258 singleton
259 begin
260 end
261 internal
262 singleton
263 begin
264 end
265 internal
266 singleton
267 begin
268 end
269 internal
270 singleton
271 begin
272 end
273 internal
274 singleton
275 begin
276 end
277 internal
278 singleton
279 begin
280 end
281 internal
282 singleton
283 begin
284 end
285 internal
286 singleton
287 begin
288 end
289 internal
290 singleton
291 begin
292 end
293 internal
294 singleton
295 begin
296 end
297 internal
298 singleton
299 begin
300 end
301 internal
302 singleton
303 begin
304 end
305 internal
306 singleton
307 begin
308 end
309 internal
310 singleton
311 begin
312 end
313 internal
314 singleton
315 begin
316 end
317 internal
318 singleton
319 begin
320 end
321 internal
322 singleton
323 begin
324 end
325 internal
326 singleton
327 begin
328 end
329 internal
330 singleton
331 begin
332 end
333 internal
334 singleton
335 begin
336 end
337 internal
338 singleton
339 begin
340 end
341 internal
342 singleton
343 begin
344 end
345 internal
346 singleton
347 begin
348 end
349 internal
350 singleton
351 begin
352 end
353 internal
354 singleton
355 begin
356 end
357 internal
358 singleton
359 begin
360 end
361 internal
362 singleton
363 begin
364 end
365 internal
366 singleton
367 begin
368 end
369 internal
370 singleton
371 begin
372 end
373 internal
374 singleton
375 begin
376 end
377 internal
378 singleton
379 begin
380 end
381 internal
382 singleton
383 begin
384 end
385 internal
386 singleton
387 begin
388 end
389 internal
390 singleton
391 begin
392 end
393 internal
394 singleton
395 begin
396 end
397 internal
398 singleton
399 begin
400 end
401 internal
402 singleton
403 begin
404 end
405 internal
406 singleton
407 begin
408 end
409 internal
410 singleton
411 begin
412 end
413 internal
414 singleton
415 begin
416 end
417 internal
418 singleton
419 begin
420 end
421 internal
422 singleton
423 begin
424 end
425 internal
426 singleton
427 begin
428 end
429 internal
430 singleton
431 begin
432 end
433 internal
434 singleton
435 begin
436 end
437 internal
438 singleton
439 begin
440 end
441 internal
442 singleton
443 begin
444 end
445 internal
446 singleton
447 begin
448 end
449 internal
450 singleton
451 begin
452 end
453 internal
454 singleton
455 begin
456 end
457 internal
458 singleton
459 begin
460 end
461 internal
462 singleton
463 begin
464 end
465 internal
466 singleton
467 begin
468 end
469 internal
470 singleton
471 begin
472 end
473 internal
474 singleton
475 begin
476 end
477 internal
478 singleton
479 begin
480 end
481 internal
482 singleton
483 begin
484 end
485 internal
486 singleton
487 begin
488 end
489 internal
490 singleton
491 begin
492 end
493 internal
494 singleton
495 begin
496 end
497 internal
498 singleton
499 begin
500 end
501 internal
502 singleton
503 begin
504 end
505 internal
506 singleton
507 begin
508 end
509 internal
510 singleton
511 begin
512 end
513 internal
514 singleton
515 begin
516 end
517 internal
518 singleton
519 begin
520 end
521 internal
522 singleton
523 begin
524 end
525 internal
526 singleton
527 begin
528 end
529 internal
530 singleton
531 begin
532 end
533 internal
534 singleton
535 begin
536 end
537 internal
538 singleton
539 begin
540 end
541 internal
542 singleton
543 begin
544 end
545 internal
546 singleton
547 begin
548 end
549 internal
550 singleton
551 begin
552 end
553 internal
554 singleton
555 begin
556 end
557 internal
558 singleton
559 begin
560 end
561 internal
562 singleton
563 begin
564 end
565 internal
566 singleton
567 begin
568 end
569 internal
570 singleton
571 begin
572 end
573 internal
574 singleton
575 begin
576 end
577 internal
578 singleton
579 begin
580 end
581 internal
582 singleton
583 begin
584 end
585 internal
586 singleton
587 begin
588 end
589 internal
590 singleton
591 begin
592 end
593 internal
594 singleton
595 begin
596 end
597 internal
598 singleton
599 begin
600 end
601 internal
602 singleton
603 begin
604 end
605 internal
606 singleton
607 begin
608 end
609 internal
610 singleton
611 begin
612 end
613 internal
614 singleton
615 begin
616 end
617 internal
618 singleton
619 begin
620 end
621 internal
622 singleton
623 begin
624 end
625 internal
626 singleton
627 begin
628 end
629 internal
630 singleton
631 begin
632 end
633 internal
634 singleton
635 begin
636 end
637 internal
638 singleton
639 begin
640 end
641 internal
642 singleton
643 begin
644 end
645 internal
646 singleton

Some files were not shown because too many files have changed in this diff Show More