Compare commits
15 Commits
feature/on
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07980fea87 | ||
|
|
1729d0f266 | ||
|
|
c29a3f665a | ||
|
|
a7ed838c92 | ||
|
|
ba73d6d780 | ||
|
|
5be50b4dbb | ||
|
|
c6765ab29c | ||
|
|
f3ff75c437 | ||
|
|
f5699fd144 | ||
|
|
4a41c25502 | ||
|
|
9662ff6a56 | ||
|
|
2bdb15db1d | ||
|
|
50226b27a1 | ||
|
|
5e8b9af17b | ||
|
|
d3580a6a0b |
13
README.md
@@ -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 国际化包的兼容
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
BIN
assets/porcupine_params_zh.pv
Normal file
BIN
assets/zhongzhong_zh_android.ppn
Normal file
686
docs/AI_Chat_Assistant PRD.md
Normal 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;误触策略:短按抬起不触发录音。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### **3.1.2 浮动聊天窗口(ChatPopup)**
|
||||
|
||||
- **用户场景**
|
||||
|
||||
用户与AI助手进行可视化的文本和语音交互。
|
||||
|
||||
- **功能描述**
|
||||
|
||||
提供弹出式聊天窗口,展示对话记录并提供输入方式,不承担复杂历史翻阅与批量操作
|
||||
|
||||
- **需求说明**
|
||||
|
||||
长按浮动按钮即可出现浮动聊天窗口,长按状态下展示聆听中,放开后则聆听结束,变为思考中状态。
|
||||
|
||||
思考中状态可以手动点击 **停止回答** 可以终止AI对话,直到回答完成,则不可点击停止回答。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### **3.1.3 全屏聊天界面(ChatFullscreen)**
|
||||
|
||||
- **用户场景**
|
||||
|
||||
用户与AI助手进行可视化的文本和语音交互,适用于长对话、多轮引用、查看历史与多类型气泡的交互。
|
||||
|
||||
- **功能描述**
|
||||
|
||||
提供全屏聊天界面,展示对话记录并提供输入方式。全屏的聊天页面功能更加丰富,多类型气泡、快捷指令区、功能按钮区、历史分页加载
|
||||
|
||||
- **需求说明**
|
||||
- 全屏聊天模式下会保留之前浮动聊天窗口的聊天记录
|
||||
- 不同的场景为不同的AI助手,现有两种场景,购车助手和用车助手(根据入口来判断)
|
||||
- 顶部会有可配置的功能按钮区域(用户服务/购车服务)
|
||||
- 功能按钮下方会有快捷对话的区域,可以点击换一换来更换快捷指令,支持“换一换”刷新策略(本地轮换或服务侧下发)
|
||||
- 聊天页面底部会有功能栏,分别为**删除当前对话**,**按住说话**,**切换文本输入**
|
||||
- 如果存在本地对话记录,点击 **显示历史对话记录** 会加载之前的5条对话记录,同样可以通过上拉来加载历史对话记录
|
||||
|
||||

|
||||
|
||||
### 3.2 AI对话场景
|
||||
|
||||
#### **3.2.1 购车助手场景**
|
||||
|
||||
- **用户场景**
|
||||
|
||||
用户未绑定车辆的情况下,爱车页面变为购车页面,购车页面也会有AI助手,未绑定车辆用户获取车型配置、优惠、推荐。
|
||||
|
||||
- **功能描述**
|
||||
|
||||
购车页面的AI助手没有聆听模式,只能点击进入全屏的聊天页面,首次的回复词也会有变化;功能按钮区域和快捷回复区域都会跟用车助手不同
|
||||
|
||||
- **需求说明**
|
||||
- 浮动按钮点击可进入全屏的AI助手页面,顶部的功能按钮为热门车型推荐,同样是icon+text的按钮,点击之后会快捷输入车型的名称,AI助手会回复车型的相关信息,同时快捷对话的区域会按照对话来进行变化;
|
||||
- 购车助手同样可以通过文本的语义来进行推荐车型,以及解读购车的优惠信息;
|
||||
- 如果在购车助手场景下,本身并没有绑定车辆,如果有车控的语音输入,则返回:您还未绑定车辆,无法使用车控指令;
|
||||
- 如果是用车助手场景,包含购车助手的全部功能,同样可以回复相应车型来获取配置信息以及购车政策。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### **3.2.2 用车助手场景**
|
||||
|
||||
- **用户场景**
|
||||
|
||||
用户在绑定车辆的情况下,进入爱车页面,使用AI助手则我用车助手场景
|
||||
|
||||
- **功能描述**
|
||||
|
||||
用车助手场景下的AI助手不同于购车场景,可以按照文本的输入分为三种:普通问答,车控指令,错误问题反馈
|
||||
|
||||
- **需求说明**
|
||||
- 用车场景下的功能按钮为官方推荐,内容包括智能场景推荐、数据埋点和运营位,其中智能场景推荐包括售后维保提醒,智能场景建议;数据埋点包括能耗报告,行为分析,用户习惯操作;运营位则是推荐的车型,用户复购建议;
|
||||
- 常见功能按钮 → 发送一条“功能集合”消息气泡(含多个按钮)。
|
||||
- 点击 更多工具按钮跟常见功能按钮一样,会返回一条聊天记录,里面会包含多个按钮,点击按钮可以进行快捷回复;
|
||||
- 快捷回复区域是可以变化的,会根据对话的最后一个回复进行变化,快捷回复区域会跟在最后一个对话的底部;例如刚进入会跟在 功能按钮的底部,但是新的聊天记录出现后,就会自动拼接到聊天气泡的底部(聊天气泡类型分为多种,后面会详细介绍);
|
||||
|
||||

|
||||
|
||||
#### **3.2.3 用户唤醒场景**
|
||||
|
||||
- **用户场景**
|
||||
|
||||
在开通语音唔醒功能之后,购车页面和爱车页面都可以使用语音唤醒,免手动触发,提高便利性
|
||||
|
||||
- **功能描述**
|
||||
|
||||
用户可以通过语音唤醒功能在不需要点击按钮的情况下激活AI助手,提高使用便捷性和用户体验。唤醒词“你好,众众”,唤醒后直接进入聆听模式并高亮反馈。
|
||||
|
||||
- **需求说明**
|
||||
- 设置页开启后常驻监听(低功耗策略)。
|
||||
- 唤醒检测在本地执行,未唤醒音频不上传。
|
||||
- 唤醒成功:音效 + 动画反馈。
|
||||
- 低电量模式:可自动降敏或暂停。
|
||||
- 权限缺失:首次引导开启麦克风权限。
|
||||
|
||||
### 3.3 聊天模块
|
||||
|
||||
#### **3.3.1 聊天气泡类型**
|
||||
|
||||
- **用户场景**
|
||||
|
||||
用户与AI助手对话过程中,会展示不同类型的聊天气泡以适应各种交互需求。
|
||||
|
||||
- **功能描述**
|
||||
|
||||
系统支持多种聊天气泡类型,包括文本气泡、卡片气泡、按钮气泡、链接气泡和富媒体气泡等,以满足不同场景下的交互需求。
|
||||
|
||||
- **需求说明**
|
||||
- 文本气泡:展示普通文字对话内容,完整支持Markdown格式;
|
||||
Markdown支持包括普通文本格式化、表格布局以及图片嵌入,提供更丰富的内容展示方式
|
||||
- 卡片气泡:用于展示结构化信息,如车辆信息、维保记录等;
|
||||
- 按钮气泡:包含可交互按钮,用于快速回复或执行操作;
|
||||
- 富媒体气泡:支持图片、视频、语音等多媒体内容展示;
|
||||
富媒体气泡可能适用于显示特定的信息,或者是视频的教程,轮播的图片
|
||||
- 链接气泡:用于显示一个链接,包含Icon,Title,Description,以及一个可以点击的链接;
|
||||
点击链接可以进入webview,链接气泡主要是用于详细介绍某些活动或者查看详情
|
||||
- 业务气泡:用于特定业务的定制化气泡,会根据对话的业务场景,显示不同的气泡。
|
||||
业务气泡不同于其他的气泡,可以在用户询问金融方案后显示返回的计算结果,在结果后面还有一个按钮,可以进入金融计算器的页面;同时在用户说出我希望订购某款车型时,会出现车型的信息,并且有一个立即订购的按钮。业务气泡是根据返回的数据来判断具体的业务场景,混合显示的气泡类型。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### **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. Debug:JIT,Release:统一 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)
|
||||
BIN
docs/images/Screenshot_20250923_162311.jpg
Normal file
|
After Width: | Height: | Size: 842 KiB |
BIN
docs/images/bubble_image_list.png
Normal file
|
After Width: | Height: | Size: 6.2 MiB |
BIN
docs/images/bussine_image.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
docs/images/button_bubble_image.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/images/db1d92f4-107d-46c9-8154-fc58cb471acc.png
Normal file
|
After Width: | Height: | Size: 992 KiB |
BIN
docs/images/image 1.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/images/image 2.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/images/image 3.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
docs/images/image 4.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/images/image.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/images/quick_reply_image.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
docs/images/分子送检系统需求规格书.docx
Normal 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
|
||||
|
||||
@@ -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")
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
62
example/lib/pages/home.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
example/lib/pages/setting.dart
Normal 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('这里可以添加更多设置选项'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
uses-material-design: true
|
||||
@@ -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';
|
||||
@@ -1,5 +1,3 @@
|
||||
import '../enums/vehicle_command_type.dart';
|
||||
import '../services/command_service.dart';
|
||||
import 'easy_bloc.dart';
|
||||
import 'command_state.dart';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
enum MessageServiceState {
|
||||
idle,
|
||||
recording,
|
||||
recognizing,
|
||||
replying,
|
||||
}
|
||||
135
lib/manager.dart
@@ -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 单例销毁');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -37,8 +37,7 @@ class _MainScreenState extends State<MainScreen> {
|
||||
),
|
||||
),
|
||||
// FloatingIcon(),
|
||||
ChatPopup(
|
||||
)
|
||||
ChatPopup()
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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,27 +59,35 @@ class MessageService extends ChangeNotifier {
|
||||
Future<dynamic> _handleMethodCall(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case "onAsrResult":
|
||||
replaceMessage(
|
||||
id: _latestUserMessageId!, text: call.arguments, status: MessageStatus.normal);
|
||||
break;
|
||||
{
|
||||
debugPrint("ASR 结果: ${call.arguments}");
|
||||
replaceMessage(
|
||||
id: _latestUserMessageId!, text: call.arguments, status: MessageStatus.normal);
|
||||
break;
|
||||
}
|
||||
case "onAsrStop":
|
||||
int index = findMessageIndexById(_latestUserMessageId!);
|
||||
if (index == -1) {
|
||||
return;
|
||||
{
|
||||
debugPrint("ASR 停止: ${call.arguments}");
|
||||
int index = findMessageIndexById(_latestUserMessageId!);
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
final message = _messages[index];
|
||||
if (message.text.isEmpty) {
|
||||
removeMessageById(_latestUserMessageId!);
|
||||
return;
|
||||
}
|
||||
if (_asrCompleter != null && !_asrCompleter!.isCompleted) {
|
||||
_asrCompleter!.complete(messages.last.text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
final message = _messages[index];
|
||||
if (message.text.isEmpty) {
|
||||
removeMessageById(_latestUserMessageId!);
|
||||
return;
|
||||
}
|
||||
if (_asrCompleter != null && !_asrCompleter!.isCompleted) {
|
||||
_asrCompleter!.complete(messages.last.text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final ChatSseService _chatSseService = ChatSseService();
|
||||
final ChatSseService _chatSseService = ChatSseService(PlatformTtsService(aliSdkChannelName));
|
||||
// final Service.ChatSseService _chatSseService = Service.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;
|
||||
|
||||
36
lib/services/platform_tts_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:basic_intl/intl.dart';
|
||||
import 'package:t_basic_intl/intl.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -89,16 +120,17 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
|
||||
|
||||
Widget _buildPopupBackground() {
|
||||
return Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final messageService = MessageService.instance;
|
||||
_removeOverlay();
|
||||
messageService.abortReply();
|
||||
messageService.initializeEmpty();
|
||||
},
|
||||
child: SizedBox.expand()
|
||||
),
|
||||
);
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final messageService = MessageService.instance;
|
||||
_removeOverlay();
|
||||
messageService.abortReply();
|
||||
messageService.initializeEmpty();
|
||||
},
|
||||
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!');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
94
packages/ai_chat_core/lib/ai_chat_core.dart
Normal 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';
|
||||
11
packages/ai_chat_core/lib/src/apis/assistant_api.dart
Normal 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';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// 消息服务状态
|
||||
enum MessageServiceState {
|
||||
// 空闲
|
||||
idle,
|
||||
// 录音中
|
||||
recording,
|
||||
// 识别中
|
||||
recognizing,
|
||||
// 回复中
|
||||
replying,
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Message状态枚举
|
||||
enum MessageStatus {
|
||||
normal('普通消息', 'Normal'),
|
||||
listening('聆听中', 'Listening'),
|
||||
@@ -13,4 +14,4 @@ enum MessageStatus {
|
||||
|
||||
final String chinese;
|
||||
final String english;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,52 @@
|
||||
// 车控指令类型
|
||||
enum VehicleCommandType {
|
||||
// 未知
|
||||
unknown('未知', 'unknown'),
|
||||
// 上锁车门
|
||||
lock('上锁车门', 'lock'),
|
||||
// 解锁车门
|
||||
unlock('解锁车门', 'unlock'),
|
||||
// 打开车窗
|
||||
openWindow('打开车窗', 'open window'),
|
||||
// 关闭车窗
|
||||
closeWindow('关闭车窗', 'close window'),
|
||||
// 预约空调
|
||||
appointAC('预约空调', 'appoint AC'),
|
||||
// 打开空调
|
||||
openAC('打开空调', 'open AC'),
|
||||
// 关闭空调
|
||||
closeAC('关闭空调', 'close AC'),
|
||||
// 修改空调温度
|
||||
changeACTemp('修改空调温度', 'change AC temperature'),
|
||||
// 极速降温
|
||||
coolSharply('极速降温', 'cool sharply'),
|
||||
// 一键备车
|
||||
prepareCar('一键备车', 'prepare car'),
|
||||
// 一键融雪
|
||||
meltSnow('一键融雪', 'melt snow'),
|
||||
// 打开后备箱
|
||||
openTrunk('打开后备箱', 'open trunk'),
|
||||
// 关闭后备箱
|
||||
closeTrunk('关闭后备箱', 'close trunk'),
|
||||
// 鸣笛
|
||||
honk('鸣笛', 'honk'),
|
||||
// 定位车辆
|
||||
locateCar('定位车辆', 'locate car'),
|
||||
// 开启方向盘加热
|
||||
openWheelHeat('开启方向盘加热', 'open wheel heat'),
|
||||
// 关闭方向盘加热
|
||||
closeWheelHeat('关闭方向盘加热', 'close wheel heat'),
|
||||
// 开启主座椅加热
|
||||
openMainSeatHeat('开启主座椅加热', 'open main seat heat'),
|
||||
// 关闭主座椅加热
|
||||
closeMainSeatHeat('关闭主座椅加热', 'close main seat heat'),
|
||||
// 开启副座椅加热
|
||||
openMinorSeatHeat('开启副座椅加热', 'open minor seat heat'),
|
||||
// 关闭副座椅加热
|
||||
closeMinorSeatHeat('关闭副座椅加热', 'close minor seat heat');
|
||||
|
||||
const VehicleCommandType(this.chinese, this.english);
|
||||
|
||||
final String chinese;
|
||||
final String english;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension ColorExtension on Color {
|
||||
static const Color commonColor = Color(0xFF6F72F1);
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:ai_chat_assistant/models/vehicle_cmd.dart';
|
||||
import 'vehicle_cmd.dart';
|
||||
|
||||
class VehicleCommandResponse {
|
||||
final String? tips;
|
||||
23
packages/ai_chat_core/lib/src/network/api_config.dart
Normal 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._();
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
198
packages/ai_chat_core/lib/src/network/http_client.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
17
packages/ai_chat_core/lib/src/network/http_exception.dart
Normal 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';
|
||||
}
|
||||
|
||||
}
|
||||
32
packages/ai_chat_core/lib/src/network/interceptor.dart
Normal 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);
|
||||
}
|
||||
45
packages/ai_chat_core/lib/src/network/log_interceptor.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
14
packages/ai_chat_core/lib/src/persistence/message_dao.dart
Normal 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 {
|
||||
}
|
||||
}
|
||||
235
packages/ai_chat_core/lib/src/services/chat_sse_service.dart
Normal 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))));
|
||||
}
|
||||
}
|
||||
21
packages/ai_chat_core/lib/src/services/tts_service.dart
Normal 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();
|
||||
}
|
||||
8
packages/ai_chat_core/lib/src/utils/constants.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
// 常量定义
|
||||
|
||||
class Constants {
|
||||
// 最大消息长度
|
||||
static const int maxMessageLength = 1000;
|
||||
// 默认分页大小
|
||||
static const int defaultPageSize = 20;
|
||||
}
|
||||
47
packages/ai_chat_core/lib/src/utils/logger.dart
Normal 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('=============================================');
|
||||
}
|
||||
}
|
||||
}
|
||||
46
packages/ai_chat_core/lib/src/utils/markdown_cleaner.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
237
packages/ai_chat_core/pubspec.lock
Normal 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"
|
||||
18
packages/ai_chat_core/pubspec.yaml
Normal 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
|
||||
@@ -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:
|
||||
|
||||
33
packages/flutter_vosk_wakeword/.gitignore
vendored
Normal 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/
|
||||
33
packages/flutter_vosk_wakeword/.metadata
Normal 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'
|
||||
3
packages/flutter_vosk_wakeword/CHANGELOG.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
1
packages/flutter_vosk_wakeword/LICENSE
Normal file
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
15
packages/flutter_vosk_wakeword/README.md
Normal 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.
|
||||
|
||||
4
packages/flutter_vosk_wakeword/analysis_options.yaml
Normal 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
|
||||
9
packages/flutter_vosk_wakeword/android/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/workspace.xml
|
||||
/.idea/libraries
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.cxx
|
||||
75
packages/flutter_vosk_wakeword/android/build.gradle
Normal 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")
|
||||
}
|
||||
32
packages/flutter_vosk_wakeword/android/models/build.gradle
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
Chinese Vosk model for mobile
|
||||
|
||||
CER results
|
||||
|
||||
23.54% speechio_02
|
||||
38.29% speechio_06
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||