Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f02bfac01 | ||
|
|
37aa772472 | ||
|
|
c9bf6c501c | ||
|
|
e6f9ceb7fa | ||
|
|
eab8b48c9c | ||
|
|
2a42d86db0 | ||
|
|
68b0d9654c | ||
|
|
8f96cf13ac | ||
|
|
dbd179e507 | ||
|
|
9541e85600 | ||
|
|
9ecd829bfa | ||
|
|
fd4cff73ae | ||
|
|
1d6c4751de | ||
|
|
e066c1d6a1 | ||
|
|
85f5404b3b | ||
|
|
33c5893d32 | ||
|
|
b597cb0686 | ||
|
|
c5e288ca9c | ||
|
|
38d984f225 | ||
|
|
628c642040 | ||
|
|
d217cc4dd0 | ||
|
|
4f0e54b390 | ||
|
|
d7f89be31c | ||
|
|
53bcee7032 | ||
|
|
96991ae5ef | ||
|
|
068bcde451 | ||
|
|
0dda4f5646 | ||
|
|
6c57ce9dd2 | ||
|
|
6ac67a7e41 | ||
|
|
a23a585ad8 | ||
|
|
eba9562e81 | ||
|
|
ce49b409ac | ||
|
|
7222f68d4d | ||
|
|
f3f0d62f12 | ||
|
|
6e7c86e159 | ||
|
|
f5565f6700 | ||
|
|
e8d0bb0c54 | ||
|
|
33d70ccc96 | ||
|
|
53313a26af | ||
|
|
7ba180752a | ||
|
|
19736e66ad | ||
|
|
a00f8e4b76 | ||
|
|
7d9895cf5b | ||
|
|
d5f804bbb3 | ||
|
|
33a385cfa8 | ||
|
|
109d924591 | ||
|
|
a825eb3d4c | ||
|
|
3fb40677a4 | ||
|
|
833971cd28 | ||
|
|
d14b14bce9 | ||
|
|
53e26821ad | ||
|
|
1a7c06eb81 | ||
|
|
42a5b4892d | ||
|
|
9f4508b0c7 | ||
|
|
b2123ff01a | ||
|
|
43ead841a4 | ||
|
|
33b4794e83 | ||
|
|
fb91e6b1dd | ||
|
|
4a4dbf123e | ||
|
|
0decedd6a1 | ||
|
|
d2e3a63418 | ||
|
|
15b3f0a8f5 | ||
|
|
5c261528d0 | ||
|
|
8b2bbec0b9 | ||
|
|
8b3a4ad163 | ||
|
|
b3e272281b | ||
|
|
06a90d6266 | ||
|
|
6eb7acaf73 | ||
|
|
2e092a7880 | ||
|
|
515f669546 | ||
|
|
7699596597 | ||
|
|
34dd325a1d | ||
|
|
d33cdd978d | ||
|
|
0da9b4a69c | ||
|
|
7fc2eeaaa5 | ||
|
|
508529f66e | ||
|
|
dc170edbc1 | ||
|
|
85e65cf9e8 | ||
|
|
41887ef431 | ||
|
|
5dbf796fa9 | ||
|
|
20f601e607 | ||
|
|
c8d2d2255d | ||
|
|
5eaaf9f01d | ||
|
|
d6697948c2 | ||
|
|
f88abd7638 | ||
|
|
fbfc408e94 | ||
|
|
5ac1a1d6a5 | ||
|
|
3a74b30a0e | ||
|
|
fe0a6852e9 | ||
|
|
bfa04743e2 | ||
|
|
48094f7e37 | ||
|
|
60521b0a7e | ||
|
|
401deff2c3 | ||
|
|
6ffa325b73 | ||
|
|
eeedfc8ac9 | ||
|
|
aba7172a03 | ||
|
|
192f6f198f | ||
|
|
43c9d85b8f | ||
|
|
6d5935fb27 | ||
|
|
efbef4ff5b | ||
|
|
e4c99b9351 | ||
|
|
11e328ea6a | ||
|
|
8879907e38 | ||
|
|
885d72c4f7 | ||
|
|
4538542c25 | ||
|
|
3d509d75dc | ||
|
|
0ef73d7c3e | ||
|
|
6da90c6a97 | ||
|
|
bacdb794da | ||
|
|
2ece3f399b | ||
|
|
68b539db15 | ||
|
|
7a7bfa26f6 | ||
|
|
5f229351c8 | ||
|
|
82125c4933 | ||
|
|
33f75d26c1 | ||
|
|
3c4d702bc1 | ||
|
|
db8d100edb | ||
|
|
6f7e8978ca | ||
|
|
856bf2c574 | ||
|
|
b10cfa6b24 | ||
|
|
d3d329bd43 | ||
|
|
f358011900 | ||
|
|
cd6c3e4f79 | ||
|
|
ee7342de9d | ||
|
|
6120d49a35 | ||
|
|
3c46e16494 | ||
|
|
1ba556988f |
@@ -183,8 +183,8 @@ Spearheaded by Professor Siyuan Liu's Team (South China University of Technology
|
||||
#### 🚀 部署方式选择
|
||||
| 部署方式 | 特点 | 适用场景 | 部署文档 | 配置要求 | 视频教程 |
|
||||
|---------|------|---------|---------|---------|---------|
|
||||
| **最简化安装** | 智能对话、IOT、MCP、视觉感知 | 低配置环境,数据存储在配置文件,无需数据库 | [①Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②源码部署](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - |
|
||||
| **全模块安装** | 智能对话、IOT、MCP接入点、声纹识别、视觉感知、OTA、智控台 | 完整功能体验,数据存储在数据库 |[①Docker版](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②源码部署](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
| **最简化安装** | 智能对话、单智能体管理 | 低配置环境,数据存储在配置文件,无需数据库 | [①Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②源码部署](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - |
|
||||
| **全模块安装** | 智能对话、多用户管理、多智能体管理、智控台界面操作 | 完整功能体验,数据存储在数据库 |[①Docker版](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②源码部署](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
|
||||
常见问题及相关教程,可参考[这个链接](./docs/FAQ.md)
|
||||
|
||||
@@ -211,10 +211,10 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 模块名称 | 入门全免费设置 | 流式配置 |
|
||||
|:---:|:---:|:---:|
|
||||
| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) |
|
||||
| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) 或 👍DoubaoLLM(doubao-1-5-pro-32k-250115) |
|
||||
| VLLM(视觉大模型) | ChatGLMVLLM(智谱glm-4v-flash) | 👍QwenVLVLLM(千问qwen2.5-vl-3b-instructh) |
|
||||
| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山双流式语音合成) 或 👍AliyunStreamTTS(阿里云流式语音合成) |
|
||||
| ASR(语音识别) | FunASR(本地) | 👍XunfeiStreamASR(讯飞流式) |
|
||||
| LLM(大模型) | glm-4-flash(智谱) | 👍qwen-flash(阿里百炼) |
|
||||
| VLLM(视觉大模型) | glm-4v-flash(智谱) | 👍qwen2.5-vl-3b-instructh(阿里百炼) |
|
||||
| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山流式) |
|
||||
| Intent(意图识别) | function_call(函数调用) | function_call(函数调用) |
|
||||
| Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) |
|
||||
|
||||
@@ -260,7 +260,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
---
|
||||
|
||||
## 产品生态 👬
|
||||
小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的[优秀项目](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE)
|
||||
小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的[优秀项目](https://github.com/78/xiaozhi-esp32/blob/main/README_zh.md#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -181,8 +181,8 @@ Dieses Projekt bietet zwei Bereitstellungsmethoden. Bitte wählen Sie basierend
|
||||
#### 🚀 Auswahl der Bereitstellungsmethode
|
||||
| Bereitstellungsmethode | Funktionen | Anwendungsszenarien | Deployment-Dokumente | Konfigurationsanforderungen | Video-Tutorials |
|
||||
|---------|------|---------|---------|---------|---------|
|
||||
| **Vereinfachte Installation** | Intelligenter Dialog, IOT, MCP, visuelle Wahrnehmung | Umgebungen mit geringer Konfiguration, Daten in Konfigurationsdateien gespeichert, keine Datenbank erforderlich | [①Docker-Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Quellcode-Deployment](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 Kerne 4GB bei Verwendung von `FunASR`, 2 Kerne 2GB bei allen APIs | - |
|
||||
| **Vollständige Modulinstallation** | Intelligenter Dialog, IOT, MCP-Endpunkte, Stimmabdruckerkennung, visuelle Wahrnehmung, OTA, intelligente Steuerkonsole | Vollständige Funktionserfahrung, Daten in Datenbank gespeichert |[①Docker-Version](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Quellcode-Deployment](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Quellcode-Deployment Auto-Update-Tutorial](./docs/dev-ops-integration.md) | 4 Kerne 8GB bei Verwendung von `FunASR`, 2 Kerne 4GB bei allen APIs| [Video-Tutorial für lokalen Quellcode-Start](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
| **Vereinfachte Installation** | Intelligenter Dialog, Einzel-Agenten-Verwaltung | Umgebungen mit geringer Konfiguration, Daten in Konfigurationsdateien gespeichert, keine Datenbank erforderlich | [①Docker-Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Quellcode-Deployment](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 Kerne 4GB bei Verwendung von `FunASR`, 2 Kerne 2GB bei allen APIs | - |
|
||||
| **Vollständige Modulinstallation** | Intelligenter Dialog, Mehrbenutzerverwaltung, Mehr-Agenten-Verwaltung, Intelligente Steuerkonsole-Bedienung | Vollständige Funktionserfahrung, Daten in Datenbank gespeichert |[①Docker-Version](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Quellcode-Deployment](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Quellcode-Deployment Auto-Update-Tutorial](./docs/dev-ops-integration.md) | 4 Kerne 8GB bei Verwendung von `FunASR`, 2 Kerne 4GB bei allen APIs| [Video-Tutorial für lokalen Quellcode-Start](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
|
||||
Häufige Fragen und entsprechende Tutorials finden Sie unter [diesem Link](./docs/FAQ.md)
|
||||
|
||||
@@ -209,10 +209,10 @@ Websocket-Schnittstellenadresse: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| Modulname | Einstiegslevel Kostenlose Einstellungen | Streaming-Konfiguration |
|
||||
|:---:|:---:|:---:|
|
||||
| ASR (Spracherkennung) | FunASR (Lokal) | 👍FunASR (Lokaler GPU-Modus) |
|
||||
| LLM (Großes Modell) | ChatGLMLLM (Zhipu glm-4-flash) | 👍AliLLM (qwen3-235b-a22b-instruct-2507) oder 👍DoubaoLLM (doubao-1-5-pro-32k-250115) |
|
||||
| VLLM (Vision Large Model) | ChatGLMVLLM (Zhipu glm-4v-flash) | 👍QwenVLVLLM (Qwen qwen2.5-vl-3b-instructh) |
|
||||
| TTS (Sprachsynthese) | ✅LinkeraiTTS (Lingxi-Streaming) | 👍HuoshanDoubleStreamTTS (Volcano Dual-Stream-Sprachsynthese) oder 👍AliyunStreamTTS (Alibaba Cloud Streaming-Sprachsynthese) |
|
||||
| ASR (Spracherkennung) | FunASR (Lokal) | 👍XunfeiStreamASR (Xunfei-Streaming) |
|
||||
| LLM (Großes Modell) | glm-4-flash (Zhipu) | 👍qwen-flash (Alibaba Bailian) |
|
||||
| VLLM (Vision Large Model) | glm-4v-flash (Zhipu) | 👍qwen2.5-vl-3b-instructh (Alibaba Bailian) |
|
||||
| TTS (Sprachsynthese) | ✅LinkeraiTTS (Lingxi-Streaming) | 👍HuoshanDoubleStreamTTS (Volcano-Streaming) |
|
||||
| Intent (Absichtserkennung) | function_call (Funktionsaufruf) | function_call (Funktionsaufruf) |
|
||||
| Memory (Gedächtnisfunktion) | mem_local_short (Lokales Kurzzeitgedächtnis) | mem_local_short (Lokales Kurzzeitgedächtnis) |
|
||||
|
||||
@@ -258,7 +258,7 @@ Wenn Sie ein Softwareentwickler sind, finden Sie hier einen [Offenen Brief an En
|
||||
---
|
||||
|
||||
## Produktökosystem 👬
|
||||
Xiaozhi ist ein Ökosystem. Wenn Sie dieses Produkt verwenden, können Sie sich auch andere [hervorragende Projekte](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE) in diesem Ökosystem ansehen
|
||||
Xiaozhi ist ein Ökosystem. Wenn Sie dieses Produkt verwenden, können Sie sich auch andere [hervorragende Projekte](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#related-open-source-projects) in diesem Ökosystem ansehen
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -181,8 +181,8 @@ This project provides two deployment methods. Please choose based on your specif
|
||||
#### 🚀 Deployment Method Selection
|
||||
| Deployment Method | Features | Applicable Scenarios | Deployment Docs | Configuration Requirements | Video Tutorials |
|
||||
|---------|------|---------|---------|---------|---------|
|
||||
| **Simplified Installation** | Intelligent dialogue, IOT, MCP, visual perception | Low-configuration environments, data stored in config files, no database required | [①Docker Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Source Code Deployment](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 cores 4GB if using `FunASR`, 2 cores 2GB if all APIs | - |
|
||||
| **Full Module Installation** | Intelligent dialogue, IOT, MCP endpoints, voiceprint recognition, visual perception, OTA, intelligent control console | Complete functionality experience, data stored in database |[①Docker Version](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Source Code Deployment](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Source Code Deployment Auto-Update Tutorial](./docs/dev-ops-integration.md) | 4 cores 8GB if using `FunASR`, 2 cores 4GB if all APIs| [Local Source Code Startup Video Tutorial](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
| **Simplified Installation** | Intelligent dialogue, single agent management | Low-configuration environments, data stored in config files, no database required | [①Docker Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Source Code Deployment](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 cores 4GB if using `FunASR`, 2 cores 2GB if all APIs | - |
|
||||
| **Full Module Installation** | Intelligent dialogue, multi-user management, multi-agent management, intelligent console interface operation | Complete functionality experience, data stored in database |[①Docker Version](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Source Code Deployment](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Source Code Deployment Auto-Update Tutorial](./docs/dev-ops-integration.md) | 4 cores 8GB if using `FunASR`, 2 cores 4GB if all APIs| [Local Source Code Startup Video Tutorial](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
|
||||
|
||||
> 💡 Note: Below is a test platform deployed with the latest code. You can burn and test if needed. Concurrent users: 6, data will be cleared daily.
|
||||
@@ -208,10 +208,10 @@ Websocket Interface Address: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| Module Name | Entry Level Free Settings | Streaming Configuration |
|
||||
|:---:|:---:|:---:|
|
||||
| ASR(Speech Recognition) | FunASR(Local) | 👍FunASRServer or 👍DoubaoStreamASR |
|
||||
| LLM(Large Model) | ChatGLMLLM(Zhipu glm-4-flash) | 👍DoubaoLLM(Volcano doubao-1-5-pro-32k-250115) |
|
||||
| VLLM(Vision Large Model) | ChatGLMVLLM(Zhipu glm-4v-flash) | 👍QwenVLVLLM(Qwen qwen2.5-vl-3b-instructh) |
|
||||
| TTS(Speech Synthesis) | ✅LinkeraiTTS(Lingxi streaming) | 👍HuoshanDoubleStreamTTS(Volcano dual-stream speech synthesis) |
|
||||
| ASR(Speech Recognition) | FunASR(Local) | 👍XunfeiStreamASR(Xunfei Streaming) |
|
||||
| LLM(Large Model) | glm-4-flash(Zhipu) | 👍qwen-flash(Alibaba Bailian) |
|
||||
| VLLM(Vision Large Model) | glm-4v-flash(Zhipu) | 👍qwen2.5-vl-3b-instructh(Alibaba Bailian) |
|
||||
| TTS(Speech Synthesis) | ✅LinkeraiTTS(Lingxi streaming) | 👍HuoshanDoubleStreamTTS(Volcano Streaming) |
|
||||
| Intent(Intent Recognition) | function_call(Function calling) | function_call(Function calling) |
|
||||
| Memory(Memory function) | mem_local_short(Local short-term memory) | mem_local_short(Local short-term memory) |
|
||||
|
||||
@@ -256,7 +256,7 @@ If you are a software developer, here is an [Open Letter to Developers](docs/con
|
||||
---
|
||||
|
||||
## Product Ecosystem 👬
|
||||
Xiaozhi is an ecosystem. When using this product, you can also check out other [excellent projects](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE) in this ecosystem
|
||||
Xiaozhi is an ecosystem. When using this product, you can also check out other [excellent projects](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#related-open-source-projects) in this ecosystem
|
||||
|
||||
| Project Name | Project Address | Project Description |
|
||||
|:---------------------|:--------|:--------|
|
||||
|
||||
@@ -182,8 +182,8 @@ Dự án này cung cấp hai phương pháp triển khai, vui lòng chọn theo
|
||||
#### 🚀 Lựa chọn phương pháp triển khai
|
||||
| Phương pháp triển khai | Đặc điểm | Tình huống áp dụng | Tài liệu triển khai | Yêu cầu cấu hình | Video hướng dẫn |
|
||||
|---------|------|---------|---------|---------|---------|
|
||||
| **Cài đặt tối giản** | Đối thoại thông minh, IOT, MCP, cảm nhận thị giác | Môi trường cấu hình thấp, dữ liệu lưu trong tệp cấu hình, không cần cơ sở dữ liệu | [①Phiên bản Docker](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Triển khai mã nguồn](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 nhân 4GB nếu dùng `FunASR`, 2 nhân 2GB nếu toàn API | - |
|
||||
| **Cài đặt toàn bộ module** | Đối thoại thông minh, IOT, điểm truy cập MCP, nhận dạng giọng nói, cảm nhận thị giác, OTA, bảng điều khiển thông minh | Trải nghiệm đầy đủ tính năng, dữ liệu lưu trong cơ sở dữ liệu |[①Phiên bản Docker](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Triển khai mã nguồn](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Hướng dẫn tự động cập nhật triển khai mã nguồn](./docs/dev-ops-integration.md) | 4 nhân 8GB nếu dùng `FunASR`, 2 nhân 4GB nếu toàn API| [Video hướng dẫn khởi động mã nguồn cục bộ](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
| **Cài đặt tối giản** | Đối thoại thông minh, quản lý đơn tác nhân | Môi trường cấu hình thấp, dữ liệu lưu trong tệp cấu hình, không cần cơ sở dữ liệu | [①Phiên bản Docker](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Triển khai mã nguồn](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 nhân 4GB nếu dùng `FunASR`, 2 nhân 2GB nếu toàn API | - |
|
||||
| **Cài đặt toàn bộ module** | Đối thoại thông minh, quản lý đa người dùng, quản lý đa tác nhân, bảng điều khiển thông minh | Trải nghiệm đầy đủ tính năng, dữ liệu lưu trong cơ sở dữ liệu |[①Phiên bản Docker](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Triển khai mã nguồn](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Hướng dẫn tự động cập nhật triển khai mã nguồn](./docs/dev-ops-integration.md) | 4 nhân 8GB nếu dùng `FunASR`, 2 nhân 4GB nếu toàn API| [Video hướng dẫn khởi động mã nguồn cục bộ](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
|
||||
Câu hỏi thường gặp và hướng dẫn liên quan, vui lòng tham khảo [liên kết này](./docs/FAQ.md)
|
||||
|
||||
@@ -210,10 +210,10 @@ Công cụ kiểm tra dịch vụ: https://2662r3426b.vicp.fun/test/
|
||||
|
||||
| Tên module | Cài đặt miễn phí cho người mới | Cấu hình streaming |
|
||||
|:---:|:---:|:---:|
|
||||
| ASR(Nhận dạng giọng nói) | FunASR(Local) | 👍FunASR(Chế độ GPU cục bộ) |
|
||||
| LLM(Mô hình lớn) | ChatGLMLLM(Zhipu glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) hoặc 👍DoubaoLLM(doubao-1-5-pro-32k-250115) |
|
||||
| VLLM(Mô hình lớn thị giác) | ChatGLMVLLM(Zhipu glm-4v-flash) | 👍QwenVLVLLM(Qwen qwen2.5-vl-3b-instructh) |
|
||||
| TTS(Tổng hợp giọng nói) | ✅LinkeraiTTS(Lingxi streaming) | 👍HuoshanDoubleStreamTTS(Tổng hợp giọng nói streaming kép Volcano) hoặc 👍AliyunStreamTTS(Tổng hợp giọng nói streaming Alibaba Cloud) |
|
||||
| ASR(Nhận dạng giọng nói) | FunASR(Local) | 👍XunfeiStreamASR(Xunfei Streaming) |
|
||||
| LLM(Mô hình lớn) | glm-4-flash(Zhipu) | 👍qwen-flash(Alibaba Bailian) |
|
||||
| VLLM(Mô hình lớn thị giác) | glm-4v-flash(Zhipu) | 👍qwen2.5-vl-3b-instructh(Alibaba Bailian) |
|
||||
| TTS(Tổng hợp giọng nói) | ✅LinkeraiTTS(Lingxi streaming) | 👍HuoshanDoubleStreamTTS(Volcano Streaming) |
|
||||
| Intent(Nhận dạng ý định) | function_call(Gọi hàm) | function_call(Gọi hàm) |
|
||||
| Memory(Chức năng bộ nhớ) | mem_local_short(Bộ nhớ ngắn hạn cục bộ) | mem_local_short(Bộ nhớ ngắn hạn cục bộ) |
|
||||
|
||||
@@ -259,7 +259,7 @@ Nếu bạn là một nhà phát triển phần mềm, đây có một [Lá thư
|
||||
---
|
||||
|
||||
## Hệ sinh thái sản phẩm 👬
|
||||
Xiaozhi là một hệ sinh thái, khi bạn sử dụng sản phẩm này, bạn cũng có thể xem các [dự án xuất sắc](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE) khác trong hệ sinh thái này
|
||||
Xiaozhi là một hệ sinh thái, khi bạn sử dụng sản phẩm này, bạn cũng có thể xem các [dự án xuất sắc](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#related-open-source-projects) khác trong hệ sinh thái này
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -38,10 +38,10 @@ conda install conda-forge::ffmpeg
|
||||
|
||||
| 模块名称 | 入门全免费设置 | 流式配置 |
|
||||
|:---:|:---:|:---:|
|
||||
| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) |
|
||||
| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) 或 👍DoubaoLLM(doubao-1-5-pro-32k-250115) |
|
||||
| VLLM(视觉大模型) | ChatGLMVLLM(智谱glm-4v-flash) | 👍QwenVLVLLM(千问qwen2.5-vl-3b-instructh) |
|
||||
| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山双流式语音合成) 或 👍AliyunStreamTTS(阿里云流式语音合成) |
|
||||
| ASR(语音识别) | FunASR(本地) | 👍XunfeiStreamASR(讯飞流式) |
|
||||
| LLM(大模型) | glm-4-flash(智谱) | 👍qwen-flash(阿里百炼) |
|
||||
| VLLM(视觉大模型) | glm-4v-flash(智谱) | 👍qwen2.5-vl-3b-instructh(阿里百炼) |
|
||||
| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山流式) |
|
||||
| Intent(意图识别) | function_call(函数调用) | function_call(函数调用) |
|
||||
| Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) |
|
||||
|
||||
@@ -69,6 +69,7 @@ VAD:
|
||||
### 9、编译固件相关教程
|
||||
1、[如何自己编译小智固件](./firmware-build.md)<br/>
|
||||
2、[如何基于虾哥编译好的固件修改OTA地址](./firmware-setting.md)<br/>
|
||||
3、[单模块部署如何配置固件OTA自动升级](./ota-upgrade-guide.md)<br/>
|
||||
|
||||
### 10、拓展相关教程
|
||||
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/>
|
||||
@@ -80,6 +81,7 @@ VAD:
|
||||
7、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
||||
8、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||
9、[知识库ragflow集成指南](./ragflow-integration.md)<br/>
|
||||
10、[如何部署上下文源](./context-provider-integration.md)<br/>
|
||||
|
||||
### 11、语音克隆、本地语音部署相关教程
|
||||
1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/>
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
# 上下文源使用教程
|
||||
|
||||
## 概述
|
||||
|
||||
`上下文源`,就是为小智系统提示词的上下文添加【数据源】。
|
||||
|
||||
`上下文源` 在小智在唤醒那一刻,获取外部系统的数据,并将其动态注入到大模型的系统提示词(System Prompt)中。
|
||||
让其做到唤醒时感知世界某个事物的状态。
|
||||
|
||||
它和MCP、记忆有本质的区别:`上下文源`是强制让小智感知世界的数据;`记忆(Mem)`是让他知道之前聊了什么内容;`MCP(functionc all)`是当需要调用某项能力/知识的时候使用调用。
|
||||
|
||||
通过这个功能,在小智唤醒的一刹那,“感知”到:
|
||||
- 人体健康传感器状态(体温、血压、血氧状态等)
|
||||
- 业务系统的实时数据(服务器负载、待办数据、股票信息等)
|
||||
- 任何可以通过 HTTP API 获取的文本信息
|
||||
|
||||
**注意**:该功能只是方便小智在唤醒的时候感知事物的状态,而如果想要小智唤醒后实时获取事物的状态,建议在此功能上再结合MCP工具的调用。
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **配置源**:用户配置一个或多个 HTTP API 地址。
|
||||
2. **触发请求**:当系统构建 Prompt 时,如果发现模板中包含 `{{ dynamic_context }}` 占位符,会请求所有配置的 API。
|
||||
3. **自动注入**:系统会自动将 API 返回的数据格式化为 Markdown 列表,替换 `{{ dynamic_context }}` 占位符。
|
||||
|
||||
## 接口规范
|
||||
|
||||
为了让小智正确解析数据,您的 API 需要满足以下规范:
|
||||
|
||||
- **请求方式**:`GET`
|
||||
- **请求头**:系统会自动添加 `device-id` 字段到 Request Header。
|
||||
- **响应格式**:必须返回 JSON 格式,且包含 `code` 和 `data` 字段。
|
||||
|
||||
### 响应示例
|
||||
|
||||
**情况 1:返回键值对**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"客厅温度": "26℃",
|
||||
"客厅湿度": "45%",
|
||||
"大门状态": "已关闭"
|
||||
}
|
||||
}
|
||||
```
|
||||
*注入效果:*
|
||||
```markdown
|
||||
<context>
|
||||
- **客厅温度:** 26℃
|
||||
- **客厅湿度:** 45%
|
||||
- **大门状态:** 已关闭
|
||||
</context>
|
||||
```
|
||||
|
||||
**情况 2:返回列表**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": [
|
||||
"您有10个待办事项",
|
||||
"当前汽车的行驶速度是100km每小时"
|
||||
]
|
||||
}
|
||||
```
|
||||
*注入效果:*
|
||||
```markdown
|
||||
<context>
|
||||
- 您有10个待办事项
|
||||
- 当前汽车的行驶速度是100km每小时
|
||||
</context>
|
||||
```
|
||||
|
||||
## 配置指南
|
||||
|
||||
### 方式 1:智控台配置(全模块部署)
|
||||
|
||||
1. 登录智控台,进入**角色配置**页面。
|
||||
2. 找到**上下文源**配置项(点击“编辑源”按钮)。
|
||||
3. 点击**添加**,输入您的 API 地址。
|
||||
4. 如果 API 需要鉴权,可以在**请求头**部分添加 `Authorization` 或其他 Header。
|
||||
5. 保存配置。
|
||||
|
||||
### 方式 2:配置文件配置(单模块部署)
|
||||
|
||||
编辑 `xiaozhi-server/data/.config.yaml` 文件,添加 `context_providers` 配置段:
|
||||
|
||||
```yaml
|
||||
# 上下文源配置
|
||||
context_providers:
|
||||
- url: "http://api.example.com/data"
|
||||
headers:
|
||||
Authorization: "Bearer your-token"
|
||||
- url: "http://another-api.com/data"
|
||||
```
|
||||
|
||||
## 启用功能
|
||||
|
||||
默认情况下,系统的提示词模板文件(`data/.agent-base-prompt.txt`)中已经预置了 `{{ dynamic_context }}` 占位符,您无需手动添加。
|
||||
|
||||
**示例:**
|
||||
|
||||
```markdown
|
||||
<context>
|
||||
【重要!以下信息已实时提供,无需调用工具查询,请直接使用:】
|
||||
- **设备ID:** {{device_id}}
|
||||
- **当前时间:** {{current_time}}
|
||||
...
|
||||
{{ dynamic_context }}
|
||||
</context>
|
||||
```
|
||||
|
||||
**注意**:如果您不需要使用此功能,可以选择**不配置任何上下文源**,也可以从提示词模板文件中**删除** `{{ dynamic_context }}` 占位符。
|
||||
|
||||
## 附录:Mock 测试服务示例
|
||||
|
||||
为了方便您测试和开发,我们提供了一个简单的 Python Mock Server 脚本。您可以运行此脚本在本地模拟 API 接口。
|
||||
|
||||
**mock_api_server.py**
|
||||
|
||||
```python
|
||||
import http.server
|
||||
import socketserver
|
||||
import json
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
# 设置端口号
|
||||
PORT = 8081
|
||||
|
||||
class MockRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
# 解析路径和参数
|
||||
parsed_path = urlparse(self.path)
|
||||
path = parsed_path.path
|
||||
query = parse_qs(parsed_path.query)
|
||||
|
||||
response_data = {}
|
||||
status_code = 200
|
||||
|
||||
print(f"收到请求: {path}, 参数: {query}")
|
||||
|
||||
# Case 1: 模拟健康数据 (返回字典 Dict)
|
||||
# 路径参数风格: /health
|
||||
# device_id 从 Header 获取
|
||||
if path == "/health":
|
||||
device_id = self.headers.get("device-id", "unknown_device")
|
||||
print(f"device_id: {device_id}")
|
||||
response_data = {
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"测试设备ID": device_id,
|
||||
"心率": "80 bpm",
|
||||
"血压": "120/80 mmHg",
|
||||
"状态": "良好"
|
||||
}
|
||||
}
|
||||
|
||||
# Case 2: 模拟新闻列表 (返回列表 List)
|
||||
# 无参数: /news/list
|
||||
elif path == "/news/list":
|
||||
response_data = {
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
"今日头条:Python 3.14 发布",
|
||||
"科技新闻:AI 助手改变生活",
|
||||
"本地新闻:明日有大雨,记得带伞"
|
||||
]
|
||||
}
|
||||
|
||||
# Case 3: 模拟天气简报 (返回字符串 String)
|
||||
# 无参数: /weather/simple
|
||||
elif path == "/weather/simple":
|
||||
response_data = {
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": "今日晴转多云,气温 20-25 度,空气质量优,适合出行。"
|
||||
}
|
||||
|
||||
# Case 4: 模拟设备详情 (Query参数风格)
|
||||
# 参数风格: /device/info
|
||||
# device_id 从 Header 获取
|
||||
elif path == "/device/info":
|
||||
device_id = self.headers.get("device-id", "unknown_device")
|
||||
response_data = {
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"查询方式": "Header参数",
|
||||
"设备ID": device_id,
|
||||
"电量": "85%",
|
||||
"固件": "v2.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
# Case 5: 404 Not Found
|
||||
else:
|
||||
status_code = 404
|
||||
response_data = {"error": "接口不存在"}
|
||||
|
||||
# 发送响应
|
||||
self.send_response(status_code)
|
||||
self.send_header('Content-type', 'application/json; charset=utf-8')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(response_data, ensure_ascii=False).encode('utf-8'))
|
||||
|
||||
# 启动服务
|
||||
# 允许地址重用,防止快速重启报错
|
||||
socketserver.TCPServer.allow_reuse_address = True
|
||||
with socketserver.TCPServer(("", PORT), MockRequestHandler) as httpd:
|
||||
print(f"==================================================")
|
||||
print(f"Mock API Server 已启动: http://localhost:{PORT}")
|
||||
print(f"可用接口列表:")
|
||||
print(f"1. [字典] http://localhost:{PORT}/health")
|
||||
print(f"2. [列表] http://localhost:{PORT}/news/list")
|
||||
print(f"3. [文本] http://localhost:{PORT}/weather/simple")
|
||||
print(f"4. [参数] http://localhost:{PORT}/device/info")
|
||||
print(f"==================================================")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n服务已停止")
|
||||
```
|
||||
@@ -23,6 +23,8 @@
|
||||
|
||||
### 2.将音色资源ID分配给系统账号
|
||||
|
||||
使用超级管理员账号登录智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`音色克隆`,点击保存配置。即可在顶部菜单看到`音色克隆`按钮。
|
||||
|
||||
使用超级管理员账号登录智控台,点击顶部【音色克隆】、【音色资源】。
|
||||
|
||||
点击新增按钮,在【平台名称】选择“火山双流式语音合成”;
|
||||
|
||||
@@ -71,6 +71,7 @@ docker logs -f mcp-endpoint-server
|
||||
请你保留好上面两个`接口地址`,下一步要用到。
|
||||
|
||||
# 2、全模块部署时,怎么配置MCP接入点
|
||||
首先,你要开启MCP接入点功能。在智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`MCP接入点`,点击`保存配置`。在`角色配置`页面,点击`编辑功能`按钮,即可看到`mcp接入点`功能。
|
||||
|
||||
如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ MQTT_PORT=1883 # MQTT服务器端口
|
||||
UDP_PORT=8884 # UDP服务器端口
|
||||
API_PORT=8007 # 管理API端口
|
||||
MQTT_SIGNATURE_KEY=test # MQTT签名密钥
|
||||
SERVER_SECRET=Te1st12134 # 服务器密钥,请保持和智控台(server.secret)一致或者和xiaozhi-server里(server.auth_key)保持一致
|
||||
```
|
||||
请注意`PUBLIC_IP`配置,确保其与实际公网IP一致,如果有域名就填域名。
|
||||
|
||||
@@ -85,6 +86,13 @@ MQTT_SIGNATURE_KEY=test # MQTT签名密钥
|
||||
- 注意不要用简单的密码,比如`123456`、`test`等。
|
||||
- 注意不要用简单的密码,比如`123456`、`test`等。
|
||||
|
||||
`SERVER_SECRET` 是用生成websocket连接的认证信息。
|
||||
|
||||
1、如果你是全模块部署,且你的智控台的参数管理里`server.auth.enabled`设置成了`true`,那么,`SERVER_SECRET`需要和智控台(`server.secret`)保持一致。
|
||||
|
||||
2、如果你是单模块部署,且你在配置文件里把`server.auth.enabled`设置成了`true`,那么,`SERVER_SECRET`需要和配置文件里(`server.auth_key`)保持一致。
|
||||
|
||||
|
||||
6. 启动MQTT网关
|
||||
```
|
||||
# 启动服务
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# 单模块部署固件OTA自动升级配置指南
|
||||
|
||||
本教程将指导你如何在**单模块部署**场景下配置固件OTA自动升级功能,实现设备固件的自动更新。
|
||||
|
||||
如果你已经使用**全模块部署**,请忽略本教程。
|
||||
|
||||
## 功能介绍
|
||||
|
||||
在单模块部署中,xiaozhi-server内置了OTA固件管理功能,可以自动检测设备版本并下发升级固件。系统会根据设备型号和当前版本,自动匹配并推送最新的固件版本。
|
||||
|
||||
## 前提条件
|
||||
|
||||
- 你已经成功进行**单模块部署**并运行xiaozhi-server
|
||||
- 设备能够正常连接到服务器
|
||||
|
||||
## 第一步 准备固件文件
|
||||
|
||||
### 1. 创建固件存放目录
|
||||
|
||||
固件文件需要放在`data/bin/`目录下。如果该目录不存在,请手动创建:
|
||||
|
||||
```bash
|
||||
mkdir -p data/bin
|
||||
```
|
||||
|
||||
### 2. 固件文件命名规则
|
||||
|
||||
固件文件必须遵循以下命名格式:
|
||||
|
||||
```
|
||||
{设备型号}_{版本号}.bin
|
||||
```
|
||||
|
||||
**命名规则说明:**
|
||||
- `设备型号`:设备的型号名称,例如 `lichuang-dev`、`bread-compact-wifi` 等
|
||||
- `版本号`:固件版本号,必须以数字开头,支持数字、字母、点号、下划线和短横线,例如 `1.6.6`、`2.0.0` 等
|
||||
- 文件扩展名必须是 `.bin`
|
||||
|
||||
**命名示例:**
|
||||
```
|
||||
bread-compact-wifi_1.6.6.bin
|
||||
lichuang-dev_2.0.0.bin
|
||||
```
|
||||
|
||||
### 3. 放置固件文件
|
||||
|
||||
将准备好的固件文件(.bin文件)复制到`data/bin/`目录下:
|
||||
|
||||
重要的事情说三遍:升级的bin文件是`xiaozhi.bin`,不是全量固件文件`merged-binary.bin`!
|
||||
|
||||
重要的事情说三遍:升级的bin文件是`xiaozhi.bin`,不是全量固件文件`merged-binary.bin`!
|
||||
|
||||
重要的事情说三遍:升级的bin文件是`xiaozhi.bin`,不是全量固件文件`merged-binary.bin`!
|
||||
|
||||
```bash
|
||||
cp xiaozhi.bin data/bin/设备型号_版本号.bin
|
||||
```
|
||||
|
||||
例如:
|
||||
```bash
|
||||
cp xiaozhi.bin data/bin/bread-compact-wifi_1.6.6.bin
|
||||
```
|
||||
|
||||
## 第二步 配置公网访问地址(仅公网部署需要)
|
||||
|
||||
**注意:此步骤仅适用于单模块公网部署的场景。**
|
||||
|
||||
如果你的xiaozhi-server是公网部署(使用公网IP或域名),**必须**配置`server.vision_explain`参数,因为OTA固件下载地址会使用该配置的域名和端口。
|
||||
|
||||
如果你是局域网部署,可以跳过此步骤。
|
||||
|
||||
### 为什么要配置这个参数?
|
||||
|
||||
在单模块部署中,系统生成固件下载地址时,会使用`vision_explain`配置的域名和端口作为基础地址。如果不配置或配置错误,设备将无法访问固件下载地址。
|
||||
|
||||
### 配置方法
|
||||
|
||||
打开`data/.config.yaml`文件,找到`server`配置段,设置`vision_explain`参数:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
vision_explain: http://你的域名或IP:端口号/mcp/vision/explain
|
||||
```
|
||||
|
||||
**配置示例:**
|
||||
|
||||
局域网部署(默认):
|
||||
```yaml
|
||||
server:
|
||||
vision_explain: http://192.168.1.100:8003/mcp/vision/explain
|
||||
```
|
||||
|
||||
公网域名部署:
|
||||
```yaml
|
||||
server:
|
||||
vision_explain: http://yourdomain.com:8003/mcp/vision/explain
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 域名或IP必须是设备能够访问的地址
|
||||
- 如果使用Docker部署,不能使用Docker内部地址(如127.0.0.1或localhost)
|
||||
- 如果你使用了nginx反向代理,请填写对外的地址和端口号,不是本项目运行的端口号
|
||||
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 设备收不到固件更新
|
||||
|
||||
**可能原因和解决方法:**
|
||||
|
||||
- 检查固件文件命名是否符合规则:`{型号}_{版本号}.bin`
|
||||
- 检查固件文件是否正确放置在`data/bin/`目录
|
||||
- 检查设备型号是否与固件文件名中的型号匹配
|
||||
- 检查固件版本号是否高于设备当前版本
|
||||
- 查看服务器日志,确认OTA请求是否正常处理
|
||||
|
||||
### 2. 设备报告下载地址无法访问
|
||||
|
||||
**可能原因和解决方法:**
|
||||
|
||||
- 检查`server.vision_explain`配置的域名或IP是否正确
|
||||
- 确认端口号配置正确(默认8003)
|
||||
- 如果是公网部署,确保设备能够访问该公网地址
|
||||
- 如果是Docker部署,确保不是使用了内部地址(127.0.0.1)
|
||||
- 检查防火墙是否开放了对应端口
|
||||
- 如果你使用了nginx反向代理,请填写对外的地址和端口号,不是本项目运行的端口号
|
||||
|
||||
### 3. 如何确认设备当前版本
|
||||
|
||||
查看OTA请求日志,日志中会显示设备上报的版本号:
|
||||
|
||||
```
|
||||
[ota_handler] - 设备 AA:BB:CC:DD:EE:FF 固件已是最新: 1.6.6
|
||||
```
|
||||
|
||||
### 4. 固件文件放置后没有生效
|
||||
|
||||
系统有30秒的缓存时间(默认),可以:
|
||||
- 等待30秒后再让设备发起OTA请求
|
||||
- 重启xiaozhi-server服务
|
||||
- 调整`firmware_cache_ttl`配置为更短的时间
|
||||
@@ -238,9 +238,11 @@ docker-compose -f docker-compose.yml up -d
|
||||
在弹框中,点击"Create new Key"按钮,生成一个API Key。复制这个`API Key`,你稍后会用到。
|
||||
|
||||
# 第二步 配置到智控台
|
||||
确保你的智控台版本是`0.8.7`或以上。使用超级管理员账号登录到智控台。在顶部导航栏中,点击`模型配置`,在左侧导航栏中,点击`知识库`。
|
||||
确保你的智控台版本是`0.8.7`或以上。使用超级管理员账号登录到智控台。
|
||||
|
||||
在列表中找到`RAG_RAGFlow`,点击`编辑`按钮。
|
||||
首先,你要先开启知识库功能。在顶部导航栏中,点击`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`知识库`,点击`保存配置`。即可在导航栏看到`知识库`功能。
|
||||
|
||||
在顶部导航栏中,点击`模型配置`,在左侧导航栏中,点击`知识库`。在列表中找到`RAG_RAGFlow`,点击`编辑`按钮。
|
||||
|
||||
在`服务地址`中,填写`http://你的ragflow服务的局域网IP:8008`,例如我的ragflow服务的局域网IP是`192.168.1.100`,那么我就填写`http://192.168.1.100:8008`。
|
||||
|
||||
|
||||
@@ -164,6 +164,8 @@ http://192.168.1.25:8005/voiceprint/health?key=abcd
|
||||
# 2、全模块部署时,怎么配置声纹识别
|
||||
|
||||
## 第一步 配置接口
|
||||
首先,你要开启声纹识别功能。在智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`声纹识别`,点击`保存配置`。即可在新建智能体的卡片上看到`声纹识别`按钮。
|
||||
|
||||
如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。
|
||||
|
||||
然后搜索参数`server.voice_print`,此时,它的值应该是`null`值。
|
||||
|
||||
@@ -141,6 +141,11 @@ public interface Constant {
|
||||
*/
|
||||
String SERVER_MQTT_SECRET = "server.mqtt_signature_key";
|
||||
|
||||
/**
|
||||
* WebSocket认证开关
|
||||
*/
|
||||
String SERVER_AUTH_ENABLED = "server.auth.enabled";
|
||||
|
||||
/**
|
||||
* 无记忆
|
||||
*/
|
||||
@@ -299,7 +304,7 @@ public interface Constant {
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
public static final String VERSION = "0.8.9";
|
||||
public static final String VERSION = "0.8.10";
|
||||
|
||||
/**
|
||||
* 无效固件URL
|
||||
|
||||
@@ -159,4 +159,11 @@ public class RedisKeys {
|
||||
public static String getKnowledgeBaseCacheKey(String datasetId) {
|
||||
return "knowledge:base:" + datasetId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取临时注册设备标记key
|
||||
*/
|
||||
public static String getTmpRegisterMacKey(String deviceId) {
|
||||
return "tmp_register_mac:" + deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ import xiaozhi.modules.agent.entity.AgentEntity;
|
||||
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
||||
import xiaozhi.modules.agent.service.AgentChatAudioService;
|
||||
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||
import xiaozhi.modules.agent.service.AgentChatSummaryService;
|
||||
import xiaozhi.modules.agent.service.AgentContextProviderService;
|
||||
import xiaozhi.modules.agent.service.AgentPluginMappingService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
@@ -64,6 +66,8 @@ public class AgentController {
|
||||
private final AgentChatHistoryService agentChatHistoryService;
|
||||
private final AgentChatAudioService agentChatAudioService;
|
||||
private final AgentPluginMappingService agentPluginMappingService;
|
||||
private final AgentContextProviderService agentContextProviderService;
|
||||
private final AgentChatSummaryService agentChatSummaryService;
|
||||
private final RedisUtils redisUtils;
|
||||
|
||||
@GetMapping("/list")
|
||||
@@ -117,6 +121,27 @@ public class AgentController {
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
@PostMapping("/chat-summary/{sessionId}/save")
|
||||
@Operation(summary = "根据会话ID生成聊天记录总结并保存(异步执行)")
|
||||
public Result<Void> generateAndSaveChatSummary(@PathVariable String sessionId) {
|
||||
try {
|
||||
// 异步执行总结生成任务,立即返回成功响应
|
||||
new Thread(() -> {
|
||||
try {
|
||||
agentChatSummaryService.generateAndSaveChatSummary(sessionId);
|
||||
System.out.println("异步执行会话 " + sessionId + " 的聊天记录总结完成");
|
||||
} catch (Exception e) {
|
||||
System.err.println("异步执行会话 " + sessionId + " 的聊天记录总结失败: " + e.getMessage());
|
||||
}
|
||||
}).start();
|
||||
|
||||
// 立即返回成功响应,不等待总结生成完成
|
||||
return new Result<Void>().ok(null);
|
||||
} catch (Exception e) {
|
||||
return new Result<Void>().error("启动异步总结生成任务失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新智能体")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
@@ -135,6 +160,8 @@ public class AgentController {
|
||||
agentChatHistoryService.deleteByAgentId(id, true, true);
|
||||
// 删除关联的插件
|
||||
agentPluginMappingService.deleteByAgentId(id);
|
||||
// 删除关联的上下文源配置
|
||||
agentContextProviderService.deleteByAgentId(id);
|
||||
// 再删除智能体
|
||||
agentService.deleteById(id);
|
||||
return new Result<>();
|
||||
@@ -182,6 +209,7 @@ public class AgentController {
|
||||
List<AgentChatHistoryDTO> result = agentChatHistoryService.getChatHistoryBySessionId(id, sessionId);
|
||||
return new Result<List<AgentChatHistoryDTO>>().ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/chat-history/user")
|
||||
@Operation(summary = "获取智能体聊天记录(用户)")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package xiaozhi.modules.agent.dao;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import xiaozhi.common.dao.BaseDao;
|
||||
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
|
||||
|
||||
@Mapper
|
||||
public interface AgentContextProviderDao extends BaseDao<AgentContextProviderEntity> {
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package xiaozhi.modules.agent.dao;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
@@ -15,12 +18,6 @@ import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
||||
*/
|
||||
@Mapper
|
||||
public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity> {
|
||||
/**
|
||||
* 根据智能体ID删除音频
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
void deleteAudioByAgentId(String agentId);
|
||||
|
||||
/**
|
||||
* 根据智能体ID删除聊天历史记录
|
||||
@@ -35,4 +32,19 @@ public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
void deleteAudioIdByAgentId(String agentId);
|
||||
|
||||
/**
|
||||
* 根据智能体ID获取所有音频ID列表
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
* @return 音频ID列表
|
||||
*/
|
||||
List<String> getAudioIdsByAgentId(String agentId);
|
||||
|
||||
/**
|
||||
* 批量删除音频
|
||||
*
|
||||
* @param audioIds 音频ID列表
|
||||
*/
|
||||
void deleteAudioByIds(@Param("audioIds") List<String> audioIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package xiaozhi.modules.agent.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 智能体聊天记录总结DTO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "智能体聊天记录总结对象")
|
||||
public class AgentChatSummaryDTO {
|
||||
|
||||
@Schema(description = "会话ID")
|
||||
private String sessionId;
|
||||
|
||||
@Schema(description = "智能体ID")
|
||||
private String agentId;
|
||||
|
||||
@Schema(description = "总结内容")
|
||||
private String summary;
|
||||
|
||||
@Schema(description = "总结状态")
|
||||
private boolean success;
|
||||
|
||||
@Schema(description = "错误信息")
|
||||
private String errorMessage;
|
||||
|
||||
public AgentChatSummaryDTO() {
|
||||
this.success = true;
|
||||
}
|
||||
|
||||
public AgentChatSummaryDTO(String sessionId, String agentId, String summary) {
|
||||
this.sessionId = sessionId;
|
||||
this.agentId = agentId;
|
||||
this.summary = summary;
|
||||
this.success = true;
|
||||
}
|
||||
|
||||
public AgentChatSummaryDTO(String sessionId, String errorMessage) {
|
||||
this.sessionId = sessionId;
|
||||
this.errorMessage = errorMessage;
|
||||
this.success = false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -69,6 +69,9 @@ public class AgentUpdateDTO implements Serializable {
|
||||
@Schema(description = "排序", example = "1", nullable = true)
|
||||
private Integer sort;
|
||||
|
||||
@Schema(description = "上下文源配置", nullable = true)
|
||||
private List<ContextProviderDTO> contextProviders;
|
||||
|
||||
@Data
|
||||
@Schema(description = "插件函数信息")
|
||||
public static class FunctionInfo implements Serializable {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package xiaozhi.modules.agent.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "上下文源配置DTO")
|
||||
public class ContextProviderDTO implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "URL地址")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "请求头")
|
||||
private Map<String, Object> headers;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package xiaozhi.modules.agent.entity;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import xiaozhi.modules.agent.dto.ContextProviderDTO;
|
||||
|
||||
@Data
|
||||
@TableName(value = "ai_agent_context_provider", autoResultMap = true)
|
||||
@Schema(description = "智能体上下文源配置")
|
||||
public class AgentContextProviderEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_UUID)
|
||||
@Schema(description = "主键")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "智能体ID")
|
||||
private String agentId;
|
||||
|
||||
@Schema(description = "上下文源配置")
|
||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||
private List<ContextProviderDTO> contextProviders;
|
||||
|
||||
@Schema(description = "创建者")
|
||||
private Long creator;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private Date createdAt;
|
||||
|
||||
@Schema(description = "更新者")
|
||||
private Long updater;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private Date updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package xiaozhi.modules.agent.service;
|
||||
|
||||
/**
|
||||
* 智能体聊天记录总结服务接口
|
||||
*/
|
||||
public interface AgentChatSummaryService {
|
||||
|
||||
/**
|
||||
* 根据会话ID生成聊天记录总结并保存到智能体记忆
|
||||
*
|
||||
* @param sessionId 会话ID
|
||||
* @return 保存结果
|
||||
*/
|
||||
boolean generateAndSaveChatSummary(String sessionId);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package xiaozhi.modules.agent.service;
|
||||
|
||||
import xiaozhi.common.service.BaseService;
|
||||
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
|
||||
|
||||
public interface AgentContextProviderService extends BaseService<AgentContextProviderEntity> {
|
||||
/**
|
||||
* 根据智能体ID获取上下文源配置
|
||||
* @param agentId 智能体ID
|
||||
* @return 上下文源配置实体
|
||||
*/
|
||||
AgentContextProviderEntity getByAgentId(String agentId);
|
||||
|
||||
/**
|
||||
* 保存或更新上下文源配置
|
||||
* @param entity 实体
|
||||
*/
|
||||
void saveOrUpdateByAgentId(AgentContextProviderEntity entity);
|
||||
|
||||
/**
|
||||
* 根据智能体ID删除上下文源配置
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
void deleteByAgentId(String agentId);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||
import xiaozhi.modules.agent.service.AgentChatAudioService;
|
||||
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||
import xiaozhi.modules.agent.service.AgentChatSummaryService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
@@ -36,6 +37,7 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
|
||||
private final AgentService agentService;
|
||||
private final AgentChatHistoryService agentChatHistoryService;
|
||||
private final AgentChatAudioService agentChatAudioService;
|
||||
private final AgentChatSummaryService agentChatSummaryService;
|
||||
private final RedisUtils redisUtils;
|
||||
private final DeviceService deviceService;
|
||||
|
||||
@@ -50,7 +52,8 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
|
||||
public Boolean report(AgentChatHistoryReportDTO report) {
|
||||
String macAddress = report.getMacAddress();
|
||||
Byte chatType = report.getChatType();
|
||||
Long reportTimeMillis = null != report.getReportTime() ? report.getReportTime() * 1000 : System.currentTimeMillis();
|
||||
Long reportTimeMillis = null != report.getReportTime() ? report.getReportTime() * 1000
|
||||
: System.currentTimeMillis();
|
||||
log.info("小智设备聊天上报请求: macAddress={}, type={} reportTime={}", macAddress, chatType, reportTimeMillis);
|
||||
|
||||
// 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
|
||||
@@ -105,7 +108,8 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
|
||||
/**
|
||||
* 组装上报数据
|
||||
*/
|
||||
private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId, Long reportTime) {
|
||||
private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId,
|
||||
Long reportTime) {
|
||||
// 构建聊天记录实体
|
||||
AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
|
||||
.macAddress(macAddress)
|
||||
|
||||
@@ -84,7 +84,16 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
|
||||
if (deleteAudio) {
|
||||
baseMapper.deleteAudioByAgentId(agentId);
|
||||
// 分批删除音频,避免超时
|
||||
List<String> audioIds = baseMapper.getAudioIdsByAgentId(agentId);
|
||||
if (audioIds != null && !audioIds.isEmpty()) {
|
||||
int batchSize = 1000; // 每批删除1000条
|
||||
for (int i = 0; i < audioIds.size(); i += batchSize) {
|
||||
int end = Math.min(i + batchSize, audioIds.size());
|
||||
List<String> batch = audioIds.subList(i, end);
|
||||
baseMapper.deleteAudioByIds(batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (deleteAudio && !deleteText) {
|
||||
baseMapper.deleteAudioIdByAgentId(agentId);
|
||||
@@ -107,7 +116,7 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
|
||||
// 添加此行,确保查询结果按照创建时间降序排列
|
||||
// 使用id的原因:数据形式,id越大的创建时间就越晚,所以使用id的结果和创建时间降序排列结果一样
|
||||
// id作为降序排列的优势,性能高,有主键索引,不用在排序的时候重新进行排除扫描比较
|
||||
.orderByDesc(AgentChatHistoryEntity::getId);
|
||||
.orderByDesc(AgentChatHistoryEntity::getId);
|
||||
|
||||
// 构建分页查询,查询前50页数据
|
||||
Page<AgentChatHistoryEntity> pageParam = new Page<>(0, 50);
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package xiaozhi.modules.agent.service.impl;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentChatSummaryDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
||||
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||
import xiaozhi.modules.agent.service.AgentChatSummaryService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.vo.AgentInfoVO;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.modules.llm.service.LLMService;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
|
||||
/**
|
||||
* 智能体聊天记录总结服务实现类
|
||||
* 实现Python端mem_local_short.py中的总结逻辑
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgentChatSummaryServiceImpl implements AgentChatSummaryService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentChatSummaryServiceImpl.class);
|
||||
|
||||
private final AgentChatHistoryService agentChatHistoryService;
|
||||
private final AgentService agentService;
|
||||
private final DeviceService deviceService;
|
||||
private final LLMService llmService;
|
||||
private final ModelConfigService modelConfigService;
|
||||
|
||||
// 总结规则常量
|
||||
private static final int MAX_SUMMARY_LENGTH = 1800; // 最大总结长度
|
||||
private static final Pattern JSON_PATTERN = Pattern.compile("\\{.*?\\}", Pattern.DOTALL);
|
||||
private static final Pattern DEVICE_CONTROL_PATTERN = Pattern.compile("设备控制|设备操作|控制设备|设备状态",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern WEATHER_PATTERN = Pattern.compile("天气|温度|湿度|降雨|气象", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern DATE_PATTERN = Pattern.compile("日期|时间|星期|月份|年份", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private AgentChatSummaryDTO generateChatSummary(String sessionId) {
|
||||
try {
|
||||
System.out.println("开始生成会话 " + sessionId + " 的聊天记录总结");
|
||||
|
||||
// 1. 根据sessionId获取聊天记录
|
||||
List<AgentChatHistoryDTO> chatHistory = getChatHistoryBySessionId(sessionId);
|
||||
if (chatHistory == null || chatHistory.isEmpty()) {
|
||||
return new AgentChatSummaryDTO(sessionId, "未找到该会话的聊天记录");
|
||||
}
|
||||
|
||||
// 2. 获取智能体信息
|
||||
String agentId = getAgentIdFromSession(sessionId, chatHistory);
|
||||
if (StringUtils.isBlank(agentId)) {
|
||||
return new AgentChatSummaryDTO(sessionId, "无法获取智能体信息");
|
||||
}
|
||||
|
||||
// 3. 提取关键对话内容
|
||||
List<String> meaningfulMessages = extractMeaningfulMessages(chatHistory);
|
||||
if (meaningfulMessages.isEmpty()) {
|
||||
return new AgentChatSummaryDTO(sessionId, "没有有效的对话内容可总结");
|
||||
}
|
||||
|
||||
// 4. 生成总结(generateSummaryFromMessages方法已包含长度限制逻辑)
|
||||
String summary = generateSummaryFromMessages(meaningfulMessages, agentId);
|
||||
|
||||
System.out.println("成功生成会话 " + sessionId + " 的聊天记录总结,长度: " + summary.length() + " 字符");
|
||||
return new AgentChatSummaryDTO(sessionId, agentId, summary);
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("生成会话 " + sessionId + " 的聊天记录总结时发生错误: " + e.getMessage());
|
||||
return new AgentChatSummaryDTO(sessionId, "生成总结时发生错误: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean generateAndSaveChatSummary(String sessionId) {
|
||||
try {
|
||||
// 1. 生成总结
|
||||
AgentChatSummaryDTO summaryDTO = generateChatSummary(sessionId);
|
||||
if (!summaryDTO.isSuccess()) {
|
||||
System.err.println("生成总结失败: " + summaryDTO.getErrorMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 获取设备信息(通过会话关联的设备)
|
||||
DeviceEntity device = getDeviceBySessionId(sessionId);
|
||||
if (device == null) {
|
||||
System.err.println("未找到与会话 " + sessionId + " 关联的设备");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 更新智能体记忆
|
||||
AgentMemoryDTO memoryDTO = new AgentMemoryDTO();
|
||||
memoryDTO.setSummaryMemory(summaryDTO.getSummary());
|
||||
|
||||
// 调用现有接口更新记忆
|
||||
agentService.updateAgentById(device.getAgentId(),
|
||||
new AgentUpdateDTO() {
|
||||
{
|
||||
setSummaryMemory(summaryDTO.getSummary());
|
||||
}
|
||||
});
|
||||
|
||||
System.out.println("成功保存会话 " + sessionId + " 的聊天记录总结到智能体 " + device.getAgentId());
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("保存会话 " + sessionId + " 的聊天记录总结时发生错误: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据会话ID获取聊天记录
|
||||
*/
|
||||
private List<AgentChatHistoryDTO> getChatHistoryBySessionId(String sessionId) {
|
||||
try {
|
||||
// 这里需要根据sessionId获取聊天记录
|
||||
// 由于现有接口需要agentId,我们需要先找到关联的agentId
|
||||
String agentId = findAgentIdBySessionId(sessionId);
|
||||
if (StringUtils.isBlank(agentId)) {
|
||||
return null;
|
||||
}
|
||||
return agentChatHistoryService.getChatHistoryBySessionId(agentId, sessionId);
|
||||
} catch (Exception e) {
|
||||
System.err.println("获取会话 " + sessionId + " 的聊天记录失败: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据会话ID查找关联的智能体ID
|
||||
*/
|
||||
private String findAgentIdBySessionId(String sessionId) {
|
||||
try {
|
||||
// 查询该会话的第一条记录获取agentId
|
||||
QueryWrapper<AgentChatHistoryEntity> wrapper = new QueryWrapper<>();
|
||||
wrapper.select("agent_id")
|
||||
.eq("session_id", sessionId)
|
||||
.last("LIMIT 1");
|
||||
|
||||
AgentChatHistoryEntity entity = agentChatHistoryService.getOne(wrapper);
|
||||
return entity != null ? entity.getAgentId() : null;
|
||||
} catch (Exception e) {
|
||||
System.err.println("根据会话ID " + sessionId + " 查找智能体ID失败: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从会话中获取智能体ID
|
||||
*/
|
||||
private String getAgentIdFromSession(String sessionId, List<AgentChatHistoryDTO> chatHistory) {
|
||||
// 直接从数据库查询智能体ID
|
||||
return findAgentIdBySessionId(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取有意义的对话内容(只提取用户消息,排除AI回复)
|
||||
*/
|
||||
private List<String> extractMeaningfulMessages(List<AgentChatHistoryDTO> chatHistory) {
|
||||
List<String> meaningfulMessages = new ArrayList<>();
|
||||
|
||||
for (AgentChatHistoryDTO message : chatHistory) {
|
||||
// 只处理用户消息(chatType = 1)
|
||||
if (message.getChatType() != null && message.getChatType() == 1) {
|
||||
String content = extractContentFromMessage(message);
|
||||
if (isMeaningfulMessage(content)) {
|
||||
meaningfulMessages.add(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return meaningfulMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从消息中提取内容(处理JSON格式)
|
||||
*/
|
||||
private String extractContentFromMessage(AgentChatHistoryDTO message) {
|
||||
String content = message.getContent();
|
||||
if (StringUtils.isBlank(content)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 处理JSON格式内容(与前端ChatHistoryDialog.vue逻辑一致)
|
||||
Matcher matcher = JSON_PATTERN.matcher(content);
|
||||
if (matcher.find()) {
|
||||
String jsonContent = matcher.group();
|
||||
// 简化处理:提取JSON中的文本内容
|
||||
return extractTextFromJson(jsonContent);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JSON中提取文本内容
|
||||
*/
|
||||
private String extractTextFromJson(String jsonContent) {
|
||||
// 简化处理:提取"content"字段的值
|
||||
Pattern contentPattern = Pattern.compile("\"content\"\s*:\s*\"([^\"]*)\"");
|
||||
Matcher matcher = contentPattern.matcher(jsonContent);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return jsonContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为有意义的消息
|
||||
*/
|
||||
private boolean isMeaningfulMessage(String content) {
|
||||
if (StringUtils.isBlank(content)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 排除设备控制信息
|
||||
if (DEVICE_CONTROL_PATTERN.matcher(content).find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 排除日期天气等无关内容
|
||||
if (WEATHER_PATTERN.matcher(content).find() || DATE_PATTERN.matcher(content).find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 排除过短的消息
|
||||
return content.length() >= 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从消息生成总结
|
||||
*/
|
||||
private String generateSummaryFromMessages(List<String> messages, String agentId) {
|
||||
if (messages.isEmpty()) {
|
||||
return "本次对话内容较少,没有需要总结的重要信息。";
|
||||
}
|
||||
|
||||
// 构建完整的对话内容
|
||||
StringBuilder conversation = new StringBuilder();
|
||||
for (int i = 0; i < messages.size(); i++) {
|
||||
conversation.append("消息").append(i + 1).append(": ").append(messages.get(i)).append("\n");
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取当前智能体的历史记忆
|
||||
String historyMemory = getCurrentAgentMemory(agentId);
|
||||
|
||||
// 调用LLM服务进行智能总结,传递agentId以获取正确的模型配置
|
||||
String summary = callJavaLLMForSummaryWithHistory(conversation.toString(), historyMemory, agentId);
|
||||
|
||||
// 应用总结规则:限制最大长度
|
||||
if (summary.length() > MAX_SUMMARY_LENGTH) {
|
||||
summary = summary.substring(0, MAX_SUMMARY_LENGTH) + "...";
|
||||
}
|
||||
|
||||
return summary;
|
||||
} catch (Exception e) {
|
||||
System.err.println("调用Java端LLM服务失败: " + e.getMessage());
|
||||
throw new RuntimeException("LLM服务不可用,无法生成聊天总结");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前智能体的历史记忆
|
||||
*/
|
||||
private String getCurrentAgentMemory(String agentId) {
|
||||
try {
|
||||
if (StringUtils.isBlank(agentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取智能体信息
|
||||
AgentInfoVO agentInfo = agentService.getAgentById(agentId);
|
||||
if (agentInfo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 返回智能体的当前总结记忆
|
||||
return agentInfo.getSummaryMemory();
|
||||
} catch (Exception e) {
|
||||
System.err.println("获取智能体历史记忆失败,agentId: " + agentId + ", 错误: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用Java端LLM服务进行智能总结(支持历史记忆合并)
|
||||
*/
|
||||
private String callJavaLLMForSummaryWithHistory(String conversation, String historyMemory, String agentId) {
|
||||
try {
|
||||
// 获取智能体配置,从中提取记忆总结的模型ID
|
||||
String modelId = getMemorySummaryModelId(agentId);
|
||||
|
||||
if (StringUtils.isBlank(modelId)) {
|
||||
System.out.println("未找到记忆总结的LLM模型配置,使用默认LLM服务");
|
||||
return llmService.generateSummaryWithHistory(conversation, historyMemory, null, null);
|
||||
}
|
||||
|
||||
// 使用指定的模型ID调用LLM服务(支持历史记忆合并)
|
||||
String summary = llmService.generateSummaryWithHistory(conversation, historyMemory, null, modelId);
|
||||
|
||||
if (StringUtils.isNotBlank(summary) && !summary.equals("服务暂不可用") && !summary.equals("总结生成失败")) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
throw new RuntimeException("Java端LLM服务返回异常: " + summary);
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("调用Java端LLM服务异常,agentId: " + agentId + ", 错误: " + e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用Java端LLM服务进行智能总结
|
||||
*/
|
||||
private String callJavaLLMForSummary(String conversation, String agentId) {
|
||||
try {
|
||||
// 获取智能体配置,从中提取记忆总结的模型ID
|
||||
String modelId = getMemorySummaryModelId(agentId);
|
||||
|
||||
if (StringUtils.isBlank(modelId)) {
|
||||
System.out.println("未找到记忆总结的LLM模型配置,使用默认LLM服务");
|
||||
return llmService.generateSummary(conversation);
|
||||
}
|
||||
|
||||
// 使用指定的模型ID调用LLM服务
|
||||
String summary = llmService.generateSummaryWithModel(conversation, modelId);
|
||||
|
||||
if (StringUtils.isNotBlank(summary) && !summary.equals("服务暂不可用") && !summary.equals("总结生成失败")) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
throw new RuntimeException("Java端LLM服务返回异常: " + summary);
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("调用Java端LLM服务异常,agentId: " + agentId + ", 错误: " + e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记忆总结的LLM模型ID
|
||||
*/
|
||||
private String getMemorySummaryModelId(String agentId) {
|
||||
try {
|
||||
if (StringUtils.isBlank(agentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取智能体信息
|
||||
AgentInfoVO agentInfo = agentService.getAgentById(agentId);
|
||||
if (agentInfo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取智能体的记忆模型ID
|
||||
String memModelId = agentInfo.getMemModelId();
|
||||
if (StringUtils.isBlank(memModelId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取记忆模型配置
|
||||
ModelConfigEntity memModelConfig = modelConfigService.getModelByIdFromCache(memModelId);
|
||||
if (memModelConfig == null || memModelConfig.getConfigJson() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 从记忆模型配置中提取对应的LLM模型ID
|
||||
Map<String, Object> configMap = memModelConfig.getConfigJson();
|
||||
String llmModelId = (String) configMap.get("llm");
|
||||
|
||||
if (StringUtils.isBlank(llmModelId)) {
|
||||
// 如果记忆模型没有配置独立的LLM,则使用智能体的默认LLM模型
|
||||
return agentInfo.getLlmModelId();
|
||||
}
|
||||
|
||||
return llmModelId;
|
||||
} catch (Exception e) {
|
||||
System.err.println("获取记忆总结LLM模型ID失败,agentId: " + agentId + ", 错误: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据会话ID获取设备信息
|
||||
*/
|
||||
private DeviceEntity getDeviceBySessionId(String sessionId) {
|
||||
try {
|
||||
// 查询该会话的第一条记录获取macAddress
|
||||
QueryWrapper<AgentChatHistoryEntity> wrapper = new QueryWrapper<>();
|
||||
wrapper.select("mac_address")
|
||||
.eq("session_id", sessionId)
|
||||
.last("LIMIT 1");
|
||||
|
||||
AgentChatHistoryEntity entity = agentChatHistoryService.getOne(wrapper);
|
||||
if (entity != null && StringUtils.isNotBlank(entity.getMacAddress())) {
|
||||
return deviceService.getDeviceByMacAddress(entity.getMacAddress());
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
System.err.println("根据会话ID " + sessionId + " 查找设备信息失败: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package xiaozhi.modules.agent.service.impl;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.modules.agent.dao.AgentContextProviderDao;
|
||||
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
|
||||
import xiaozhi.modules.agent.service.AgentContextProviderService;
|
||||
|
||||
@Service
|
||||
public class AgentContextProviderServiceImpl extends BaseServiceImpl<AgentContextProviderDao, AgentContextProviderEntity> implements AgentContextProviderService {
|
||||
|
||||
@Override
|
||||
public AgentContextProviderEntity getByAgentId(String agentId) {
|
||||
return baseDao.selectOne(new QueryWrapper<AgentContextProviderEntity>().eq("agent_id", agentId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveOrUpdateByAgentId(AgentContextProviderEntity entity) {
|
||||
AgentContextProviderEntity exist = getByAgentId(entity.getAgentId());
|
||||
if (exist != null) {
|
||||
entity.setId(exist.getId());
|
||||
updateById(entity);
|
||||
} else {
|
||||
insert(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteByAgentId(String agentId) {
|
||||
baseDao.delete(new QueryWrapper<AgentContextProviderEntity>().eq("agent_id", agentId));
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,12 @@ import xiaozhi.modules.agent.dao.AgentDao;
|
||||
import xiaozhi.modules.agent.dto.AgentCreateDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
|
||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||
import xiaozhi.modules.agent.entity.AgentPluginMapping;
|
||||
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
||||
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||
import xiaozhi.modules.agent.service.AgentContextProviderService;
|
||||
import xiaozhi.modules.agent.service.AgentPluginMappingService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
@@ -62,6 +64,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
private final AgentChatHistoryService agentChatHistoryService;
|
||||
private final AgentTemplateService agentTemplateService;
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final AgentContextProviderService agentContextProviderService;
|
||||
|
||||
@Override
|
||||
public PageData<AgentEntity> adminAgentList(Map<String, Object> params) {
|
||||
@@ -85,6 +88,13 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
// 查询上下文源配置
|
||||
AgentContextProviderEntity contextProviderEntity = agentContextProviderService.getByAgentId(id);
|
||||
if (contextProviderEntity != null) {
|
||||
agent.setContextProviders(contextProviderEntity.getContextProviders());
|
||||
}
|
||||
|
||||
// 无需额外查询插件列表,已通过SQL查询出来
|
||||
return agent;
|
||||
}
|
||||
@@ -331,6 +341,14 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, false);
|
||||
}
|
||||
|
||||
// 更新上下文源配置
|
||||
if (dto.getContextProviders() != null) {
|
||||
AgentContextProviderEntity contextEntity = new AgentContextProviderEntity();
|
||||
contextEntity.setAgentId(agentId);
|
||||
contextEntity.setContextProviders(dto.getContextProviders());
|
||||
agentContextProviderService.saveOrUpdateByAgentId(contextEntity);
|
||||
}
|
||||
|
||||
boolean b = validateLLMIntentParams(dto.getLlmModelId(), dto.getIntentModelId());
|
||||
if (!b) {
|
||||
throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH);
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import xiaozhi.modules.agent.dto.ContextProviderDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||
import xiaozhi.modules.agent.entity.AgentPluginMapping;
|
||||
|
||||
@@ -21,4 +22,7 @@ public class AgentInfoVO extends AgentEntity
|
||||
@Schema(description = "插件列表Id")
|
||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||
private List<AgentPluginMapping> functions;
|
||||
|
||||
@Schema(description = "上下文源配置")
|
||||
private List<ContextProviderDTO> contextProviders;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,12 @@ import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.modules.agent.dao.AgentVoicePrintDao;
|
||||
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
|
||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||
import xiaozhi.modules.agent.entity.AgentPluginMapping;
|
||||
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
||||
import xiaozhi.modules.agent.entity.AgentVoicePrintEntity;
|
||||
import xiaozhi.modules.agent.service.AgentContextProviderService;
|
||||
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
|
||||
import xiaozhi.modules.agent.service.AgentPluginMappingService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
@@ -53,6 +55,7 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
private final TimbreService timbreService;
|
||||
private final AgentPluginMappingService agentPluginMappingService;
|
||||
private final AgentMcpAccessPointService agentMcpAccessPointService;
|
||||
private final AgentContextProviderService agentContextProviderService;
|
||||
private final VoiceCloneService cloneVoiceService;
|
||||
private final AgentVoicePrintDao agentVoicePrintDao;
|
||||
|
||||
@@ -103,6 +106,15 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getAgentModels(String macAddress, Map<String, String> selectedModule) {
|
||||
// 检查是否为管理控制台请求
|
||||
String redisKey = RedisKeys.getTmpRegisterMacKey(macAddress);
|
||||
Object isAdminRequest = redisUtils.get(redisKey);
|
||||
|
||||
if (isAdminRequest != null && "true".equals(isAdminRequest)) {
|
||||
// 管理控制台请求,返回getConfig的结果
|
||||
redisUtils.delete(redisKey); // 使用后清理
|
||||
return (Map<String, Object>) getConfig(true);
|
||||
}
|
||||
// 根据MAC地址查找设备
|
||||
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
|
||||
if (device == null) {
|
||||
@@ -178,6 +190,13 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
mcpEndpoint = mcpEndpoint.replace("/mcp/", "/call/");
|
||||
result.put("mcp_endpoint", mcpEndpoint);
|
||||
}
|
||||
|
||||
// 获取上下文源配置
|
||||
AgentContextProviderEntity contextProviderEntity = agentContextProviderService.getByAgentId(agent.getId());
|
||||
if (contextProviderEntity != null && contextProviderEntity.getContextProviders() != null && !contextProviderEntity.getContextProviders().isEmpty()) {
|
||||
result.put("context_providers", contextProviderEntity.getContextProviders());
|
||||
}
|
||||
|
||||
// 获取声纹信息
|
||||
buildVoiceprintConfig(agent.getId(), result);
|
||||
|
||||
|
||||
@@ -72,10 +72,12 @@ public class DeviceController {
|
||||
return new Result<String>().error(ErrorCode.MCA_NOT_NULL);
|
||||
}
|
||||
// 生成六位验证码
|
||||
String code = String.valueOf(Math.random()).substring(2, 8);
|
||||
String key = RedisKeys.getDeviceCaptchaKey(code);
|
||||
String code;
|
||||
String key;
|
||||
String existsMac = null;
|
||||
do {
|
||||
code = String.valueOf(Math.random()).substring(2, 8);
|
||||
key = RedisKeys.getDeviceCaptchaKey(code);
|
||||
existsMac = (String) redisUtils.get(key);
|
||||
} while (StringUtils.isNotBlank(existsMac));
|
||||
|
||||
|
||||
@@ -98,4 +98,14 @@ public interface DeviceService extends BaseService<DeviceEntity> {
|
||||
*/
|
||||
void updateDeviceConnectionInfo(String agentId, String deviceId, String appVersion);
|
||||
|
||||
/**
|
||||
* 生成WebSocket认证token
|
||||
*
|
||||
* @param clientId 客户端ID
|
||||
* @param username 用户名(通常为deviceId)
|
||||
* @return 认证token字符串
|
||||
* @throws Exception 生成token时的异常
|
||||
*/
|
||||
String generateWebSocketToken(String clientId, String username) throws Exception;
|
||||
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
@@ -169,7 +171,22 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
|
||||
// 从系统参数获取WebSocket URL,如果未配置则使用默认值
|
||||
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||
websocket.setToken("");
|
||||
|
||||
// 检查是否启用认证并生成token
|
||||
String authEnabled = sysParamsService.getValue(Constant.SERVER_AUTH_ENABLED, true);
|
||||
if ("true".equalsIgnoreCase(authEnabled)) {
|
||||
try {
|
||||
// 生成token
|
||||
String token = generateWebSocketToken(clientId, macAddress);
|
||||
websocket.setToken(token);
|
||||
} catch (Exception e) {
|
||||
log.error("生成WebSocket token失败: {}", e.getMessage());
|
||||
websocket.setToken("");
|
||||
}
|
||||
} else {
|
||||
websocket.setToken("");
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
||||
log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
|
||||
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
|
||||
@@ -189,7 +206,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
|
||||
// 添加MQTT UDP配置
|
||||
// 从系统参数获取MQTT Gateway地址,仅在配置有效时使用
|
||||
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, false);
|
||||
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true);
|
||||
if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
|
||||
try {
|
||||
String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard()
|
||||
@@ -494,6 +511,40 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
return Base64.getEncoder().encodeToString(signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成WebSocket认证token 遵循Python端AuthManager的实现逻辑:token = signature.timestamp
|
||||
*
|
||||
* @param clientId 客户端ID
|
||||
* @param username 用户名 (通常为deviceId/macAddress)
|
||||
* @return 认证token字符串
|
||||
*/
|
||||
public String generateWebSocketToken(String clientId, String username)
|
||||
throws NoSuchAlgorithmException, InvalidKeyException {
|
||||
// 从系统参数获取密钥
|
||||
String secretKey = sysParamsService.getValue(Constant.SERVER_SECRET, false);
|
||||
if (StringUtils.isBlank(secretKey)) {
|
||||
throw new IllegalStateException("WebSocket认证密钥未配置(server.secret)");
|
||||
}
|
||||
|
||||
// 获取当前时间戳(秒)
|
||||
long timestamp = System.currentTimeMillis() / 1000;
|
||||
|
||||
// 构建签名内容: clientId|username|timestamp
|
||||
String content = String.format("%s|%s|%d", clientId, username, timestamp);
|
||||
|
||||
// 生成HMAC-SHA256签名
|
||||
Mac hmac = Mac.getInstance("HmacSHA256");
|
||||
SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
hmac.init(keySpec);
|
||||
byte[] signature = hmac.doFinal(content.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// Base64 URL-safe编码签名(去除填充符=)
|
||||
String signatureBase64 = Base64.getUrlEncoder().withoutPadding().encodeToString(signature);
|
||||
|
||||
// 返回格式: signature.timestamp
|
||||
return String.format("%s.%d", signatureBase64, timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建MQTT配置信息
|
||||
*
|
||||
@@ -504,7 +555,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
private DeviceReportRespDTO.MQTT buildMqttConfig(String macAddress, String groupId)
|
||||
throws Exception {
|
||||
// 从环境变量或系统参数获取签名密钥
|
||||
String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", false);
|
||||
String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", true);
|
||||
if (StringUtils.isBlank(signatureKey)) {
|
||||
log.warn("缺少MQTT_SIGNATURE_KEY,跳过MQTT配置生成");
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package xiaozhi.modules.llm.service;
|
||||
|
||||
/**
|
||||
* LLM服务接口
|
||||
* 支持多种大模型调用
|
||||
*/
|
||||
public interface LLMService {
|
||||
|
||||
/**
|
||||
* 生成聊天记录总结
|
||||
*
|
||||
* @param conversation 对话内容
|
||||
* @param promptTemplate 提示词模板
|
||||
* @return 总结结果
|
||||
*/
|
||||
String generateSummary(String conversation, String promptTemplate);
|
||||
|
||||
/**
|
||||
* 生成聊天记录总结(使用默认提示词)
|
||||
*
|
||||
* @param conversation 对话内容
|
||||
* @return 总结结果
|
||||
*/
|
||||
String generateSummary(String conversation);
|
||||
|
||||
/**
|
||||
* 生成聊天记录总结(指定模型ID)
|
||||
*
|
||||
* @param conversation 对话内容
|
||||
* @param modelId 模型ID
|
||||
* @return 总结结果
|
||||
*/
|
||||
String generateSummaryWithModel(String conversation, String modelId);
|
||||
|
||||
/**
|
||||
* 生成聊天记录总结(指定模型ID和提示词模板)
|
||||
*
|
||||
* @param conversation 对话内容
|
||||
* @param promptTemplate 提示词模板
|
||||
* @param modelId 模型ID
|
||||
* @return 总结结果
|
||||
*/
|
||||
String generateSummary(String conversation, String promptTemplate, String modelId);
|
||||
|
||||
/**
|
||||
* 生成聊天记录总结(包含历史记忆合并)
|
||||
*
|
||||
* @param conversation 对话内容
|
||||
* @param historyMemory 历史记忆
|
||||
* @param promptTemplate 提示词模板
|
||||
* @param modelId 模型ID
|
||||
* @return 总结结果
|
||||
*/
|
||||
String generateSummaryWithHistory(String conversation, String historyMemory, String promptTemplate, String modelId);
|
||||
|
||||
/**
|
||||
* 检查服务是否可用
|
||||
*
|
||||
* @return 是否可用
|
||||
*/
|
||||
boolean isAvailable();
|
||||
|
||||
/**
|
||||
* 检查指定模型的服务是否可用
|
||||
*
|
||||
* @param modelId 模型ID
|
||||
* @return 是否可用
|
||||
*/
|
||||
boolean isAvailable(String modelId);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package xiaozhi.modules.llm.service.impl;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.modules.llm.service.LLMService;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
|
||||
/**
|
||||
* OpenAI风格API的LLM服务实现
|
||||
* 支持阿里云、DeepSeek、ChatGLM等兼容OpenAI API的模型
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class OpenAIStyleLLMServiceImpl implements LLMService {
|
||||
|
||||
@Autowired
|
||||
private ModelConfigService modelConfigService;
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
private static final String DEFAULT_SUMMARY_PROMPT = "你是一个经验丰富的记忆总结者,擅长将对话内容进行总结摘要,遵循以下规则:\n1、总结用户的重要信息,以便在未来的对话中提供更个性化的服务\n2、不要重复总结,不要遗忘之前记忆,除非原来的记忆超过了1800字,否则不要遗忘、不要压缩用户的历史记忆\n3、用户操控的设备音量、播放音乐、天气、退出、不想对话等和用户本身无关的内容,这些信息不需要加入到总结中\n4、聊天内容中的今天的日期时间、今天的天气情况与用户事件无关的数据,这些信息如果当成记忆存储会影响后续对话,这些信息不需要加入到总结中\n5、不要把设备操控的成果结果和失败结果加入到总结中,也不要把用户的一些废话加入到总结中\n6、不要为了总结而总结,如果用户的聊天没有意义,请返回原来的历史记录也是可以的\n7、只需要返回总结摘要,严格控制在1800字内\n8、不要包含代码、xml,不需要解释、注释和说明,保存记忆时仅从对话提取信息,不要混入示例内容\n9、如果提供了历史记忆,请将新对话内容与历史记忆进行智能合并,保留有价值的历史信息,同时添加新的重要信息\n\n历史记忆:\n{history_memory}\n\n新对话内容:\n{conversation}";
|
||||
|
||||
@Override
|
||||
public String generateSummary(String conversation) {
|
||||
return generateSummary(conversation, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateSummaryWithModel(String conversation, String modelId) {
|
||||
return generateSummary(conversation, null, modelId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateSummary(String conversation, String promptTemplate, String modelId) {
|
||||
if (!isAvailable()) {
|
||||
log.warn("LLM服务不可用,无法生成总结");
|
||||
return "LLM服务不可用,无法生成总结";
|
||||
}
|
||||
|
||||
try {
|
||||
// 从智控台获取LLM模型配置
|
||||
ModelConfigEntity llmConfig;
|
||||
if (modelId != null && !modelId.trim().isEmpty()) {
|
||||
// 通过具体模型ID获取配置
|
||||
llmConfig = modelConfigService.getModelByIdFromCache(modelId);
|
||||
} else {
|
||||
// 保持向后兼容,使用默认配置
|
||||
llmConfig = getDefaultLLMConfig();
|
||||
}
|
||||
|
||||
if (llmConfig == null || llmConfig.getConfigJson() == null) {
|
||||
log.error("未找到可用的LLM模型配置,modelId: {}", modelId);
|
||||
return "未找到可用的LLM模型配置";
|
||||
}
|
||||
|
||||
JSONObject configJson = llmConfig.getConfigJson();
|
||||
String baseUrl = configJson.getStr("base_url");
|
||||
String model = configJson.getStr("model_name");
|
||||
String apiKey = configJson.getStr("api_key");
|
||||
Double temperature = configJson.getDouble("temperature");
|
||||
Integer maxTokens = configJson.getInt("max_tokens");
|
||||
|
||||
if (StringUtils.isBlank(baseUrl) || StringUtils.isBlank(apiKey)) {
|
||||
log.error("LLM配置不完整,baseUrl或apiKey为空");
|
||||
return "LLM配置不完整,无法生成总结";
|
||||
}
|
||||
|
||||
// 构建提示词
|
||||
String prompt = (promptTemplate != null ? promptTemplate : DEFAULT_SUMMARY_PROMPT).replace("{conversation}",
|
||||
conversation);
|
||||
|
||||
// 构建请求体
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
|
||||
|
||||
Map<String, Object>[] messages = new Map[1];
|
||||
Map<String, Object> message = new HashMap<>();
|
||||
message.put("role", "user");
|
||||
message.put("content", prompt);
|
||||
messages[0] = message;
|
||||
|
||||
requestBody.put("messages", messages);
|
||||
requestBody.put("temperature", temperature != null ? temperature : 0.7);
|
||||
requestBody.put("max_tokens", maxTokens != null ? maxTokens : 2000);
|
||||
|
||||
// 发送HTTP请求
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Authorization", "Bearer " + apiKey);
|
||||
|
||||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
|
||||
|
||||
// 构建完整的API URL
|
||||
String apiUrl = baseUrl;
|
||||
if (!apiUrl.endsWith("/chat/completions")) {
|
||||
if (!apiUrl.endsWith("/")) {
|
||||
apiUrl += "/";
|
||||
}
|
||||
apiUrl += "chat/completions";
|
||||
}
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
apiUrl, HttpMethod.POST, entity, String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
JSONObject responseJson = JSONUtil.parseObj(response.getBody());
|
||||
JSONArray choices = responseJson.getJSONArray("choices");
|
||||
if (choices != null && choices.size() > 0) {
|
||||
JSONObject choice = choices.getJSONObject(0);
|
||||
JSONObject messageObj = choice.getJSONObject("message");
|
||||
return messageObj.getStr("content");
|
||||
}
|
||||
} else {
|
||||
log.error("LLM API调用失败,状态码:{},响应:{}", response.getStatusCode(), response.getBody());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("调用LLM服务生成总结时发生异常,modelId: {}", modelId, e);
|
||||
}
|
||||
|
||||
return "生成总结失败,请稍后重试";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateSummary(String conversation, String promptTemplate) {
|
||||
return generateSummary(conversation, promptTemplate, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateSummaryWithHistory(String conversation, String historyMemory, String promptTemplate,
|
||||
String modelId) {
|
||||
if (!isAvailable()) {
|
||||
log.warn("LLM服务不可用,无法生成总结");
|
||||
return "LLM服务不可用,无法生成总结";
|
||||
}
|
||||
|
||||
try {
|
||||
// 从智控台获取LLM模型配置
|
||||
ModelConfigEntity llmConfig;
|
||||
if (modelId != null && !modelId.trim().isEmpty()) {
|
||||
// 通过具体模型ID获取配置
|
||||
llmConfig = modelConfigService.getModelByIdFromCache(modelId);
|
||||
} else {
|
||||
// 保持向后兼容,使用默认配置
|
||||
llmConfig = getDefaultLLMConfig();
|
||||
}
|
||||
|
||||
if (llmConfig == null || llmConfig.getConfigJson() == null) {
|
||||
log.error("未找到可用的LLM模型配置,modelId: {}", modelId);
|
||||
return "未找到可用的LLM模型配置";
|
||||
}
|
||||
|
||||
JSONObject configJson = llmConfig.getConfigJson();
|
||||
String baseUrl = configJson.getStr("base_url");
|
||||
String model = configJson.getStr("model_name");
|
||||
String apiKey = configJson.getStr("api_key");
|
||||
|
||||
if (StringUtils.isBlank(baseUrl) || StringUtils.isBlank(apiKey)) {
|
||||
log.error("LLM配置不完整,baseUrl或apiKey为空");
|
||||
return "LLM配置不完整,无法生成总结";
|
||||
}
|
||||
|
||||
// 构建提示词,包含历史记忆
|
||||
String prompt = (promptTemplate != null ? promptTemplate : DEFAULT_SUMMARY_PROMPT)
|
||||
.replace("{history_memory}", historyMemory != null ? historyMemory : "无历史记忆")
|
||||
.replace("{conversation}", conversation);
|
||||
|
||||
// 构建请求体
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
|
||||
|
||||
Map<String, Object>[] messages = new Map[1];
|
||||
Map<String, Object> message = new HashMap<>();
|
||||
message.put("role", "user");
|
||||
message.put("content", prompt);
|
||||
messages[0] = message;
|
||||
|
||||
requestBody.put("messages", messages);
|
||||
requestBody.put("temperature", 0.2);
|
||||
requestBody.put("max_tokens", 2000);
|
||||
|
||||
// 发送HTTP请求
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Authorization", "Bearer " + apiKey);
|
||||
|
||||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
|
||||
|
||||
// 构建完整的API URL
|
||||
String apiUrl = baseUrl;
|
||||
if (!apiUrl.endsWith("/chat/completions")) {
|
||||
if (!apiUrl.endsWith("/")) {
|
||||
apiUrl += "/";
|
||||
}
|
||||
apiUrl += "chat/completions";
|
||||
}
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
apiUrl, HttpMethod.POST, entity, String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
JSONObject responseJson = JSONUtil.parseObj(response.getBody());
|
||||
JSONArray choices = responseJson.getJSONArray("choices");
|
||||
if (choices != null && choices.size() > 0) {
|
||||
JSONObject choice = choices.getJSONObject(0);
|
||||
JSONObject messageObj = choice.getJSONObject("message");
|
||||
return messageObj.getStr("content");
|
||||
}
|
||||
} else {
|
||||
log.error("LLM API调用失败,状态码:{},响应:{}", response.getStatusCode(), response.getBody());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("调用LLM服务生成总结时发生异常,modelId: {}", modelId, e);
|
||||
}
|
||||
|
||||
return "生成总结失败,请稍后重试";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAvailable() {
|
||||
try {
|
||||
ModelConfigEntity defaultLLMConfig = getDefaultLLMConfig();
|
||||
if (defaultLLMConfig == null || defaultLLMConfig.getConfigJson() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
JSONObject configJson = defaultLLMConfig.getConfigJson();
|
||||
String baseUrl = configJson.getStr("base_url");
|
||||
String apiKey = configJson.getStr("api_key");
|
||||
|
||||
return baseUrl != null && !baseUrl.trim().isEmpty() &&
|
||||
apiKey != null && !apiKey.trim().isEmpty();
|
||||
} catch (Exception e) {
|
||||
log.error("检查LLM服务可用性时发生异常:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAvailable(String modelId) {
|
||||
try {
|
||||
if (modelId == null || modelId.trim().isEmpty()) {
|
||||
return isAvailable();
|
||||
}
|
||||
|
||||
// 通过具体模型ID获取配置
|
||||
ModelConfigEntity modelConfig = modelConfigService.getModelByIdFromCache(modelId);
|
||||
if (modelConfig == null || modelConfig.getConfigJson() == null) {
|
||||
log.warn("未找到指定的LLM模型配置,modelId: {}", modelId);
|
||||
return false;
|
||||
}
|
||||
|
||||
JSONObject configJson = modelConfig.getConfigJson();
|
||||
String baseUrl = configJson.getStr("base_url");
|
||||
String apiKey = configJson.getStr("api_key");
|
||||
|
||||
return baseUrl != null && !baseUrl.trim().isEmpty() &&
|
||||
apiKey != null && !apiKey.trim().isEmpty();
|
||||
} catch (Exception e) {
|
||||
log.error("检查LLM服务可用性时发生异常,modelId: {}", modelId, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从智控台获取默认的LLM模型配置
|
||||
*/
|
||||
private ModelConfigEntity getDefaultLLMConfig() {
|
||||
try {
|
||||
// 获取所有启用的LLM模型配置
|
||||
List<ModelConfigEntity> llmConfigs = modelConfigService.getEnabledModelsByType("LLM");
|
||||
if (llmConfigs == null || llmConfigs.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 优先返回默认配置,如果没有默认配置则返回第一个启用的配置
|
||||
for (ModelConfigEntity config : llmConfigs) {
|
||||
if (config.getIsDefault() != null && config.getIsDefault() == 1) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
return llmConfigs.get(0);
|
||||
} catch (Exception e) {
|
||||
log.error("获取LLM模型配置时发生异常:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,12 @@ public interface ModelConfigService extends BaseService<ModelConfigEntity> {
|
||||
* @return TTS平台列表(id和modelName)
|
||||
*/
|
||||
List<Map<String, Object>> getTtsPlatformList();
|
||||
|
||||
/**
|
||||
* 根据模型类型获取所有启用的模型配置
|
||||
*
|
||||
* @param modelType 模型类型(如:LLM, TTS, ASR等)
|
||||
* @return 启用的模型配置列表
|
||||
*/
|
||||
List<ModelConfigEntity> getEnabledModelsByType(String modelType);
|
||||
}
|
||||
|
||||
@@ -502,4 +502,22 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
public List<Map<String, Object>> getTtsPlatformList() {
|
||||
return modelConfigDao.getTtsPlatformList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模型类型获取所有启用的模型配置
|
||||
*/
|
||||
@Override
|
||||
public List<ModelConfigEntity> getEnabledModelsByType(String modelType) {
|
||||
if (StringUtils.isBlank(modelType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<ModelConfigEntity> entities = modelConfigDao.selectList(
|
||||
new QueryWrapper<ModelConfigEntity>()
|
||||
.eq("model_type", modelType)
|
||||
.eq("is_enabled", 1)
|
||||
.orderByAsc("sort"));
|
||||
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ public class ShiroConfig {
|
||||
filterMap.put("/config/**", "server");
|
||||
filterMap.put("/agent/chat-history/report", "server");
|
||||
filterMap.put("/agent/chat-history/download/**", "anon");
|
||||
filterMap.put("/agent/saveMemory/**", "server");
|
||||
filterMap.put("/agent/chat-summary/**", "server");
|
||||
filterMap.put("/agent/play/**", "anon");
|
||||
filterMap.put("/voiceClone/play/**", "anon");
|
||||
filterMap.put("/**", "oauth2");
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
@@ -23,7 +24,9 @@ import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.TokenDTO;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.common.utils.Sm2DecryptUtil;
|
||||
import xiaozhi.common.validator.AssertUtils;
|
||||
import xiaozhi.common.validator.ValidatorUtils;
|
||||
import xiaozhi.modules.security.dto.LoginDTO;
|
||||
@@ -32,8 +35,6 @@ import xiaozhi.modules.security.password.PasswordUtils;
|
||||
import xiaozhi.modules.security.service.CaptchaService;
|
||||
import xiaozhi.modules.security.service.SysUserTokenService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.common.utils.Sm2DecryptUtil;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import xiaozhi.modules.sys.dto.PasswordDTO;
|
||||
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
|
||||
import xiaozhi.modules.sys.dto.SysUserDTO;
|
||||
@@ -89,13 +90,13 @@ public class LoginController {
|
||||
@Operation(summary = "登录")
|
||||
public Result<TokenDTO> login(@RequestBody LoginDTO login) {
|
||||
String password = login.getPassword();
|
||||
|
||||
|
||||
// 使用工具类解密并验证验证码
|
||||
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
|
||||
password, login.getCaptchaId(), captchaService, sysParamsService);
|
||||
|
||||
|
||||
login.setPassword(actualPassword);
|
||||
|
||||
|
||||
// 按照用户名获取用户
|
||||
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
|
||||
// 判断用户是否存在
|
||||
@@ -108,8 +109,6 @@ public class LoginController {
|
||||
}
|
||||
return sysUserTokenService.createToken(userDTO.getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "注册")
|
||||
@@ -117,15 +116,15 @@ public class LoginController {
|
||||
if (!sysUserService.getAllowUserRegister()) {
|
||||
throw new RenException(ErrorCode.USER_REGISTER_DISABLED);
|
||||
}
|
||||
|
||||
|
||||
String password = login.getPassword();
|
||||
|
||||
|
||||
// 使用工具类解密并验证验证码
|
||||
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
|
||||
password, login.getCaptchaId(), captchaService, sysParamsService);
|
||||
|
||||
|
||||
login.setPassword(actualPassword);
|
||||
|
||||
|
||||
// 是否开启手机注册
|
||||
Boolean isMobileRegister = sysParamsService
|
||||
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
|
||||
@@ -204,11 +203,11 @@ public class LoginController {
|
||||
}
|
||||
|
||||
String password = dto.getPassword();
|
||||
|
||||
|
||||
// 使用工具类解密并验证验证码
|
||||
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
|
||||
password, dto.getCaptchaId(), captchaService, sysParamsService);
|
||||
|
||||
|
||||
dto.setPassword(actualPassword);
|
||||
|
||||
sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword());
|
||||
@@ -229,7 +228,7 @@ public class LoginController {
|
||||
config.put("beianIcpNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_ICP_NUM.getValue(), true));
|
||||
config.put("beianGaNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_GA_NUM.getValue(), true));
|
||||
config.put("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true));
|
||||
|
||||
|
||||
// SM2公钥
|
||||
String publicKey = sysParamsService.getValue(Constant.SM2_PUBLIC_KEY, true);
|
||||
if (StringUtils.isBlank(publicKey)) {
|
||||
@@ -237,6 +236,12 @@ public class LoginController {
|
||||
}
|
||||
config.put("sm2PublicKey", publicKey);
|
||||
|
||||
// 获取system-web.menu参数配置
|
||||
String menuConfig = sysParamsService.getValue("system-web.menu", true);
|
||||
if (StringUtils.isNotBlank(menuConfig)) {
|
||||
config.put("systemWebMenu", JsonUtils.parseObject(menuConfig, Object.class));
|
||||
}
|
||||
|
||||
return new Result<Map<String, Object>>().ok(config);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import xiaozhi.modules.sys.dto.ServerActionResponseDTO;
|
||||
import xiaozhi.modules.sys.enums.ServerActionEnum;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
import xiaozhi.modules.sys.utils.WebSocketClientManager;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
|
||||
/**
|
||||
* 服务端管理控制器
|
||||
@@ -41,6 +43,8 @@ import xiaozhi.modules.sys.utils.WebSocketClientManager;
|
||||
@AllArgsConstructor
|
||||
public class ServerSideManageController {
|
||||
private final SysParamsService sysParamsService;
|
||||
private final DeviceService deviceService;
|
||||
private final RedisUtils redisUtils;
|
||||
private static final ObjectMapper objectMapper;
|
||||
static {
|
||||
objectMapper = new ObjectMapper();
|
||||
@@ -85,9 +89,22 @@ public class ServerSideManageController {
|
||||
return false;
|
||||
}
|
||||
String serverSK = sysParamsService.getValue(Constant.SERVER_SECRET, true);
|
||||
|
||||
String deviceId = UUID.randomUUID().toString();
|
||||
String clientId = UUID.randomUUID().toString();
|
||||
|
||||
String redisKey = xiaozhi.common.redis.RedisKeys.getTmpRegisterMacKey(deviceId);
|
||||
redisUtils.set(redisKey, "true", 300); // 5分钟有效期
|
||||
|
||||
WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
|
||||
headers.add("device-id", UUID.randomUUID().toString());
|
||||
headers.add("client-id", UUID.randomUUID().toString());
|
||||
headers.add("device-id", deviceId);
|
||||
headers.add("client-id", clientId);
|
||||
try {
|
||||
String token = deviceService.generateWebSocketToken(clientId, deviceId);
|
||||
headers.add("authorization", "Bearer " + token);
|
||||
} catch (Exception e) {
|
||||
throw new RenException(ErrorCode.WEB_SOCKET_CONNECT_FAILED);
|
||||
}
|
||||
|
||||
try (WebSocketClientManager client = new WebSocketClientManager.Builder()
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 更新HuoshanDoubleStreamTTS供应器配置,增加开启链接复用选项
|
||||
UPDATE `ai_model_provider`
|
||||
SET fields = '[{"key": "ws_url", "type": "string", "label": "WebSocket地址"}, {"key": "appid", "type": "string", "label": "应用ID"}, {"key": "access_token", "type": "string", "label": "访问令牌"}, {"key": "resource_id", "type": "string", "label": "资源ID"}, {"key": "speaker", "type": "string", "label": "默认音色"}, {"key": "enable_ws_reuse", "type": "boolean", "label": "是否开启链接复用", "default": true}, {"key": "speech_rate", "type": "number", "label": "语速(-50~100)"}, {"key": "loudness_rate", "type": "number", "label": "音量(-50~100)"}, {"key": "pitch", "type": "number", "label": "音高(-12~12)"}]'
|
||||
WHERE id = 'SYSTEM_TTS_HSDSTTS';
|
||||
|
||||
UPDATE `ai_model_config` SET
|
||||
`doc_link` = 'https://console.volcengine.com/speech/service/10007',
|
||||
`remark` = '火山引擎语音合成服务配置说明:
|
||||
1. 访问 https://www.volcengine.com/ 注册并开通火山引擎账号
|
||||
2. 访问 https://console.volcengine.com/speech/service/10007 开通语音合成大模型,购买音色
|
||||
3. 在页面底部获取appid和access_token
|
||||
5. 资源ID固定为:volc.service_type.10029(大模型语音合成及混音)
|
||||
6. 链接复用:开启WebSocket连接复用,默认true减少链接损耗(注意:复用后设备处于聆听状态时空闲链接会占并发数)
|
||||
7. 语速:-50~100,可不填,正常默认值0,可填-50~100
|
||||
8. 音量:-50~100,可不填,正常默认值0,可填-50~100
|
||||
9. 音高:-12~12,可不填,正常默认值0,可填-12~12
|
||||
10. 填入配置文件中' WHERE `id` = 'TTS_HuoshanDoubleStreamTTS';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 添加系统功能菜单配置参数
|
||||
delete from `sys_params` where param_code = 'system-web.menu';
|
||||
|
||||
-- 添加系统功能菜单配置参数
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES
|
||||
(600, 'system-web.menu', '{"features":{"voiceprintRecognition":{"name":"feature.voiceprintRecognition.name","enabled":false,"description":"feature.voiceprintRecognition.description"},"voiceClone":{"name":"feature.voiceClone.name","enabled":false,"description":"feature.voiceClone.description"},"knowledgeBase":{"name":"feature.knowledgeBase.name","enabled":false,"description":"feature.knowledgeBase.description"},"mcpAccessPoint":{"name":"feature.mcpAccessPoint.name","enabled":false,"description":"feature.mcpAccessPoint.description"},"vad":{"name":"feature.vad.name","enabled":true,"description":"feature.vad.description"},"asr":{"name":"feature.asr.name","enabled":true,"description":"feature.asr.description"}},"groups":{"featureManagement":["voiceprintRecognition","voiceClone","knowledgeBase","mcpAccessPoint"],"voiceManagement":["vad","asr"]}}', 'json', 1, '系统功能菜单配置');
|
||||
@@ -0,0 +1,14 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- changeset xiaozhi:202512041515
|
||||
CREATE TABLE ai_agent_context_provider (
|
||||
id VARCHAR(32) NOT NULL COMMENT '主键',
|
||||
agent_id VARCHAR(32) NOT NULL COMMENT '智能体ID',
|
||||
context_providers JSON COMMENT '上下文源配置',
|
||||
creator BIGINT COMMENT '创建者',
|
||||
created_at DATETIME COMMENT '创建时间',
|
||||
updater BIGINT COMMENT '更新者',
|
||||
updated_at DATETIME COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_agent_id (agent_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体上下文源配置表';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 删除server模块是否开启token认证参数
|
||||
delete from `sys_params` where param_code = 'server.auth.enabled';
|
||||
|
||||
-- 添加server模块是否开启token认证参数
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES
|
||||
(122, 'server.auth.enabled', 'true', 'boolean', 1, 'server模块是否开启token认证');
|
||||
@@ -0,0 +1 @@
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (311, 'enable_websocket_ping', 'false', 'boolean', 1, '是否启用WebSocket心跳保活机制');
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 为智能体聊天历史记录添加音频ID索引
|
||||
ALTER TABLE ai_agent_chat_history ADD INDEX idx_ai_agent_chat_history_audio_id (audio_id);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 更新豆包流式ASR供应器,增加end_window_size配置
|
||||
delete from `ai_model_provider` where id = 'SYSTEM_ASR_DoubaoStreamASR';
|
||||
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
|
||||
('SYSTEM_ASR_DoubaoStreamASR', 'ASR', 'doubao_stream', '火山引擎语音识别(流式)', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"access_token","label":"访问令牌","type":"string"},{"key":"cluster","label":"集群","type":"string"},{"key":"boosting_table_name","label":"热词文件名称","type":"string"},{"key":"correct_table_name","label":"替换词文件名称","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"end_window_size","label":"静音判定时长(ms)","type":"number"}]', 3, 1, NOW(), 1, NOW());
|
||||
|
||||
|
||||
-- 更新豆包流式ASR模型配置,增加end_window_size默认值
|
||||
UPDATE `ai_model_config` SET
|
||||
`config_json` = JSON_SET(`config_json`, '$.end_window_size', 200)
|
||||
WHERE `id` = 'ASR_DoubaoStreamASR' AND JSON_EXTRACT(`config_json`, '$.end_window_size') IS NULL;
|
||||
@@ -423,3 +423,53 @@ databaseChangeLog:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202511131023.sql
|
||||
- changeSet:
|
||||
id: 202511221450
|
||||
author: RanChen
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202511221450.sql
|
||||
- changeSet:
|
||||
id: 202512031517
|
||||
author: rainv123
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202512031517.sql
|
||||
|
||||
- changeSet:
|
||||
id: 202512041515
|
||||
author: cgd
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202512041515.sql
|
||||
- changeSet:
|
||||
id: 202512131453
|
||||
author: hrz
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202512131453.sql
|
||||
- changeSet:
|
||||
id: 202512161529
|
||||
author: RanChen
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202512161529.sql
|
||||
- changeSet:
|
||||
id: 202512192245
|
||||
author: hrz
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202512192245.sql
|
||||
- changeSet:
|
||||
id: 202512221117
|
||||
author: RanChen
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202512221117.sql
|
||||
@@ -22,13 +22,18 @@
|
||||
created_at, updated_at
|
||||
</sql>
|
||||
|
||||
<delete id="deleteAudioByAgentId">
|
||||
DELETE FROM ai_agent_chat_audio
|
||||
WHERE id IN (
|
||||
SELECT audio_id
|
||||
FROM ai_agent_chat_history
|
||||
WHERE agent_id = #{agentId}
|
||||
)
|
||||
<select id="getAudioIdsByAgentId" resultType="java.lang.String">
|
||||
SELECT DISTINCT audio_id
|
||||
FROM ai_agent_chat_history
|
||||
WHERE agent_id = #{agentId} AND audio_id IS NOT NULL
|
||||
</select>
|
||||
|
||||
<delete id="deleteAudioByIds">
|
||||
DELETE FROM ai_agent_chat_audio
|
||||
WHERE id IN
|
||||
<foreach collection="audioIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
</delete>
|
||||
|
||||
<update id="deleteAudioIdByAgentId">
|
||||
|
||||
@@ -235,7 +235,7 @@ function showAbout() {
|
||||
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
|
||||
content: t('settings.aboutContent', {
|
||||
appName: import.meta.env.VITE_APP_TITLE,
|
||||
version: '0.8.9'
|
||||
version: '0.8.10'
|
||||
}),
|
||||
showCancel: false,
|
||||
confirmText: t('common.confirm'),
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:visible.sync="dialogVisible"
|
||||
width="900px"
|
||||
:title="$t('contextProviderDialog.title')"
|
||||
:close-on-click-modal="false"
|
||||
custom-class="context-provider-dialog"
|
||||
append-to-body
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<el-empty v-if="localProviders.length === 0" :description="$t('contextProviderDialog.noContextApi')">
|
||||
<el-button type="primary" icon="el-icon-plus" @click="addProvider(0)">{{ $t('contextProviderDialog.add') }}</el-button>
|
||||
</el-empty>
|
||||
|
||||
<div
|
||||
v-for="(provider, pIndex) in localProviders"
|
||||
:key="pIndex"
|
||||
class="provider-item"
|
||||
>
|
||||
<el-card class="provider-card" shadow="hover" :body-style="{ padding: '15px 20px' }">
|
||||
<!-- URL Row -->
|
||||
<div class="input-row">
|
||||
<span class="label-text">{{ $t('contextProviderDialog.apiUrl') }}</span>
|
||||
<el-input
|
||||
v-model="provider.url"
|
||||
:placeholder="$t('contextProviderDialog.apiUrlPlaceholder')"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
></el-input>
|
||||
</div>
|
||||
|
||||
<!-- Headers Section -->
|
||||
<div class="headers-section">
|
||||
<div class="label-text" style="margin-top: 6px;">{{ $t('contextProviderDialog.requestHeaders') }}</div>
|
||||
<div class="headers-list">
|
||||
<div
|
||||
v-for="(header, hIndex) in provider.headers"
|
||||
:key="hIndex"
|
||||
class="header-row"
|
||||
>
|
||||
<el-input
|
||||
v-model="header.key"
|
||||
:placeholder="$t('contextProviderDialog.headerKeyPlaceholder')"
|
||||
size="small"
|
||||
style="width: 180px;"
|
||||
></el-input>
|
||||
<span class="separator">:</span>
|
||||
<el-input
|
||||
v-model="header.value"
|
||||
:placeholder="$t('contextProviderDialog.headerValuePlaceholder')"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
></el-input>
|
||||
|
||||
<div class="row-controls">
|
||||
<el-button
|
||||
type="primary"
|
||||
icon="el-icon-plus"
|
||||
circle
|
||||
size="mini"
|
||||
plain
|
||||
@click="addHeader(pIndex, hIndex + 1)"
|
||||
></el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
icon="el-icon-minus"
|
||||
circle
|
||||
size="mini"
|
||||
plain
|
||||
@click="removeHeader(pIndex, hIndex)"
|
||||
></el-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty Headers State -->
|
||||
<div v-if="provider.headers.length === 0" class="header-row empty-header">
|
||||
<span class="no-header-text">{{ $t('contextProviderDialog.noHeaders') }}</span>
|
||||
<el-button
|
||||
type="text"
|
||||
icon="el-icon-plus"
|
||||
size="mini"
|
||||
@click="addHeader(pIndex, 0)"
|
||||
>{{ $t('contextProviderDialog.addHeader') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Provider Block Controls (Right Side) -->
|
||||
<div class="block-controls">
|
||||
<el-button
|
||||
type="primary"
|
||||
icon="el-icon-plus"
|
||||
circle
|
||||
size="medium"
|
||||
@click="addProvider(pIndex + 1)"
|
||||
></el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
icon="el-icon-minus"
|
||||
circle
|
||||
size="medium"
|
||||
@click="removeProvider(pIndex)"
|
||||
></el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">{{ $t('contextProviderDialog.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">{{ $t('contextProviderDialog.confirm') }}</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ContextProviderDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
providers: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localProviders: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dialogVisible: {
|
||||
get() {
|
||||
return this.visible;
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:visible', val);
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(val) {
|
||||
if (val) {
|
||||
this.initLocalData();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initLocalData() {
|
||||
// 深拷贝并将 headers 对象转换为数组
|
||||
this.localProviders = this.providers.map(p => {
|
||||
const headers = p.headers || {};
|
||||
return {
|
||||
url: p.url || '',
|
||||
headers: Object.entries(headers).map(([key, value]) => ({ key, value }))
|
||||
};
|
||||
});
|
||||
|
||||
// 如果为空,添加一个默认块
|
||||
if (this.localProviders.length === 0) {
|
||||
this.localProviders.push({ url: '', headers: [{ key: '', value: '' }] });
|
||||
}
|
||||
},
|
||||
addProvider(index) {
|
||||
this.localProviders.splice(index, 0, {
|
||||
url: '',
|
||||
headers: [{ key: '', value: '' }]
|
||||
});
|
||||
},
|
||||
removeProvider(index) {
|
||||
this.localProviders.splice(index, 1);
|
||||
},
|
||||
addHeader(pIndex, hIndex) {
|
||||
this.localProviders[pIndex].headers.splice(hIndex, 0, { key: '', value: '' });
|
||||
},
|
||||
removeHeader(pIndex, hIndex) {
|
||||
this.localProviders[pIndex].headers.splice(hIndex, 1);
|
||||
},
|
||||
handleConfirm() {
|
||||
const result = this.localProviders
|
||||
.filter(p => p.url.trim() !== '')
|
||||
.map(p => {
|
||||
const headersObj = {};
|
||||
p.headers.forEach(h => {
|
||||
if (h.key.trim()) {
|
||||
headersObj[h.key.trim()] = h.value;
|
||||
}
|
||||
});
|
||||
return {
|
||||
url: p.url.trim(),
|
||||
headers: headersObj
|
||||
};
|
||||
});
|
||||
|
||||
this.$emit('confirm', result);
|
||||
this.dialogVisible = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px 25px;
|
||||
}
|
||||
|
||||
.dialog-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.dialog-content::-webkit-scrollbar-thumb {
|
||||
background: #dcdfe6;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.dialog-content::-webkit-scrollbar-track {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
flex: 1;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-left: 4px solid #409EFF; /* 左侧强调色 */
|
||||
background-color: #fff;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.provider-card:hover {
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.block-controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
width: 60px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
line-height: 32px; /* 垂直居中对齐 */
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.headers-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.headers-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: #fcfcfc;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #dcdfe6;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.headers-list:hover {
|
||||
border-color: #c0c4cc;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: #909399;
|
||||
font-weight: bold;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.row-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.header-row:hover .row-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty-header {
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.no-header-text {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="settings-btn" @click="handleConfigure">
|
||||
{{ $t('home.configureRole') }}
|
||||
</div>
|
||||
<div class="settings-btn" @click="handleVoicePrint">
|
||||
<div v-if="featureStatus.voiceprintRecognition" class="settings-btn" @click="handleVoicePrint">
|
||||
{{ $t('home.voiceprintRecognition') }}
|
||||
</div>
|
||||
<div class="settings-btn" @click="handleDeviceManage">
|
||||
@@ -49,7 +49,15 @@ import i18n from '@/i18n';
|
||||
export default {
|
||||
name: 'DeviceItem',
|
||||
props: {
|
||||
device: { type: Object, required: true }
|
||||
device: { type: Object, required: true },
|
||||
featureStatus: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
voiceprintRecognition: false,
|
||||
voiceClone: false,
|
||||
knowledgeBase: false
|
||||
})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return { switchValue: false }
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
</div>
|
||||
|
||||
<!-- MCP区域 -->
|
||||
<div class="mcp-access-point">
|
||||
<div class="mcp-access-point" v-if="featureStatus.mcpAccessPoint">
|
||||
<div class="mcp-container">
|
||||
<!-- 左侧区域 -->
|
||||
<div class="mcp-left">
|
||||
@@ -171,6 +171,7 @@
|
||||
<script>
|
||||
import Api from '@/apis/api';
|
||||
import i18n from '@/i18n';
|
||||
import featureManager from '@/utils/featureManager';
|
||||
|
||||
export default {
|
||||
i18n,
|
||||
@@ -205,6 +206,11 @@ export default {
|
||||
mcpUrl: "",
|
||||
mcpStatus: "disconnected",
|
||||
mcpTools: [],
|
||||
|
||||
// 功能状态
|
||||
featureStatus: {
|
||||
mcpAccessPoint: false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -249,6 +255,9 @@ export default {
|
||||
// 右侧默认指向第一个
|
||||
this.currentFunction = this.selectedList[0] || null;
|
||||
|
||||
// 加载功能状态
|
||||
this.loadFeatureStatus();
|
||||
|
||||
// 加载MCP数据
|
||||
this.loadMcpAddress();
|
||||
this.loadMcpTools();
|
||||
@@ -259,6 +268,19 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 加载功能状态
|
||||
*/
|
||||
async loadFeatureStatus() {
|
||||
// 确保featureManager已初始化完成
|
||||
await featureManager.waitForInitialization();
|
||||
|
||||
const config = featureManager.getConfig();
|
||||
this.featureStatus = {
|
||||
mcpAccessPoint: config.mcpAccessPoint || false
|
||||
};
|
||||
},
|
||||
|
||||
copyUrl() {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = this.mcpUrl;
|
||||
@@ -456,6 +478,7 @@ export default {
|
||||
.function-column {
|
||||
position: relative;
|
||||
width: auto;
|
||||
height:700px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #EBEEF5;
|
||||
@@ -463,6 +486,12 @@ export default {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.mcp-access-point {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.function-column::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<!-- 左侧元素 -->
|
||||
<div class="header-left" @click="goHome">
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" class="logo-img" />
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" class="brand-img" />
|
||||
<img loading="lazy" alt="" :src="xiaozhiAiIcon" class="brand-img" />
|
||||
</div>
|
||||
|
||||
<!-- 中间导航菜单 -->
|
||||
@@ -26,7 +26,7 @@
|
||||
<span class="nav-text">{{ $t("header.smartManagement") }}</span>
|
||||
</div>
|
||||
<!-- 普通用户显示音色克隆 -->
|
||||
<div v-if="!isSuperAdmin" class="equipment-management"
|
||||
<div v-if="!isSuperAdmin && featureStatus.voiceClone" class="equipment-management"
|
||||
:class="{ 'active-tab': $route.path === '/voice-clone-management' }" @click="goVoiceCloneManagement">
|
||||
<img loading="lazy" alt="" src="@/assets/header/voice.png" :style="{
|
||||
filter:
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 超级管理员显示音色克隆下拉菜单 -->
|
||||
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown" :class="{
|
||||
<el-dropdown v-if="isSuperAdmin && featureStatus.voiceClone" trigger="click" class="equipment-management more-dropdown" :class="{
|
||||
'active-tab':
|
||||
$route.path === '/voice-clone-management' ||
|
||||
$route.path === '/voice-resource-management',
|
||||
@@ -72,7 +72,7 @@
|
||||
}" />
|
||||
<span class="nav-text">{{ $t("header.modelConfig") }}</span>
|
||||
</div>
|
||||
<div class="equipment-management"
|
||||
<div v-if="featureStatus.knowledgeBase" class="equipment-management"
|
||||
:class="{ 'active-tab': $route.path === '/knowledge-base-management' || $route.path === '/knowledge-file-upload' }"
|
||||
@click="goKnowledgeBaseManagement">
|
||||
<img loading="lazy" alt="" src="@/assets/header/knowledge_base.png" :style="{
|
||||
@@ -89,7 +89,8 @@
|
||||
$route.path === '/server-side-management' ||
|
||||
$route.path === '/agent-template-management' ||
|
||||
$route.path === '/ota-management' ||
|
||||
$route.path === '/user-management',
|
||||
$route.path === '/user-management' ||
|
||||
$route.path === '/feature-management',
|
||||
}" @visible-change="handleParamDropdownVisibleChange">
|
||||
<span class="el-dropdown-link">
|
||||
<img loading="lazy" alt="" src="@/assets/header/param_management.png" :style="{
|
||||
@@ -100,7 +101,8 @@
|
||||
$route.path === '/server-side-management' ||
|
||||
$route.path === '/agent-template-management' ||
|
||||
$route.path === '/ota-management' ||
|
||||
$route.path === '/user-management'
|
||||
$route.path === '/user-management' ||
|
||||
$route.path === '/feature-management'
|
||||
? 'brightness(0) invert(1)'
|
||||
: 'None',
|
||||
}" />
|
||||
@@ -129,6 +131,9 @@
|
||||
<el-dropdown-item @click.native="goServerSideManagement">
|
||||
{{ $t("header.serverSideManagement") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click.native="goFeatureManagement">
|
||||
{{ $t("header.featureManagement") }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
@@ -186,6 +191,7 @@ import userApi from "@/apis/module/user";
|
||||
import i18n, { changeLanguage } from "@/i18n";
|
||||
import { mapActions, mapGetters } from "vuex";
|
||||
import ChangePasswordDialog from "./ChangePasswordDialog.vue"; // 引入修改密码弹窗组件
|
||||
import featureManager from "@/utils/featureManager"; // 引入功能管理工具类
|
||||
|
||||
export default {
|
||||
name: "HeaderBar",
|
||||
@@ -217,6 +223,11 @@ export default {
|
||||
label: "label",
|
||||
children: "children",
|
||||
},
|
||||
// 功能状态
|
||||
featureStatus: {
|
||||
voiceClone: false, // 音色克隆功能状态
|
||||
knowledgeBase: false, // 知识库功能状态
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -246,6 +257,24 @@ export default {
|
||||
return this.$t("language.zhCN");
|
||||
}
|
||||
},
|
||||
// 根据当前语言获取对应的xiaozhi-ai图标
|
||||
xiaozhiAiIcon() {
|
||||
const currentLang = this.currentLanguage;
|
||||
switch (currentLang) {
|
||||
case "zh_CN":
|
||||
return require("@/assets/xiaozhi-ai.png");
|
||||
case "zh_TW":
|
||||
return require("@/assets/xiaozhi-ai_zh_TW.png");
|
||||
case "en":
|
||||
return require("@/assets/xiaozhi-ai_en.png");
|
||||
case "de":
|
||||
return require("@/assets/xiaozhi-ai_de.png");
|
||||
case "vi":
|
||||
return require("@/assets/xiaozhi-ai_vi.png");
|
||||
default:
|
||||
return require("@/assets/xiaozhi-ai.png");
|
||||
}
|
||||
},
|
||||
// 用户菜单选项
|
||||
userMenuOptions() {
|
||||
return [
|
||||
@@ -286,12 +315,14 @@ export default {
|
||||
];
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
this.fetchUserInfo();
|
||||
this.checkScreenSize();
|
||||
window.addEventListener("resize", this.checkScreenSize);
|
||||
// 从localStorage加载搜索历史
|
||||
this.loadSearchHistory();
|
||||
// 等待featureManager初始化完成后再加载功能状态
|
||||
await this.loadFeatureStatus();
|
||||
},
|
||||
//移除事件监听器
|
||||
beforeDestroy() {
|
||||
@@ -338,6 +369,20 @@ export default {
|
||||
goAgentTemplateManagement() {
|
||||
this.$router.push("/agent-template-management");
|
||||
},
|
||||
// 跳转到功能管理
|
||||
goFeatureManagement() {
|
||||
this.$router.push("/feature-management");
|
||||
},
|
||||
// 加载功能状态
|
||||
async loadFeatureStatus() {
|
||||
// 等待featureManager初始化完成
|
||||
await featureManager.waitForInitialization();
|
||||
|
||||
const config = featureManager.getConfig();
|
||||
|
||||
this.featureStatus.voiceClone = config.voiceClone;
|
||||
this.featureStatus.knowledgeBase = config.knowledgeBase;
|
||||
},
|
||||
// 获取用户信息
|
||||
fetchUserInfo() {
|
||||
userApi.getUserInfo(({ data }) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ export default {
|
||||
'header.clearHistory': 'Verlauf löschen',
|
||||
'header.providerManagement': 'Anbieterverwaltung',
|
||||
'header.serverSideManagement': 'Serververwaltung',
|
||||
'header.featureManagement': 'Systemfunktionsverwaltung',
|
||||
'header.changePassword': 'Passwort ändern',
|
||||
'header.logout': 'Abmelden',
|
||||
'header.searchPlaceholder': 'Nach Namen suchen..',
|
||||
@@ -229,6 +230,26 @@ export default {
|
||||
'voicePrintDialog.requiredName': 'Bitte Namen eingeben',
|
||||
'voicePrintDialog.requiredAudioVector': 'Bitte Audio-Vektor auswählen',
|
||||
|
||||
// Context provider dialog related
|
||||
'contextProviderDialog.title': 'Quelle bearbeiten',
|
||||
'contextProviderDialog.noContextApi': 'Keine Kontext-API',
|
||||
'contextProviderDialog.add': 'Hinzufügen',
|
||||
'contextProviderDialog.apiUrl': 'API-URL',
|
||||
'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data',
|
||||
'contextProviderDialog.requestHeaders': 'Anfrage-Header',
|
||||
'contextProviderDialog.headerKeyPlaceholder': 'Schlüssel',
|
||||
'contextProviderDialog.headerValuePlaceholder': 'Wert',
|
||||
'contextProviderDialog.noHeaders': 'Keine Headers',
|
||||
'contextProviderDialog.addHeader': 'Header hinzufügen',
|
||||
'contextProviderDialog.cancel': 'Abbrechen',
|
||||
'contextProviderDialog.confirm': 'Bestätigen',
|
||||
|
||||
// Role config page - context provider related
|
||||
'roleConfig.contextProvider': 'Kontext',
|
||||
'roleConfig.contextProviderSuccess': '{count} Quellen erfolgreich hinzugefügt.',
|
||||
'roleConfig.contextProviderDocLink': 'Wie man Kontextquellen bereitstellt',
|
||||
'roleConfig.editContextProvider': 'Quelle bearbeiten',
|
||||
|
||||
// Voice print page related
|
||||
'voicePrint.pageTitle': 'Stimmabdruck-Erkennung',
|
||||
'voicePrint.name': 'Name',
|
||||
@@ -692,7 +713,7 @@ export default {
|
||||
'paramManagement.deleteFailed': 'Löschen fehlgeschlagen, bitte versuchen Sie es erneut',
|
||||
'paramManagement.operationCancelled': 'Löschen abgebrochen',
|
||||
'paramManagement.operationClosed': 'Operation geschlossen',
|
||||
'paramManagement.updateSuccess': 'Aktualisierung erfolgreich',
|
||||
'paramManagement.updateSuccess': 'Aktualisierung erfolgreich. Einige Konfigurationen werden erst nach Neustart des xiaozhi-server-Moduls wirksam.',
|
||||
'paramManagement.addSuccess': 'Hinzufügen erfolgreich',
|
||||
'paramManagement.updateFailed': 'Aktualisierung fehlgeschlagen',
|
||||
'paramManagement.addFailed': 'Hinzufügen fehlgeschlagen',
|
||||
@@ -800,7 +821,7 @@ export default {
|
||||
'modelConfig.rag': 'RAG',
|
||||
'modelConfig.modelId': 'Modell-ID',
|
||||
'modelConfig.modelName': 'Modellname',
|
||||
'modelConfig.provider': 'Anbieter',
|
||||
'modelConfig.provider': 'Schnittstellentyp',
|
||||
'modelConfig.unknown': 'Unbekannt',
|
||||
'modelConfig.isEnabled': 'Aktiviert',
|
||||
'modelConfig.isDefault': 'Standard',
|
||||
@@ -831,7 +852,7 @@ export default {
|
||||
'modelConfig.enableSuccess': 'Aktivieren erfolgreich',
|
||||
'modelConfig.disableSuccess': 'Deaktivieren erfolgreich',
|
||||
'modelConfig.operationFailed': 'Operation fehlgeschlagen',
|
||||
'modelConfig.setDefaultSuccess': 'Standardmodell erfolgreich gesetzt',
|
||||
'modelConfig.setDefaultSuccess': 'Standardmodell erfolgreich gesetzt, bitte starten Sie das xiaozhi-server-Modul zeitnah manuell neu',
|
||||
'modelConfig.itemsPerPage': '{items} Einträge/Seite',
|
||||
'modelConfig.firstPage': 'Erste Seite',
|
||||
'modelConfig.prevPage': 'Vorherige Seite',
|
||||
@@ -1262,4 +1283,35 @@ export default {
|
||||
'knowledgeFileUpload.content': 'Inhalt:',
|
||||
'knowledgeFileUpload.testQuestionRequired': 'Bitte Testfrage eingeben',
|
||||
'knowledgeBaseDialog.descriptionRequired': 'Bitte Wissensbasis-Beschreibung eingeben',
|
||||
|
||||
// Feature Management page text
|
||||
'featureManagement.selectAll': 'Alle auswählen',
|
||||
'featureManagement.deselectAll': 'Alle abwählen',
|
||||
'featureManagement.save': 'Konfiguration speichern',
|
||||
'featureManagement.reset': 'Zurücksetzen',
|
||||
'featureManagement.group.featureManagement': 'Funktion/Bereich aktivieren oder deaktivieren',
|
||||
'featureManagement.group.voiceManagement': 'Für Benutzer bei der Agenten-Konfiguration sichtbar',
|
||||
'featureManagement.noFeatures': 'Keine Funktionen verfügbar',
|
||||
'featureManagement.contactAdmin': 'Bitte kontaktieren Sie Administrator zur Funktionskonfiguration',
|
||||
'featureManagement.saveSuccess': 'Funktionskonfiguration erfolgreich gespeichert',
|
||||
'featureManagement.resetConfirm': 'Sind Sie sicher, dass Sie alle Funktionskonfigurationen zurücksetzen möchten?',
|
||||
'featureManagement.confirm': 'Bestätigen',
|
||||
'featureManagement.cancel': 'Abbrechen',
|
||||
'featureManagement.resetSuccess': 'Funktionskonfiguration erfolgreich zurückgesetzt',
|
||||
'featureManagement.noChanges': 'Keine Änderungen zum Speichern',
|
||||
|
||||
// Feature names and descriptions
|
||||
'feature.voiceprintRecognition.name': 'Stimmerkennung',
|
||||
'feature.voiceprintRecognition.description': 'Benutzeridentität durch Stimmerkennungstechnologie verifizieren, bietet sichere Sprachiinteraktion',
|
||||
'feature.voiceClone.name': 'Stimmenklon',
|
||||
'feature.voiceClone.description': 'Spezifische Stimmfarbe mit KI-Technologie klonen, um personalisierte Sprachsynthese zu ermöglichen',
|
||||
'feature.knowledgeBase.name': 'Wissensbasis',
|
||||
'feature.knowledgeBase.description': 'Wissensbasissystem aufbauen und verwalten, um professionelle Wissensunterstützung für KI-Assistenten zu bieten',
|
||||
'feature.mcpAccessPoint.name': 'MCP-Zugangspunkt',
|
||||
'feature.mcpAccessPoint.description': 'MCP-Protokoll-Zugangspunkte bereitstellen, um Integration externer Tools und Dienste zu unterstützen',
|
||||
'feature.vad.name': 'Sprachaktivitätserkennung',
|
||||
'feature.vad.description': 'Sprachaktivität automatisch erkennen, um die Effizienz der Sprachiinteraktion zu optimieren',
|
||||
'feature.asr.name': 'Spracherkennung',
|
||||
'feature.asr.description': 'Sprache in Text umwandeln, um natürliche Sprachinteraktionsfunktionalität zu ermöglichen',
|
||||
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export default {
|
||||
'header.clearHistory': 'Clear History',
|
||||
'header.providerManagement': 'Provider Management',
|
||||
'header.serverSideManagement': 'Server Management',
|
||||
'header.featureManagement': 'System Feature Management',
|
||||
'header.changePassword': 'Change Password',
|
||||
'header.logout': 'Logout',
|
||||
'header.searchPlaceholder': 'Search by name..',
|
||||
@@ -229,6 +230,26 @@ export default {
|
||||
'voicePrintDialog.requiredName': 'Please enter name',
|
||||
'voicePrintDialog.requiredAudioVector': 'Please select audio vector',
|
||||
|
||||
// Context provider dialog related
|
||||
'contextProviderDialog.title': 'Edit Source',
|
||||
'contextProviderDialog.noContextApi': 'No Context API',
|
||||
'contextProviderDialog.add': 'Add',
|
||||
'contextProviderDialog.apiUrl': 'API URL',
|
||||
'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data',
|
||||
'contextProviderDialog.requestHeaders': 'Request Headers',
|
||||
'contextProviderDialog.headerKeyPlaceholder': 'Key',
|
||||
'contextProviderDialog.headerValuePlaceholder': 'Value',
|
||||
'contextProviderDialog.noHeaders': 'No Headers',
|
||||
'contextProviderDialog.addHeader': 'Add Header',
|
||||
'contextProviderDialog.cancel': 'Cancel',
|
||||
'contextProviderDialog.confirm': 'Confirm',
|
||||
|
||||
// Role config page - context provider related
|
||||
'roleConfig.contextProvider': 'Context',
|
||||
'roleConfig.contextProviderSuccess': 'Successfully added {count} sources.',
|
||||
'roleConfig.contextProviderDocLink': 'How to deploy context provider',
|
||||
'roleConfig.editContextProvider': 'Edit Source',
|
||||
|
||||
// Voice print page related
|
||||
'voicePrint.pageTitle': 'Voice Print Recognition',
|
||||
'voicePrint.name': 'Name',
|
||||
@@ -692,7 +713,7 @@ export default {
|
||||
'paramManagement.deleteFailed': 'Deletion failed, please try again',
|
||||
'paramManagement.operationCancelled': 'Deletion cancelled',
|
||||
'paramManagement.operationClosed': 'Operation closed',
|
||||
'paramManagement.updateSuccess': 'Update successful',
|
||||
'paramManagement.updateSuccess': 'Update successful. Some configurations will take effect only after restarting the xiaozhi-server module.',
|
||||
'paramManagement.addSuccess': 'Add successful',
|
||||
'paramManagement.updateFailed': 'Update failed',
|
||||
'paramManagement.addFailed': 'Add failed',
|
||||
@@ -800,7 +821,7 @@ export default {
|
||||
'modelConfig.rag': 'RAG',
|
||||
'modelConfig.modelId': 'Model ID',
|
||||
'modelConfig.modelName': 'Model Name',
|
||||
'modelConfig.provider': 'Provider',
|
||||
'modelConfig.provider': 'Interface Type',
|
||||
'modelConfig.unknown': 'Unknown',
|
||||
'modelConfig.isEnabled': 'Enabled',
|
||||
'modelConfig.isDefault': 'Default',
|
||||
@@ -831,7 +852,7 @@ export default {
|
||||
'modelConfig.enableSuccess': 'Enable successful',
|
||||
'modelConfig.disableSuccess': 'Disable successful',
|
||||
'modelConfig.operationFailed': 'Operation failed',
|
||||
'modelConfig.setDefaultSuccess': 'Set default model successful',
|
||||
'modelConfig.setDefaultSuccess': 'Set default model successful, please restart the xiaozhi-server module manually in time',
|
||||
'modelConfig.itemsPerPage': '{items} items/page',
|
||||
'modelConfig.firstPage': 'First Page',
|
||||
'modelConfig.prevPage': 'Previous Page',
|
||||
@@ -1262,4 +1283,35 @@ export default {
|
||||
'knowledgeFileUpload.content': 'Content:',
|
||||
'knowledgeFileUpload.testQuestionRequired': 'Please enter test question',
|
||||
'knowledgeBaseDialog.descriptionRequired': 'Please enter knowledge base description',
|
||||
|
||||
// Feature Management page text
|
||||
'featureManagement.selectAll': 'Select All',
|
||||
'featureManagement.deselectAll': 'Deselect All',
|
||||
'featureManagement.save': 'Save Configuration',
|
||||
'featureManagement.reset': 'Reset',
|
||||
'featureManagement.group.featureManagement': 'Enable/Disable the feature/section',
|
||||
'featureManagement.group.voiceManagement': 'Visible to users during agent configuration',
|
||||
'featureManagement.noFeatures': 'No features available',
|
||||
'featureManagement.contactAdmin': 'Please contact administrator to configure features',
|
||||
'featureManagement.saveSuccess': 'Feature configuration saved successfully',
|
||||
'featureManagement.resetConfirm': 'Are you sure you want to reset all feature configurations?',
|
||||
'featureManagement.confirm': 'Confirm',
|
||||
'featureManagement.cancel': 'Cancel',
|
||||
'featureManagement.resetSuccess': 'Feature configuration reset successfully',
|
||||
'featureManagement.noChanges': 'No changes to save',
|
||||
|
||||
// Feature names and descriptions
|
||||
'feature.voiceprintRecognition.name': 'Voiceprint Recognition',
|
||||
'feature.voiceprintRecognition.description': 'Verify user identity through voiceprint recognition technology, providing secure voice interaction experience',
|
||||
'feature.voiceClone.name': 'Voice Clone',
|
||||
'feature.voiceClone.description': 'Clone specific voice timbre using AI technology to achieve personalized voice synthesis',
|
||||
'feature.knowledgeBase.name': 'Knowledge Base',
|
||||
'feature.knowledgeBase.description': 'Build and manage knowledge base system to provide professional knowledge support for AI assistants',
|
||||
'feature.mcpAccessPoint.name': 'MCP Access Point',
|
||||
'feature.mcpAccessPoint.description': 'Provide MCP protocol access points to support integration of external tools and services',
|
||||
'feature.vad.name': 'Voice Activity Detection',
|
||||
'feature.vad.description': 'Automatically detect voice activity to optimize voice interaction response efficiency',
|
||||
'feature.asr.name': 'Speech Recognition',
|
||||
'feature.asr.description': 'Convert speech to text to enable natural language interaction functionality',
|
||||
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export default {
|
||||
'header.clearHistory': 'Xóa lịch sử',
|
||||
'header.providerManagement': 'Quản lý nhà cung cấp',
|
||||
'header.serverSideManagement': 'Quản lý máy chủ',
|
||||
'header.featureManagement': 'Cấu hình chức năng hệ thống',
|
||||
'header.changePassword': 'Đổi mật khẩu',
|
||||
'header.logout': 'Đăng xuất',
|
||||
'header.searchPlaceholder': 'Tìm kiếm theo tên..',
|
||||
@@ -229,6 +230,26 @@ export default {
|
||||
'voicePrintDialog.requiredName': 'Vui lòng nhập tên',
|
||||
'voicePrintDialog.requiredAudioVector': 'Vui lòng chọn vector âm thanh',
|
||||
|
||||
// Context provider dialog related
|
||||
'contextProviderDialog.title': 'Chỉnh sửa nguồn',
|
||||
'contextProviderDialog.noContextApi': 'Không có API ngữ cảnh',
|
||||
'contextProviderDialog.add': 'Thêm',
|
||||
'contextProviderDialog.apiUrl': 'Địa chỉ API',
|
||||
'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data',
|
||||
'contextProviderDialog.requestHeaders': 'Header yêu cầu',
|
||||
'contextProviderDialog.headerKeyPlaceholder': 'Khóa',
|
||||
'contextProviderDialog.headerValuePlaceholder': 'Giá trị',
|
||||
'contextProviderDialog.noHeaders': 'Không có Headers',
|
||||
'contextProviderDialog.addHeader': 'Thêm Header',
|
||||
'contextProviderDialog.cancel': 'Hủy bỏ',
|
||||
'contextProviderDialog.confirm': 'Xác nhận',
|
||||
|
||||
// Role config page - context provider related
|
||||
'roleConfig.contextProvider': 'Bối cảnh',
|
||||
'roleConfig.contextProviderSuccess': 'Đã thêm thành công {count} nguồn.',
|
||||
'roleConfig.contextProviderDocLink': 'Cách triển khai nguồn ngữ cảnh',
|
||||
'roleConfig.editContextProvider': 'Chỉnh sửa nguồn',
|
||||
|
||||
// Voice print page related
|
||||
'voicePrint.pageTitle': 'Nhận dạng dấu giọng nói',
|
||||
'voicePrint.name': 'Tên',
|
||||
@@ -692,7 +713,7 @@ export default {
|
||||
'paramManagement.deleteFailed': 'Xóa thất bại, vui lòng thử lại',
|
||||
'paramManagement.operationCancelled': 'Đã hủy xóa',
|
||||
'paramManagement.operationClosed': 'Đã đóng thao tác',
|
||||
'paramManagement.updateSuccess': 'Cập nhật thành công',
|
||||
'paramManagement.updateSuccess': 'Cập nhật thành công. Một số cấu hình chỉ có hiệu lực sau khi khởi động lại mô-đun xiaozhi-server.',
|
||||
'paramManagement.addSuccess': 'Thêm thành công',
|
||||
'paramManagement.updateFailed': 'Cập nhật thất bại',
|
||||
'paramManagement.addFailed': 'Thêm thất bại',
|
||||
@@ -800,7 +821,7 @@ export default {
|
||||
'modelConfig.rag': 'RAG',
|
||||
'modelConfig.modelId': 'ID mô hình',
|
||||
'modelConfig.modelName': 'Tên mô hình',
|
||||
'modelConfig.provider': 'Nhà cung cấp',
|
||||
'modelConfig.provider': 'Loại giao diện',
|
||||
'modelConfig.unknown': 'Không xác định',
|
||||
'modelConfig.isEnabled': 'Đã bật',
|
||||
'modelConfig.isDefault': 'Mặc định',
|
||||
@@ -831,7 +852,7 @@ export default {
|
||||
'modelConfig.enableSuccess': 'Bật thành công',
|
||||
'modelConfig.disableSuccess': 'Tắt thành công',
|
||||
'modelConfig.operationFailed': 'Thao tác thất bại',
|
||||
'modelConfig.setDefaultSuccess': 'Đặt mô hình mặc định thành công',
|
||||
'modelConfig.setDefaultSuccess': 'Đặt mô hình mặc định thành công, vui lòng khởi động lại module xiaozhi-server thủ công kịp thời',
|
||||
'modelConfig.itemsPerPage': '{items} mục/trang',
|
||||
'modelConfig.firstPage': 'Trang đầu',
|
||||
'modelConfig.prevPage': 'Trang trước',
|
||||
@@ -1262,4 +1283,35 @@ export default {
|
||||
'knowledgeFileUpload.content': 'Nội dung:',
|
||||
'knowledgeFileUpload.testQuestionRequired': 'Vui lòng nhập câu hỏi kiểm tra',
|
||||
'knowledgeBaseDialog.descriptionRequired': 'Vui lòng nhập mô tả cơ sở kiến thức',
|
||||
|
||||
// Feature Management page text
|
||||
'featureManagement.selectAll': 'Chọn tất cả',
|
||||
'featureManagement.deselectAll': 'Bỏ chọn tất cả',
|
||||
'featureManagement.save': 'Lưu cấu hình',
|
||||
'featureManagement.reset': 'Đặt lại',
|
||||
'featureManagement.group.featureManagement': 'Có bật tính năng/khối này lên hay không',
|
||||
'featureManagement.group.voiceManagement': 'Khi cấu hình agent, có hiển thị với người dùng hay không',
|
||||
'featureManagement.noFeatures': 'Chưa có tính năng nào',
|
||||
'featureManagement.contactAdmin': 'Vui lòng liên hệ quản trị viên để cấu hình tính năng',
|
||||
'featureManagement.saveSuccess': 'Cấu hình tính năng đã được lưu thành công',
|
||||
'featureManagement.resetConfirm': 'Bạn có chắc chắn muốn đặt lại tất cả cấu hình tính năng?',
|
||||
'featureManagement.confirm': 'Xác nhận',
|
||||
'featureManagement.cancel': 'Hủy bỏ',
|
||||
'featureManagement.resetSuccess': 'Cấu hình tính năng đã được đặt lại thành công',
|
||||
'featureManagement.noChanges': 'Không có thay đổi nào để lưu',
|
||||
|
||||
// Feature names and descriptions
|
||||
'feature.voiceprintRecognition.name': 'Nhận dạng giọng nói',
|
||||
'feature.voiceprintRecognition.description': 'Xác minh danh tính người dùng thông qua công nghệ nhận dạng giọng nói, cung cấp trải nghiệm tương tác giọng nói an toàn',
|
||||
'feature.voiceClone.name': 'Nhân bản âm sắc',
|
||||
'feature.voiceClone.description': 'Sử dụng công nghệ AI để nhân bản âm sắc cụ thể, đạt được tổng hợp giọng nói cá nhân hóa',
|
||||
'feature.knowledgeBase.name': 'Cơ sở kiến thức',
|
||||
'feature.knowledgeBase.description': 'Xây dựng và quản lý hệ thống cơ sở kiến thức, cung cấp hỗ trợ kiến thức chuyên môn cho trợ lý AI',
|
||||
'feature.mcpAccessPoint.name': 'Điểm truy cập MCP',
|
||||
'feature.mcpAccessPoint.description': 'Cung cấp điểm truy cập giao thức MCP, hỗ trợ tích hợp các công cụ và dịch vụ bên ngoài',
|
||||
'feature.vad.name': 'Phát hiện hoạt động giọng nói',
|
||||
'feature.vad.description': 'Tự động phát hiện hoạt động giọng nói, tối ưu hóa hiệu suất phản hồi tương tác giọng nói',
|
||||
'feature.asr.name': 'Nhận dạng giọng nói',
|
||||
'feature.asr.description': 'Chuyển đổi giọng nói thành văn bản, thực hiện chức năng tương tác ngôn ngữ tự nhiên',
|
||||
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export default {
|
||||
'header.clearHistory': '清空历史',
|
||||
'header.providerManagement': '字段管理',
|
||||
'header.serverSideManagement': '服务端管理',
|
||||
'header.featureManagement': '系统功能配置',
|
||||
'header.changePassword': '修改密码',
|
||||
'header.logout': '退出登录',
|
||||
'header.searchPlaceholder': '输入名称搜索..',
|
||||
@@ -229,6 +230,26 @@ export default {
|
||||
'voicePrintDialog.requiredName': '请输入姓名',
|
||||
'voicePrintDialog.requiredAudioVector': '请选择音频向量',
|
||||
|
||||
// 上下文源对话框相关
|
||||
'contextProviderDialog.title': '编辑源',
|
||||
'contextProviderDialog.noContextApi': '暂无上下文API',
|
||||
'contextProviderDialog.add': '添加',
|
||||
'contextProviderDialog.apiUrl': '接口地址',
|
||||
'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data',
|
||||
'contextProviderDialog.requestHeaders': '请求头',
|
||||
'contextProviderDialog.headerKeyPlaceholder': 'Key',
|
||||
'contextProviderDialog.headerValuePlaceholder': 'Value',
|
||||
'contextProviderDialog.noHeaders': '暂无 Headers',
|
||||
'contextProviderDialog.addHeader': '添加 Header',
|
||||
'contextProviderDialog.cancel': '取消',
|
||||
'contextProviderDialog.confirm': '确定',
|
||||
|
||||
// 角色配置页面-上下文源相关
|
||||
'roleConfig.contextProvider': '上下文源',
|
||||
'roleConfig.contextProviderSuccess': '已成功添加 {count} 个源。',
|
||||
'roleConfig.contextProviderDocLink': '如何部署上下文源',
|
||||
'roleConfig.editContextProvider': '编辑源',
|
||||
|
||||
// 声纹页面相关
|
||||
'voicePrint.pageTitle': '声纹识别',
|
||||
'voicePrint.name': '姓名',
|
||||
@@ -692,7 +713,7 @@ export default {
|
||||
'paramManagement.deleteFailed': '删除失败,请重试',
|
||||
'paramManagement.operationCancelled': '已取消删除操作',
|
||||
'paramManagement.operationClosed': '操作已关闭',
|
||||
'paramManagement.updateSuccess': '修改成功',
|
||||
'paramManagement.updateSuccess': '修改成功,部分配置需重启xiaozhi-server模块才生效',
|
||||
'paramManagement.addSuccess': '新增成功',
|
||||
'paramManagement.updateFailed': '更新失败',
|
||||
'paramManagement.addFailed': '新增失败',
|
||||
@@ -800,7 +821,7 @@ export default {
|
||||
'modelConfig.rag': '知识库',
|
||||
'modelConfig.modelId': '模型ID',
|
||||
'modelConfig.modelName': '模型名称',
|
||||
'modelConfig.provider': '提供商',
|
||||
'modelConfig.provider': '接口类型',
|
||||
'modelConfig.unknown': '未知',
|
||||
'modelConfig.isEnabled': '是否启用',
|
||||
'modelConfig.isDefault': '是否默认',
|
||||
@@ -831,7 +852,7 @@ export default {
|
||||
'modelConfig.enableSuccess': '启用成功',
|
||||
'modelConfig.disableSuccess': '禁用成功',
|
||||
'modelConfig.operationFailed': '操作失败',
|
||||
'modelConfig.setDefaultSuccess': '设置默认模型成功',
|
||||
'modelConfig.setDefaultSuccess': '设置默认模型成功,请及时手动重启xiaozhi-server模块',
|
||||
'modelConfig.itemsPerPage': '{items}条/页',
|
||||
'modelConfig.firstPage': '首页',
|
||||
'modelConfig.prevPage': '上一页',
|
||||
@@ -1262,4 +1283,35 @@ export default {
|
||||
'knowledgeFileUpload.content': '内容:',
|
||||
'knowledgeFileUpload.testQuestionRequired': '请输入测试问题',
|
||||
'knowledgeBaseDialog.descriptionRequired': '请输入知识库描述',
|
||||
|
||||
// 系统功能配置页面文本
|
||||
'featureManagement.selectAll': '全选',
|
||||
'featureManagement.deselectAll': '取消全选',
|
||||
'featureManagement.save': '保存配置',
|
||||
'featureManagement.reset': '重置',
|
||||
'featureManagement.group.featureManagement': '是否开启功能/板块',
|
||||
'featureManagement.group.voiceManagement': '配置智能体时是否对用户可见',
|
||||
'featureManagement.noFeatures': '暂无功能',
|
||||
'featureManagement.contactAdmin': '请联系管理员配置功能',
|
||||
'featureManagement.saveSuccess': '功能配置保存成功',
|
||||
'featureManagement.resetConfirm': '确定要重置所有功能配置吗?',
|
||||
'featureManagement.confirm': '确定',
|
||||
'featureManagement.cancel': '取消',
|
||||
'featureManagement.resetSuccess': '功能配置重置成功',
|
||||
'featureManagement.noChanges': '没有需要保存的更改',
|
||||
|
||||
// 功能名称和描述
|
||||
'feature.voiceprintRecognition.name': '声纹识别',
|
||||
'feature.voiceprintRecognition.description': '通过声纹识别技术验证用户身份,提供安全的语音交互体验',
|
||||
'feature.voiceClone.name': '音色克隆',
|
||||
'feature.voiceClone.description': '使用AI技术克隆特定音色,实现个性化语音合成',
|
||||
'feature.knowledgeBase.name': '知识库',
|
||||
'feature.knowledgeBase.description': '构建和管理知识库系统,为AI助手提供专业知识支持',
|
||||
'feature.mcpAccessPoint.name': 'MCP接入点',
|
||||
'feature.mcpAccessPoint.description': '提供MCP协议接入点,支持外部工具和服务的集成',
|
||||
'feature.vad.name': '语音活动检测',
|
||||
'feature.vad.description': '自动检测语音活动,优化语音交互的响应效率',
|
||||
'feature.asr.name': '语音识别',
|
||||
'feature.asr.description': '将语音转换为文本,实现自然语言交互功能',
|
||||
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export default {
|
||||
'header.clearHistory': '清空歷史',
|
||||
'header.providerManagement': '字段管理',
|
||||
'header.serverSideManagement': '服務端管理',
|
||||
'header.featureManagement': '系統功能配置',
|
||||
'header.changePassword': '修改密碼',
|
||||
'header.logout': '退出登錄',
|
||||
'header.searchPlaceholder': '輸入名稱搜索..',
|
||||
@@ -229,6 +230,26 @@ export default {
|
||||
'voicePrintDialog.requiredName': '請輸入姓名',
|
||||
'voicePrintDialog.requiredAudioVector': '請選擇音頻向量',
|
||||
|
||||
// 上下文源對話框相關
|
||||
'contextProviderDialog.title': '編輯源',
|
||||
'contextProviderDialog.noContextApi': '暫無上下文API',
|
||||
'contextProviderDialog.add': '添加',
|
||||
'contextProviderDialog.apiUrl': '接口地址',
|
||||
'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data',
|
||||
'contextProviderDialog.requestHeaders': '請求頭',
|
||||
'contextProviderDialog.headerKeyPlaceholder': 'Key',
|
||||
'contextProviderDialog.headerValuePlaceholder': 'Value',
|
||||
'contextProviderDialog.noHeaders': '暫無 Headers',
|
||||
'contextProviderDialog.addHeader': '添加 Header',
|
||||
'contextProviderDialog.cancel': '取消',
|
||||
'contextProviderDialog.confirm': '確定',
|
||||
|
||||
// 角色配置頁面-上下文源相關
|
||||
'roleConfig.contextProvider': '上下文源',
|
||||
'roleConfig.contextProviderSuccess': '已成功添加 {count} 個源。',
|
||||
'roleConfig.contextProviderDocLink': '如何部署上下文源',
|
||||
'roleConfig.editContextProvider': '編輯源',
|
||||
|
||||
// 聲紋頁面相關
|
||||
'voicePrint.pageTitle': '聲紋識別',
|
||||
'voicePrint.name': '姓名',
|
||||
@@ -692,7 +713,7 @@ export default {
|
||||
'paramManagement.deleteFailed': '刪除失敗,請重試',
|
||||
'paramManagement.operationCancelled': '已取消刪除操作',
|
||||
'paramManagement.operationClosed': '操作已關閉',
|
||||
'paramManagement.updateSuccess': '修改成功',
|
||||
'paramManagement.updateSuccess': '修改成功,部分配置需重啟xiaozhi-server模組才生效',
|
||||
'paramManagement.addSuccess': '新增成功',
|
||||
'paramManagement.updateFailed': '更新失敗',
|
||||
'paramManagement.addFailed': '新增失敗',
|
||||
@@ -800,7 +821,7 @@ export default {
|
||||
'modelConfig.rag': '知識庫',
|
||||
'modelConfig.modelId': '模型ID',
|
||||
'modelConfig.modelName': '模型名稱',
|
||||
'modelConfig.provider': '提供商',
|
||||
'modelConfig.provider': '接口類型',
|
||||
'modelConfig.unknown': '未知',
|
||||
'modelConfig.isEnabled': '是否啟用',
|
||||
'modelConfig.isDefault': '是否默認',
|
||||
@@ -831,7 +852,7 @@ export default {
|
||||
'modelConfig.enableSuccess': '啟用成功',
|
||||
'modelConfig.disableSuccess': '禁用成功',
|
||||
'modelConfig.operationFailed': '操作失敗',
|
||||
'modelConfig.setDefaultSuccess': '設置默認模型成功',
|
||||
'modelConfig.setDefaultSuccess': '設置默認模型成功,請及時手動重啟xiaozhi-server模組',
|
||||
'modelConfig.itemsPerPage': '{items}條/頁',
|
||||
'modelConfig.firstPage': '首頁',
|
||||
'modelConfig.prevPage': '上一頁',
|
||||
@@ -1262,4 +1283,35 @@ export default {
|
||||
'knowledgeFileUpload.content': '內容:',
|
||||
'knowledgeFileUpload.testQuestionRequired': '請輸入測試問題',
|
||||
'knowledgeBaseDialog.descriptionRequired': '請輸入知识库描述',
|
||||
|
||||
// 功能管理頁面文本
|
||||
'featureManagement.selectAll': '全選',
|
||||
'featureManagement.deselectAll': '取消全選',
|
||||
'featureManagement.save': '儲存配置',
|
||||
'featureManagement.reset': '重置',
|
||||
'featureManagement.group.featureManagement': '是否開啟功能/板块',
|
||||
'featureManagement.group.voiceManagement': '配置智能体時是否對用戶可見',
|
||||
'featureManagement.noFeatures': '暫無功能',
|
||||
'featureManagement.contactAdmin': '請聯繫管理員配置功能',
|
||||
'featureManagement.saveSuccess': '功能配置儲存成功',
|
||||
'featureManagement.resetConfirm': '確定要重置所有功能配置嗎?',
|
||||
'featureManagement.confirm': '確定',
|
||||
'featureManagement.cancel': '取消',
|
||||
'featureManagement.resetSuccess': '功能配置重置成功',
|
||||
'featureManagement.noChanges': '沒有需要儲存的更改',
|
||||
|
||||
// 功能名稱和描述
|
||||
'feature.voiceprintRecognition.name': '聲紋識別',
|
||||
'feature.voiceprintRecognition.description': '通過聲紋識別技術驗證用戶身份,提供安全的語音交互體驗',
|
||||
'feature.voiceClone.name': '音色複刻',
|
||||
'feature.voiceClone.description': '使用AI技術複刻特定音色,實現個性化語音合成',
|
||||
'feature.knowledgeBase.name': '知識庫',
|
||||
'feature.knowledgeBase.description': '構建和管理知識庫系統,為AI助手提供專業知識支持',
|
||||
'feature.mcpAccessPoint.name': 'MCP接入點',
|
||||
'feature.mcpAccessPoint.description': '提供MCP協議接入點,支持外部工具和服務的整合',
|
||||
'feature.vad.name': '語音活動檢測',
|
||||
'feature.vad.description': '自動檢測語音活動,優化語音交互的響應效率',
|
||||
'feature.asr.name': '語音識別',
|
||||
'feature.asr.description': '將語音轉換為文本,實現自然語言交互功能',
|
||||
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import store from './store';
|
||||
import i18n from './i18n';
|
||||
import './styles/global.scss';
|
||||
import { register as registerServiceWorker } from './registerServiceWorker';
|
||||
import featureManager from './utils/featureManager';
|
||||
|
||||
// 创建事件总线,用于组件间通信
|
||||
Vue.prototype.$eventBus = new Vue();
|
||||
|
||||
@@ -76,7 +76,8 @@ const routes = [
|
||||
return import('../views/ModelConfig.vue')
|
||||
}
|
||||
},
|
||||
{ path: '/params-management',
|
||||
{
|
||||
path: '/params-management',
|
||||
name: 'ParamsManagement',
|
||||
component: function () {
|
||||
return import('../views/ParamsManagement.vue')
|
||||
@@ -86,7 +87,8 @@ const routes = [
|
||||
title: '参数管理'
|
||||
}
|
||||
},
|
||||
{ path: '/knowledge-base-management',
|
||||
{
|
||||
path: '/knowledge-base-management',
|
||||
name: 'KnowledgeBaseManagement',
|
||||
component: function () {
|
||||
return import('../views/KnowledgeBaseManagement.vue')
|
||||
@@ -96,7 +98,8 @@ const routes = [
|
||||
title: '知识库管理'
|
||||
}
|
||||
},
|
||||
{ path: '/knowledge-file-upload',
|
||||
{
|
||||
path: '/knowledge-file-upload',
|
||||
name: 'KnowledgeFileUpload',
|
||||
component: function () {
|
||||
return import('../views/KnowledgeFileUpload.vue')
|
||||
@@ -181,6 +184,18 @@ const routes = [
|
||||
return import('../views/TemplateQuickConfig.vue')
|
||||
}
|
||||
},
|
||||
// 功能配置页面路由
|
||||
{
|
||||
path: '/feature-management',
|
||||
name: 'FeatureManagement',
|
||||
component: function () {
|
||||
return import('../views/FeatureManagement.vue')
|
||||
},
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '功能配置'
|
||||
}
|
||||
},
|
||||
]
|
||||
const router = new VueRouter({
|
||||
base: process.env.VUE_APP_PUBLIC_PATH || '/',
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//功能配置工具
|
||||
import Api from "@/apis/api";
|
||||
|
||||
class FeatureManager {
|
||||
constructor() {
|
||||
this.defaultFeatures = {
|
||||
voiceprintRecognition: {
|
||||
name: 'feature.voiceprintRecognition.name',
|
||||
enabled: false,
|
||||
description: 'feature.voiceprintRecognition.description'
|
||||
},
|
||||
voiceClone: {
|
||||
name: 'feature.voiceClone.name',
|
||||
enabled: false,
|
||||
description: 'feature.voiceClone.description'
|
||||
},
|
||||
knowledgeBase: {
|
||||
name: 'feature.knowledgeBase.name',
|
||||
enabled: false,
|
||||
description: 'feature.knowledgeBase.description'
|
||||
},
|
||||
mcpAccessPoint: {
|
||||
name: 'feature.mcpAccessPoint.name',
|
||||
enabled: false,
|
||||
description: 'feature.mcpAccessPoint.description'
|
||||
},
|
||||
vad: {
|
||||
name: 'feature.vad.name',
|
||||
enabled: false,
|
||||
description: 'feature.vad.description'
|
||||
},
|
||||
asr: {
|
||||
name: 'feature.asr.name',
|
||||
enabled: false,
|
||||
description: 'feature.asr.description'
|
||||
}
|
||||
};
|
||||
this.currentFeatures = { ...this.defaultFeatures }; // 当前内存中的配置
|
||||
this.initialized = false;
|
||||
this.initPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待初始化完成
|
||||
*/
|
||||
async waitForInitialization() {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = this.init();
|
||||
}
|
||||
await this.initPromise;
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化功能配置
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
// 从pub-config接口获取配置
|
||||
const config = await this.getConfigFromPubConfig();
|
||||
if (config) {
|
||||
this.currentFeatures = { ...config }; // 保存到内存
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('从pub-config接口获取配置失败:', error);
|
||||
}
|
||||
|
||||
// pub-config接口失败,使用默认配置
|
||||
this.currentFeatures = { ...this.defaultFeatures }; // 保存默认配置到内存
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 从pub-config接口获取配置
|
||||
*/
|
||||
async getConfigFromPubConfig() {
|
||||
return new Promise((resolve) => {
|
||||
// 直接调用pub-config接口获取配置
|
||||
Api.user.getPubConfig((result) => {
|
||||
// 检查返回结果的结构
|
||||
if (result && result.status === 200) {
|
||||
// 检查是否有data字段
|
||||
if (result.data) {
|
||||
// 检查是否有code字段,如果有则按照code判断
|
||||
if (result.data.code !== undefined) {
|
||||
if (result.data.code === 0 && result.data.data && result.data.data.systemWebMenu) {
|
||||
try {
|
||||
let config;
|
||||
if (typeof result.data.data.systemWebMenu === 'string') {
|
||||
// 如果是字符串,需要解析JSON
|
||||
config = JSON.parse(result.data.data.systemWebMenu);
|
||||
} else {
|
||||
// 如果已经是对象,直接使用
|
||||
config = result.data.data.systemWebMenu;
|
||||
}
|
||||
|
||||
// 检查配置中是否包含features对象
|
||||
if (config && config.features) {
|
||||
// 确保knowledgeBase功能存在且配置正确
|
||||
if (!config.features.knowledgeBase) {
|
||||
console.warn('配置中缺少knowledgeBase功能,合并默认配置');
|
||||
config.features = { ...this.defaultFeatures, ...config.features };
|
||||
}
|
||||
resolve(config.features);
|
||||
} else {
|
||||
console.warn('配置中缺少features对象,使用默认配置');
|
||||
resolve(this.defaultFeatures);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('处理systemWebMenu配置失败:', error);
|
||||
resolve(null);
|
||||
}
|
||||
} else {
|
||||
console.warn('接口返回code不为0或缺少必要数据,使用默认配置');
|
||||
resolve(null);
|
||||
}
|
||||
} else {
|
||||
// 如果没有code字段,直接检查systemWebMenu
|
||||
if (result.data && result.data.systemWebMenu) {
|
||||
try {
|
||||
let config;
|
||||
if (typeof result.data.systemWebMenu === 'string') {
|
||||
// 如果是字符串,需要解析JSON
|
||||
config = JSON.parse(result.data.systemWebMenu);
|
||||
} else {
|
||||
// 如果已经是对象,直接使用
|
||||
config = result.data.systemWebMenu;
|
||||
}
|
||||
|
||||
// 检查配置中是否包含features对象
|
||||
if (config && config.features) {
|
||||
// 确保knowledgeBase功能存在且配置正确
|
||||
if (!config.features.knowledgeBase) {
|
||||
console.warn('配置中缺少knowledgeBase功能,合并默认配置');
|
||||
config.features = { ...this.defaultFeatures, ...config.features };
|
||||
}
|
||||
resolve(config.features);
|
||||
} else {
|
||||
console.warn('配置中缺少features对象,使用默认配置');
|
||||
resolve(this.defaultFeatures);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('处理systemWebMenu配置失败:', error);
|
||||
resolve(null);
|
||||
}
|
||||
} else {
|
||||
console.warn('接口返回缺少systemWebMenu数据,使用默认配置');
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('接口返回数据中缺少data字段,使用默认配置');
|
||||
resolve(null);
|
||||
}
|
||||
} else {
|
||||
console.warn('pub-config接口调用失败,使用默认配置');
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
*/
|
||||
getCurrentConfig() {
|
||||
// 返回内存中的当前配置
|
||||
return this.currentFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置到后端API
|
||||
*/
|
||||
async saveConfig(config) {
|
||||
try {
|
||||
// 更新内存中的配置
|
||||
this.currentFeatures = { ...config };
|
||||
|
||||
// 异步保存到后端API
|
||||
this.saveConfigToAPI(config).catch(error => {
|
||||
console.warn('保存配置到API失败:', error);
|
||||
});
|
||||
|
||||
// 触发配置变更事件
|
||||
window.dispatchEvent(new CustomEvent('featureConfigChanged', {
|
||||
detail: config
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('保存功能配置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置到后端API
|
||||
*/
|
||||
async saveConfigToAPI(config) {
|
||||
return new Promise((resolve) => {
|
||||
// 直接使用已知的ID(600)更新参数
|
||||
Api.admin.updateParam(
|
||||
{
|
||||
id: 600,
|
||||
paramCode: 'system-web.menu',
|
||||
paramValue: JSON.stringify({
|
||||
features: config,
|
||||
groups: {
|
||||
featureManagement: ["voiceprintRecognition", "voiceClone", "knowledgeBase", "mcpAccessPoint"],
|
||||
voiceManagement: ["vad", "asr"]
|
||||
}
|
||||
}),
|
||||
valueType: 'json',
|
||||
remark: '系统功能菜单配置'
|
||||
},
|
||||
(updateResult) => {
|
||||
if (updateResult.code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
// 如果更新失败,可能是参数不存在或其他错误,记录但不阻止保存到localStorage
|
||||
console.warn('更新参数失败:', updateResult.msg);
|
||||
resolve(); // 不阻止保存到localStorage
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.warn('更新参数失败:', error);
|
||||
resolve(); // 不阻止保存到localStorage
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取所有功能配置
|
||||
*/
|
||||
getAllFeatures() {
|
||||
return this.getCurrentConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取简化的配置对象(用于首页组件)
|
||||
*/
|
||||
getConfig() {
|
||||
const features = this.getAllFeatures();
|
||||
return {
|
||||
voiceprintRecognition: features.voiceprintRecognition?.enabled || false,
|
||||
voiceClone: features.voiceClone?.enabled || false,
|
||||
knowledgeBase: features.knowledgeBase?.enabled || false,
|
||||
mcpAccessPoint: features.mcpAccessPoint?.enabled || false,
|
||||
vad: features.vad?.enabled || false,
|
||||
asr: features.asr?.enabled || false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定功能的状态
|
||||
*/
|
||||
getFeatureStatus(featureKey) {
|
||||
const features = this.getAllFeatures();
|
||||
return features[featureKey]?.enabled || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置功能状态
|
||||
*/
|
||||
setFeatureStatus(featureKey, enabled) {
|
||||
const features = this.getAllFeatures();
|
||||
if (features[featureKey]) {
|
||||
features[featureKey].enabled = enabled;
|
||||
this.saveConfig(features);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用功能
|
||||
*/
|
||||
enableFeature(featureKey) {
|
||||
return this.setFeatureStatus(featureKey, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用功能
|
||||
*/
|
||||
disableFeature(featureKey) {
|
||||
return this.setFeatureStatus(featureKey, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换功能状态
|
||||
*/
|
||||
toggleFeature(featureKey) {
|
||||
const currentStatus = this.getFeatureStatus(featureKey);
|
||||
return this.setFeatureStatus(featureKey, !currentStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有功能为默认状态
|
||||
*/
|
||||
resetToDefault() {
|
||||
this.saveConfig(this.defaultFeatures);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新功能状态
|
||||
*/
|
||||
updateFeatures(featureUpdates) {
|
||||
const features = this.getAllFeatures();
|
||||
Object.keys(featureUpdates).forEach(featureKey => {
|
||||
if (features[featureKey]) {
|
||||
features[featureKey].enabled = featureUpdates[featureKey];
|
||||
}
|
||||
});
|
||||
this.saveConfig(features);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已启用的功能列表
|
||||
*/
|
||||
getEnabledFeatures() {
|
||||
const features = this.getAllFeatures();
|
||||
return Object.keys(features).filter(key => features[key].enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查功能是否启用
|
||||
*/
|
||||
isFeatureEnabled(featureKey) {
|
||||
return this.getFeatureStatus(featureKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const featureManager = new FeatureManager();
|
||||
|
||||
export default featureManager;
|
||||
@@ -35,7 +35,7 @@
|
||||
<el-table-column :label="$t('device.bindTime')" prop="bindTime" align="center"></el-table-column>
|
||||
<el-table-column :label="$t('device.lastConversation')" prop="lastConversation"
|
||||
align="center"></el-table-column>
|
||||
<el-table-column :label="$t('device.deviceStatus')" prop="deviceStatus" align="center">
|
||||
<el-table-column v-if="mqttServiceAvailable" :label="$t('device.deviceStatus')" prop="deviceStatus" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.deviceStatus === 'online'" type="success">{{ $t('device.online') }}</el-tag>
|
||||
<el-tag v-else type="danger">{{ $t('device.offline') }}</el-tag>
|
||||
@@ -147,6 +147,7 @@ export default {
|
||||
loading: false,
|
||||
userApi: null,
|
||||
firmwareTypes: [],
|
||||
mqttServiceAvailable: false, // MQTT服务是否可用
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -392,12 +393,21 @@ export default {
|
||||
|
||||
// 直接使用解析后的数据作为设备状态映射(不需要devices字段包装)
|
||||
if (statusData && typeof statusData === 'object') {
|
||||
// 成功获取到设备状态
|
||||
this.mqttServiceAvailable = true;
|
||||
// 更新设备状态
|
||||
this.updateDeviceStatusFromResponse(statusData);
|
||||
} else {
|
||||
// 数据格式不正确,MQTT服务不可用
|
||||
this.mqttServiceAvailable = false;
|
||||
}
|
||||
} catch (error) {
|
||||
// JSON解析失败,忽略状态更新
|
||||
// JSON解析失败,MQTT服务不可用
|
||||
this.mqttServiceAvailable = false;
|
||||
}
|
||||
} else {
|
||||
// 接口调用失败,MQTT服务不可用
|
||||
this.mqttServiceAvailable = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<HeaderBar />
|
||||
|
||||
<div class="operation-bar">
|
||||
<h2 class="page-title">{{ $t('header.featureManagement') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<div class="content-panel">
|
||||
<div class="content-area">
|
||||
<el-card class="feature-card" shadow="never">
|
||||
<div class="config-header">
|
||||
<div class="header-icon">
|
||||
<img loading="lazy" src="@/assets/home/equipment.png" alt="" />
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="!isSaving && toggleSelectAll()" class="btn-select-all" :disabled="isSaving">
|
||||
{{ isAllSelected ? $t('featureManagement.deselectAll') : $t('featureManagement.selectAll') }}
|
||||
</el-button>
|
||||
<el-button type="primary" class="save-btn" @click="handleSave" :disabled="isSaving">
|
||||
{{ isSaving ? $t('featureManagement.saving') : $t('featureManagement.save') }}
|
||||
</el-button>
|
||||
<el-button class="reset-btn" @click="handleReset" :disabled="isSaving">
|
||||
{{ $t('featureManagement.reset') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- 功能分组容器 - 左右布局 -->
|
||||
<div class="feature-groups-container">
|
||||
<!-- 功能管理分组 -->
|
||||
<div v-if="featureManagementFeatures.length > 0" class="feature-group">
|
||||
<h3 class="group-title">{{ $t('featureManagement.group.featureManagement') }}</h3>
|
||||
<div class="features-grid">
|
||||
<div
|
||||
v-for="feature in featureManagementFeatures"
|
||||
:key="feature.id"
|
||||
class="feature-card-item"
|
||||
:class="{ 'feature-enabled': feature.enabled, 'feature-disabled': isSaving }"
|
||||
@click="!isSaving && toggleFeature(feature)"
|
||||
>
|
||||
<div class="feature-header">
|
||||
<h3 class="feature-name">{{ $t(`feature.${feature.id}.name`) }}</h3>
|
||||
<el-checkbox
|
||||
v-model="feature.enabled"
|
||||
@change="!isSaving && toggleFeature(feature)"
|
||||
class="feature-checkbox"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
</div>
|
||||
<p class="feature-description">{{ $t(`feature.${feature.id}.description`) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 语音管理分组 -->
|
||||
<div v-if="voiceManagementFeatures.length > 0" class="feature-group">
|
||||
<h3 class="group-title">{{ $t('featureManagement.group.voiceManagement') }}</h3>
|
||||
<div class="features-grid">
|
||||
<div
|
||||
v-for="feature in voiceManagementFeatures"
|
||||
:key="feature.id"
|
||||
class="feature-card-item"
|
||||
:class="{ 'feature-enabled': feature.enabled, 'feature-disabled': isSaving }"
|
||||
@click="!isSaving && toggleFeature(feature)"
|
||||
>
|
||||
<div class="feature-header">
|
||||
<h3 class="feature-name">{{ $t(`feature.${feature.id}.name`) }}</h3>
|
||||
<el-checkbox
|
||||
v-model="feature.enabled"
|
||||
@change="!isSaving && toggleFeature(feature)"
|
||||
class="feature-checkbox"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
</div>
|
||||
<p class="feature-description">{{ $t(`feature.${feature.id}.description`) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredFeatures.length === 0" class="empty-state">
|
||||
<el-empty :description="$t('featureManagement.noFeatures')">
|
||||
<p class="empty-tip">{{ $t('featureManagement.contactAdmin') }}</p>
|
||||
</el-empty>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-footer>
|
||||
<VersionFooter />
|
||||
</el-footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
import VersionFooter from "@/components/VersionFooter.vue";
|
||||
import featureManager from "@/utils/featureManager.js";
|
||||
|
||||
export default {
|
||||
name: "FeatureManagement",
|
||||
components: {
|
||||
HeaderBar,
|
||||
VersionFooter
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
pendingChanges: false,
|
||||
featureManagementFeatures: [],
|
||||
voiceManagementFeatures: [],
|
||||
isSaving: false // 添加保存状态锁定
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 所有功能列表
|
||||
filteredFeatures() {
|
||||
return [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
|
||||
},
|
||||
|
||||
// 判断是否所有功能都已选中
|
||||
isAllSelected() {
|
||||
const allFeatures = [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
|
||||
return allFeatures.length > 0 && allFeatures.every(feature => feature.enabled)
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
// 等待功能配置管理器初始化完成
|
||||
try {
|
||||
console.log('等待功能配置管理器初始化...')
|
||||
await featureManager.waitForInitialization()
|
||||
console.log('功能配置管理器初始化完成,开始加载功能配置')
|
||||
await this.loadFeatures()
|
||||
this.setupConfigChangeListener()
|
||||
} catch (error) {
|
||||
console.error('功能配置管理器初始化等待失败:', error)
|
||||
await this.loadFeatures()
|
||||
this.setupConfigChangeListener()
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.removeConfigChangeListener()
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 根据ID列表获取功能
|
||||
async getFeaturesByIds(featureIds) {
|
||||
try {
|
||||
const featureConfig = await featureManager.getAllFeatures()
|
||||
console.log('获取到的功能配置:', JSON.stringify(featureConfig, null, 2))
|
||||
console.log('请求的功能ID列表:', featureIds)
|
||||
|
||||
const result = featureIds.map(id => {
|
||||
const feature = featureConfig[id]
|
||||
console.log(`功能 ${id} 的配置:`, feature)
|
||||
console.log(`功能 ${id} 的启用状态:`, feature?.enabled)
|
||||
|
||||
return {
|
||||
id: id,
|
||||
name: this.$t(`feature.${id}.name`),
|
||||
description: this.$t(`feature.${id}.description`),
|
||||
enabled: feature?.enabled || false
|
||||
}
|
||||
})
|
||||
|
||||
console.log('最终返回的功能列表:', JSON.stringify(result, null, 2))
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('获取功能配置失败:', error)
|
||||
// 如果获取失败,返回默认配置
|
||||
return featureIds.map(id => ({
|
||||
id: id,
|
||||
name: this.$t(`feature.${id}.name`),
|
||||
description: this.$t(`feature.${id}.description`),
|
||||
enabled: false
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
// 加载功能配置
|
||||
async loadFeatures() {
|
||||
// 保存当前用户的选择状态
|
||||
const currentFeatureStates = {}
|
||||
const allCurrentFeatures = [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
|
||||
allCurrentFeatures.forEach(feature => {
|
||||
currentFeatureStates[feature.id] = feature.enabled
|
||||
})
|
||||
|
||||
// 重新加载配置
|
||||
this.featureManagementFeatures = await this.getFeaturesByIds(['voiceprintRecognition', 'voiceClone', 'knowledgeBase', 'mcpAccessPoint'])
|
||||
this.voiceManagementFeatures = await this.getFeaturesByIds(['vad', 'asr'])
|
||||
|
||||
// 恢复用户的选择状态(如果存在)
|
||||
const allFeatures = [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
|
||||
allFeatures.forEach(feature => {
|
||||
if (currentFeatureStates.hasOwnProperty(feature.id)) {
|
||||
feature.enabled = currentFeatureStates[feature.id]
|
||||
}
|
||||
})
|
||||
},
|
||||
// 切换功能状态
|
||||
async toggleFeature(feature) {
|
||||
// 如果正在保存,阻止操作
|
||||
if (this.isSaving) {
|
||||
return
|
||||
}
|
||||
|
||||
feature.enabled = !feature.enabled
|
||||
this.pendingChanges = true
|
||||
|
||||
// 不再立即更新到配置管理器,只在保存时统一更新
|
||||
},
|
||||
// 保存配置
|
||||
async handleSave() {
|
||||
if (!this.pendingChanges) {
|
||||
this.$message.info({
|
||||
message: this.$t('featureManagement.noChanges'),
|
||||
showClose: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置保存状态,锁定界面
|
||||
this.isSaving = true
|
||||
|
||||
try {
|
||||
// 获取当前所有功能的状态并保存
|
||||
const featureUpdates = {}
|
||||
const allFeatures = [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
|
||||
allFeatures.forEach(feature => {
|
||||
featureUpdates[feature.id] = feature.enabled
|
||||
})
|
||||
await featureManager.updateFeatures(featureUpdates)
|
||||
|
||||
this.pendingChanges = false
|
||||
this.$message.success({
|
||||
message: this.$t('featureManagement.saveSuccess'),
|
||||
showClose: true
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
this.loadFeatures()
|
||||
this.$router.go(0)
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
this.$message.error({
|
||||
message: this.$t('featureManagement.saveError'),
|
||||
showClose: true
|
||||
})
|
||||
} finally {
|
||||
// 无论成功与否,都解除保存状态锁定
|
||||
this.isSaving = false
|
||||
}
|
||||
},
|
||||
// 设置配置变化监听器
|
||||
setupConfigChangeListener() {
|
||||
this.configChangeHandler = () => {
|
||||
console.log('检测到配置变化,重新加载功能列表')
|
||||
this.loadFeatures()
|
||||
}
|
||||
window.addEventListener('featureConfigReloaded', this.configChangeHandler)
|
||||
},
|
||||
|
||||
// 移除配置变化监听器
|
||||
removeConfigChangeListener() {
|
||||
if (this.configChangeHandler) {
|
||||
window.removeEventListener('featureConfigReloaded', this.configChangeHandler)
|
||||
}
|
||||
},
|
||||
|
||||
// 重置配置
|
||||
async handleReset() {
|
||||
try {
|
||||
await this.$confirm(
|
||||
this.$t('featureManagement.resetConfirm'),
|
||||
this.$t('featureManagement.reset'),
|
||||
{
|
||||
confirmButtonText: this.$t('featureManagement.confirm'),
|
||||
cancelButtonText: this.$t('featureManagement.cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
featureManager.resetToDefault()
|
||||
this.loadFeatures()
|
||||
this.pendingChanges = false
|
||||
|
||||
this.$message.success({
|
||||
message: this.$t('featureManagement.resetSuccess'),
|
||||
showClose: true
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
this.loadFeatures()
|
||||
this.$router.go(0)
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
// 用户取消操作
|
||||
}
|
||||
},
|
||||
// 搜索功能(预留接口)
|
||||
handleSearch() {
|
||||
// 搜索功能待实现
|
||||
},
|
||||
// 全选/取消全选
|
||||
toggleSelectAll() {
|
||||
// 如果正在保存,阻止操作
|
||||
if (this.isSaving) {
|
||||
return
|
||||
}
|
||||
|
||||
const allFeatures = [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
|
||||
const newStatus = !this.isAllSelected
|
||||
|
||||
allFeatures.forEach(feature => {
|
||||
feature.enabled = newStatus
|
||||
})
|
||||
|
||||
this.pendingChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.welcome {
|
||||
min-width: 900px;
|
||||
min-height: 506px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
background-size: cover;
|
||||
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
|
||||
-webkit-background-size: cover;
|
||||
-o-background-size: cover;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.operation-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #5778ff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.header-icon img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #e0e0e0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.btn-select-all {
|
||||
background: #e6ebff;
|
||||
color: #5778ff;
|
||||
border: 1px solid #adbdff;
|
||||
border-radius: 18px;
|
||||
padding: 8px 16px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-select-all:hover {
|
||||
background: #d0d8ff;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: #5778ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
padding: 8px 16px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
background: #4a6ae8;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: #e6ebff;
|
||||
color: #5778ff;
|
||||
border: 1px solid #adbdff;
|
||||
border-radius: 18px;
|
||||
padding: 8px 16px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
background: #d0d8ff;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
margin: 0 22px 5px 22px;
|
||||
border-radius: 15px;
|
||||
min-height: calc(100vh - 24vh);
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
background: rgba(237, 242, 255, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border-radius: 15px;
|
||||
background: transparent;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 600px;
|
||||
overflow: auto;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: white;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-card ::v-deep .el-card__body {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.feature-card-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feature-card-item:hover {
|
||||
border-color: #869bf0;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.feature-card-item.feature-enabled {
|
||||
border-color:#5778ff;
|
||||
box-shadow: 0 4px 16px rgba(95, 112, 243, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.feature-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature-checkbox ::v-deep .el-checkbox__input {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.feature-checkbox ::v-deep .el-checkbox__input.is-checked .el-checkbox__inner {
|
||||
background-color: #5778ff;
|
||||
border-color: #5778ff;
|
||||
}
|
||||
|
||||
.feature-checkbox ::v-deep .el-checkbox__input.is-checked + .el-checkbox__label {
|
||||
color: #5778ff;
|
||||
}
|
||||
|
||||
|
||||
.feature-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
.feature-description {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
margin: 0 0 12px 0;
|
||||
transition: color 0.3s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
/* 功能分组容器 - 左右布局 */
|
||||
.feature-groups-container {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 分组之间的分隔线 */
|
||||
.feature-groups-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
height: 550px;
|
||||
background: #e0e0e0;
|
||||
opacity: 0.5;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* 分组样式 */
|
||||
.feature-group {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 12px;
|
||||
border-left: 4px solid #5f70f3;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -39,8 +39,9 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" @configure="goToRoleConfig"
|
||||
@deviceManage="handleDeviceManage" @delete="handleDeleteAgent" @chat-history="handleShowChatHistory" />
|
||||
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" :feature-status="featureStatus"
|
||||
@configure="goToRoleConfig" @deviceManage="handleDeviceManage" @delete="handleDeleteAgent"
|
||||
@chat-history="handleShowChatHistory" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,6 +62,7 @@ import ChatHistoryDialog from '@/components/ChatHistoryDialog.vue';
|
||||
import DeviceItem from '@/components/DeviceItem.vue';
|
||||
import HeaderBar from '@/components/HeaderBar.vue';
|
||||
import VersionFooter from '@/components/VersionFooter.vue';
|
||||
import featureManager from '@/utils/featureManager';
|
||||
|
||||
export default {
|
||||
name: 'HomePage',
|
||||
@@ -76,15 +78,33 @@ export default {
|
||||
skeletonCount: localStorage.getItem('skeletonCount') || 8,
|
||||
showChatHistory: false,
|
||||
currentAgentId: '',
|
||||
currentAgentName: ''
|
||||
currentAgentName: '',
|
||||
// 功能状态
|
||||
featureStatus: {
|
||||
voiceprintRecognition: false,
|
||||
voiceClone: false,
|
||||
knowledgeBase: false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
async mounted() {
|
||||
this.fetchAgentList();
|
||||
await this.loadFeatureStatus();
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 加载功能状态
|
||||
async loadFeatureStatus() {
|
||||
await featureManager.waitForInitialization();
|
||||
const config = featureManager.getConfig();
|
||||
this.featureStatus = {
|
||||
voiceprintRecognition: config.voiceprintRecognition,
|
||||
voiceClone: config.voiceClone,
|
||||
knowledgeBase: config.knowledgeBase
|
||||
};
|
||||
},
|
||||
|
||||
showAddDialog() {
|
||||
this.addDeviceDialogVisible = true
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
gap: 10px;
|
||||
">
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px; height: 45px" />
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="height: 18px" />
|
||||
<img loading="lazy" alt="" :src="xiaozhiAiIcon" style="height: 18px" />
|
||||
</div>
|
||||
</el-header>
|
||||
<div class="login-person">
|
||||
@@ -156,6 +156,7 @@ import VersionFooter from "@/components/VersionFooter.vue";
|
||||
import i18n, { changeLanguage } from "@/i18n";
|
||||
import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from "@/utils";
|
||||
import { mapState } from "vuex";
|
||||
import featureManager from "@/utils/featureManager";
|
||||
|
||||
export default {
|
||||
name: "login",
|
||||
@@ -191,6 +192,24 @@ export default {
|
||||
return this.$t("language.zhCN");
|
||||
}
|
||||
},
|
||||
// 根据当前语言获取对应的xiaozhi-ai图标
|
||||
xiaozhiAiIcon() {
|
||||
const currentLang = this.currentLanguage;
|
||||
switch (currentLang) {
|
||||
case "zh_CN":
|
||||
return require("@/assets/xiaozhi-ai.png");
|
||||
case "zh_TW":
|
||||
return require("@/assets/xiaozhi-ai_zh_TW.png");
|
||||
case "en":
|
||||
return require("@/assets/xiaozhi-ai_en.png");
|
||||
case "de":
|
||||
return require("@/assets/xiaozhi-ai_de.png");
|
||||
case "vi":
|
||||
return require("@/assets/xiaozhi-ai_vi.png");
|
||||
default:
|
||||
return require("@/assets/xiaozhi-ai.png");
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -214,6 +233,13 @@ export default {
|
||||
this.$store.dispatch("fetchPubConfig").then(() => {
|
||||
// 根据配置决定默认登录方式
|
||||
this.isMobileLogin = this.enableMobileRegister;
|
||||
|
||||
// pub-config接口调用完成后,重新初始化featureManager以确保使用最新的配置
|
||||
featureManager.waitForInitialization().then(() => {
|
||||
console.log('featureManager重新初始化完成,使用pub-config配置');
|
||||
}).catch(error => {
|
||||
console.warn('featureManager重新初始化失败:', error);
|
||||
});
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<el-header>
|
||||
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;" />
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="height: 18px;" />
|
||||
<img loading="lazy" alt="" :src="xiaozhiAiIcon" style="height: 18px;" />
|
||||
</div>
|
||||
</el-header>
|
||||
<div class="login-person">
|
||||
@@ -108,7 +108,7 @@
|
||||
<div style="font-size: 14px;color: #979db1;">
|
||||
{{ $t('register.agreeTo') }}
|
||||
<div style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.userAgreement') }}</div>
|
||||
{{ $t('register.and') }}
|
||||
{{ $t('login.and') }}
|
||||
<div style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.privacyPolicy') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,6 +127,7 @@ import Api from '@/apis/api';
|
||||
import VersionFooter from '@/components/VersionFooter.vue';
|
||||
import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from '@/utils';
|
||||
import { mapState } from 'vuex';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
// 导入语言切换功能
|
||||
|
||||
@@ -142,6 +143,28 @@ export default {
|
||||
mobileAreaList: state => state.pubConfig.mobileAreaList,
|
||||
sm2PublicKey: state => state.pubConfig.sm2PublicKey,
|
||||
}),
|
||||
// 获取当前语言
|
||||
currentLanguage() {
|
||||
return i18n.locale || "zh_CN";
|
||||
},
|
||||
// 根据当前语言获取对应的xiaozhi-ai图标
|
||||
xiaozhiAiIcon() {
|
||||
const currentLang = this.currentLanguage;
|
||||
switch (currentLang) {
|
||||
case "zh_CN":
|
||||
return require("@/assets/xiaozhi-ai.png");
|
||||
case "zh_TW":
|
||||
return require("@/assets/xiaozhi-ai_zh_TW.png");
|
||||
case "en":
|
||||
return require("@/assets/xiaozhi-ai_en.png");
|
||||
case "de":
|
||||
return require("@/assets/xiaozhi-ai_de.png");
|
||||
case "vi":
|
||||
return require("@/assets/xiaozhi-ai_vi.png");
|
||||
default:
|
||||
return require("@/assets/xiaozhi-ai.png");
|
||||
}
|
||||
},
|
||||
canSendMobileCaptcha() {
|
||||
return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<el-header>
|
||||
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;" />
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="height: 18px;" />
|
||||
<img loading="lazy" alt="" :src="xiaozhiAiIcon" style="height: 18px;" />
|
||||
</div>
|
||||
</el-header>
|
||||
<div class="login-person">
|
||||
@@ -83,7 +83,7 @@
|
||||
<div style="font-size: 14px;color: #979db1;">
|
||||
{{ $t('retrievePassword.agreeTo') }}
|
||||
<div style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.userAgreement') }}</div>
|
||||
{{ $t('register.and') }}
|
||||
{{ $t('login.and') }}
|
||||
<div style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.privacyPolicy') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,6 +103,7 @@ import Api from '@/apis/api';
|
||||
import VersionFooter from '@/components/VersionFooter.vue';
|
||||
import { getUUID, goToPage, showDanger, showSuccess, validateMobile, sm2Encrypt } from '@/utils';
|
||||
import { mapState } from 'vuex';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
// 导入语言切换功能
|
||||
import { changeLanguage } from '@/i18n';
|
||||
@@ -118,6 +119,28 @@ export default {
|
||||
mobileAreaList: state => state.pubConfig.mobileAreaList,
|
||||
sm2PublicKey: state => state.pubConfig.sm2PublicKey
|
||||
}),
|
||||
// 获取当前语言
|
||||
currentLanguage() {
|
||||
return i18n.locale || "zh_CN";
|
||||
},
|
||||
// 根据当前语言获取对应的xiaozhi-ai图标
|
||||
xiaozhiAiIcon() {
|
||||
const currentLang = this.currentLanguage;
|
||||
switch (currentLang) {
|
||||
case "zh_CN":
|
||||
return require("@/assets/xiaozhi-ai.png");
|
||||
case "zh_TW":
|
||||
return require("@/assets/xiaozhi-ai_zh_TW.png");
|
||||
case "en":
|
||||
return require("@/assets/xiaozhi-ai_en.png");
|
||||
case "de":
|
||||
return require("@/assets/xiaozhi-ai_de.png");
|
||||
case "vi":
|
||||
return require("@/assets/xiaozhi-ai_vi.png");
|
||||
default:
|
||||
return require("@/assets/xiaozhi-ai.png");
|
||||
}
|
||||
},
|
||||
canSendMobileCaptcha() {
|
||||
return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode);
|
||||
}
|
||||
|
||||
@@ -55,10 +55,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('roleConfig.contextProvider') + ':'" class="context-provider-item">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<span style="color: #606266; font-size: 13px;">
|
||||
{{ $t('roleConfig.contextProviderSuccess', { count: currentContextProviders.length }) }}<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/context-provider-integration.md" target="_blank" class="doc-link">{{ $t('roleConfig.contextProviderDocLink') }}</a>
|
||||
</span>
|
||||
<el-button
|
||||
class="edit-function-btn"
|
||||
size="small"
|
||||
@click="openContextProviderDialog"
|
||||
>
|
||||
{{ $t('roleConfig.editContextProvider') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('roleConfig.roleIntroduction') + ':'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
rows="9"
|
||||
rows="8"
|
||||
resize="none"
|
||||
:placeholder="$t('roleConfig.pleaseEnterContent')"
|
||||
v-model="form.systemPrompt"
|
||||
@@ -71,7 +85,7 @@
|
||||
<el-form-item :label="$t('roleConfig.memoryHis') + ':'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
rows="6"
|
||||
rows="4"
|
||||
resize="none"
|
||||
v-model="form.summaryMemory"
|
||||
maxlength="2000"
|
||||
@@ -107,7 +121,11 @@
|
||||
</div>
|
||||
<div class="form-column">
|
||||
<div class="model-row">
|
||||
<el-form-item :label="$t('roleConfig.vad')" class="model-item">
|
||||
<el-form-item
|
||||
v-if="featureStatus.vad"
|
||||
:label="$t('roleConfig.vad')"
|
||||
class="model-item"
|
||||
>
|
||||
<div class="model-select-wrapper">
|
||||
<el-select
|
||||
v-model="form.model.vadModelId"
|
||||
@@ -125,7 +143,11 @@
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('roleConfig.asr')" class="model-item">
|
||||
<el-form-item
|
||||
v-if="featureStatus.asr"
|
||||
:label="$t('roleConfig.asr')"
|
||||
class="model-item"
|
||||
>
|
||||
<div class="model-select-wrapper">
|
||||
<el-select
|
||||
v-model="form.model.asrModelId"
|
||||
@@ -267,6 +289,11 @@
|
||||
@update-functions="handleUpdateFunctions"
|
||||
@dialog-closed="handleDialogClosed"
|
||||
/>
|
||||
<context-provider-dialog
|
||||
:visible.sync="showContextProviderDialog"
|
||||
:providers="currentContextProviders"
|
||||
@confirm="handleUpdateContext"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -275,14 +302,17 @@ import Api from "@/apis/api";
|
||||
import { getServiceUrl } from "@/apis/api";
|
||||
import RequestService from "@/apis/httpRequest";
|
||||
import FunctionDialog from "@/components/FunctionDialog.vue";
|
||||
import ContextProviderDialog from "@/components/ContextProviderDialog.vue";
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
import i18n from "@/i18n";
|
||||
import featureManager from "@/utils/featureManager";
|
||||
|
||||
export default {
|
||||
name: "RoleConfigPage",
|
||||
components: { HeaderBar, FunctionDialog },
|
||||
components: { HeaderBar, FunctionDialog, ContextProviderDialog },
|
||||
data() {
|
||||
return {
|
||||
showContextProviderDialog: false,
|
||||
form: {
|
||||
agentCode: "",
|
||||
agentName: "",
|
||||
@@ -320,12 +350,18 @@ export default {
|
||||
voiceDetails: {}, // 保存完整的音色信息
|
||||
showFunctionDialog: false,
|
||||
currentFunctions: [],
|
||||
currentContextProviders: [],
|
||||
allFunctions: [],
|
||||
originalFunctions: [],
|
||||
playingVoice: false,
|
||||
isPaused: false,
|
||||
currentAudio: null,
|
||||
currentPlayingVoiceId: null,
|
||||
// 功能状态
|
||||
featureStatus: {
|
||||
vad: false, // 语言检测活动功能状态
|
||||
asr: false, // 语音识别功能状态
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
@@ -356,6 +392,7 @@ export default {
|
||||
paramInfo: item.params,
|
||||
};
|
||||
}),
|
||||
contextProviders: this.currentContextProviders,
|
||||
};
|
||||
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
@@ -472,6 +509,9 @@ export default {
|
||||
};
|
||||
// 后端只给了最小映射:[{ id, agentId, pluginId }, ...]
|
||||
const savedMappings = data.data.functions || [];
|
||||
|
||||
// 加载上下文配置
|
||||
this.currentContextProviders = data.data.contextProviders || [];
|
||||
|
||||
// 先保证 allFunctions 已经加载(如果没有,则先 fetchAllFunctions)
|
||||
const ensureFuncs = this.allFunctions.length
|
||||
@@ -646,6 +686,12 @@ export default {
|
||||
this.showFunctionDialog = true;
|
||||
}
|
||||
},
|
||||
openContextProviderDialog() {
|
||||
this.showContextProviderDialog = true;
|
||||
},
|
||||
handleUpdateContext(providers) {
|
||||
this.currentContextProviders = providers;
|
||||
},
|
||||
handleUpdateFunctions(selected) {
|
||||
this.currentFunctions = selected;
|
||||
},
|
||||
@@ -980,6 +1026,19 @@ export default {
|
||||
this.form.chatHistoryConf = 0;
|
||||
}
|
||||
},
|
||||
// 加载功能状态
|
||||
async loadFeatureStatus() {
|
||||
try {
|
||||
// 确保featureManager已初始化完成
|
||||
await featureManager.waitForInitialization();
|
||||
const config = featureManager.getConfig();
|
||||
this.featureStatus.voiceprintRecognition = config.voiceprintRecognition || false;
|
||||
this.featureStatus.vad = config.vad || false;
|
||||
this.featureStatus.asr = config.asr || false;
|
||||
} catch (error) {
|
||||
console.error("加载功能状态失败:", error);
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"form.model.ttsModelId": {
|
||||
@@ -1002,7 +1061,7 @@ export default {
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
const agentId = this.$route.query.agentId;
|
||||
if (agentId) {
|
||||
this.fetchAgentConfig(agentId);
|
||||
@@ -1010,6 +1069,8 @@ export default {
|
||||
}
|
||||
this.fetchModelOptions();
|
||||
this.fetchTemplates();
|
||||
// 加载功能状态,确保featureManager已初始化
|
||||
await this.loadFeatureStatus();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1298,6 +1359,26 @@ export default {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-history-options ::v-deep .el-radio-button {
|
||||
border-color: #5778ff;
|
||||
}
|
||||
|
||||
.chat-history-options ::v-deep .el-radio-button .el-radio-button__inner {
|
||||
color: #5778ff;
|
||||
border-color: #5778ff;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.chat-history-options ::v-deep .el-radio-button.is-active .el-radio-button__inner {
|
||||
background-color: #5778ff;
|
||||
border-color: #5778ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chat-history-options ::v-deep .el-radio-button .el-radio-button__inner:hover {
|
||||
color: #5778ff;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1345,4 +1426,18 @@ export default {
|
||||
height: 32px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.context-provider-item ::v-deep .el-form-item__label {
|
||||
line-height: 42px !important;
|
||||
}
|
||||
|
||||
.doc-link {
|
||||
color: #5778ff;
|
||||
text-decoration: none;
|
||||
margin-left: 4px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
- **今天农历:** {{lunar_date}}
|
||||
- **用户所在城市:** {{local_address}}
|
||||
- **当地未来7天天气:** {{weather_info}}
|
||||
{{ dynamic_context }}
|
||||
</context>
|
||||
|
||||
<memory>
|
||||
|
||||
@@ -3,11 +3,11 @@ import uuid
|
||||
import signal
|
||||
import asyncio
|
||||
from aioconsole import ainput
|
||||
from config.settings import load_config
|
||||
from config.config_loader import load_config
|
||||
from config.logger import setup_logging
|
||||
from core.utils.util import get_local_ip, validate_mcp_endpoint
|
||||
from core.http_server import SimpleHttpServer
|
||||
from core.websocket_server import WebSocketServer
|
||||
from core.xiaozhi_server_facade import XiaozhiServerFacade
|
||||
from core.utils.util import check_ffmpeg_installed
|
||||
from core.utils.gc_manager import get_gc_manager
|
||||
|
||||
@@ -68,9 +68,9 @@ async def main():
|
||||
gc_manager = get_gc_manager(interval_seconds=300)
|
||||
await gc_manager.start()
|
||||
|
||||
# 启动 WebSocket 服务器
|
||||
ws_server = WebSocketServer(config)
|
||||
ws_task = asyncio.create_task(ws_server.start())
|
||||
# 启动小智服务器门面(支持WebSocket和MQTT)
|
||||
xiaozhi_server = XiaozhiServerFacade(config)
|
||||
xiaozhi_task = asyncio.create_task(xiaozhi_server.start())
|
||||
# 启动 Simple http 服务器
|
||||
ota_server = SimpleHttpServer(config)
|
||||
ota_task = asyncio.create_task(ota_server.start())
|
||||
@@ -100,24 +100,52 @@ async def main():
|
||||
logger.bind(tag=TAG).error("mcp接入点不符合规范")
|
||||
config["mcp_endpoint"] = "你的接入点 websocket地址"
|
||||
|
||||
# 获取WebSocket配置,使用安全的默认值
|
||||
websocket_port = 8000
|
||||
server_config = config.get("server", {})
|
||||
if isinstance(server_config, dict):
|
||||
websocket_port = int(server_config.get("port", 8000))
|
||||
|
||||
logger.bind(tag=TAG).info(
|
||||
"Websocket地址是\tws://{}:{}/xiaozhi/v1/",
|
||||
get_local_ip(),
|
||||
websocket_port,
|
||||
)
|
||||
|
||||
logger.bind(tag=TAG).info(
|
||||
"=======上面的地址是websocket协议地址,请勿用浏览器访问======="
|
||||
)
|
||||
logger.bind(tag=TAG).info(
|
||||
"如想测试websocket请用谷歌浏览器打开test目录下的test_page.html"
|
||||
)
|
||||
# 显示协议连接信息
|
||||
connection_info = xiaozhi_server.get_connection_info()
|
||||
|
||||
# WebSocket信息
|
||||
websocket_info = connection_info.get('websocket', {})
|
||||
if websocket_info.get('enabled', False):
|
||||
websocket_port = websocket_info.get('port', 8000)
|
||||
logger.bind(tag=TAG).info(
|
||||
"WebSocket地址是\tws://{}:{}/xiaozhi/v1/",
|
||||
get_local_ip(),
|
||||
websocket_port,
|
||||
)
|
||||
|
||||
# MQTT信息
|
||||
mqtt_info = connection_info.get('mqtt', {})
|
||||
if mqtt_info.get('enabled', False):
|
||||
mqtt_port = mqtt_info.get('port', 1883)
|
||||
udp_port = mqtt_info.get('udp_port', 1883)
|
||||
logger.bind(tag=TAG).info(
|
||||
"MQTT地址是\t\tmqtt://{}:{}",
|
||||
get_local_ip(),
|
||||
mqtt_port,
|
||||
)
|
||||
logger.bind(tag=TAG).info(
|
||||
"UDP音频端口是\t{}:{}",
|
||||
get_local_ip(),
|
||||
udp_port,
|
||||
)
|
||||
|
||||
# 显示启用的协议
|
||||
enabled_protocols = xiaozhi_server.config.get('enabled_protocols', [])
|
||||
logger.bind(tag=TAG).info(f"启用的协议: {', '.join(enabled_protocols)}")
|
||||
|
||||
if 'websocket' in enabled_protocols:
|
||||
logger.bind(tag=TAG).info(
|
||||
"=======上面的WebSocket地址请勿用浏览器访问======="
|
||||
)
|
||||
logger.bind(tag=TAG).info(
|
||||
"如想测试WebSocket请用谷歌浏览器打开test目录下的test_page.html"
|
||||
)
|
||||
|
||||
if 'mqtt' in enabled_protocols:
|
||||
logger.bind(tag=TAG).info(
|
||||
"=======MQTT客户端ID格式: GID_test@@@mac_address@@@uuid======="
|
||||
)
|
||||
|
||||
logger.bind(tag=TAG).info(
|
||||
"=============================================================\n"
|
||||
)
|
||||
@@ -127,18 +155,28 @@ async def main():
|
||||
except asyncio.CancelledError:
|
||||
print("任务被取消,清理资源中...")
|
||||
finally:
|
||||
# 停止小智服务器
|
||||
try:
|
||||
await xiaozhi_server.stop()
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"停止小智服务器失败: {e}")
|
||||
|
||||
# 停止全局GC管理器
|
||||
await gc_manager.stop()
|
||||
|
||||
# 取消所有任务(关键修复点)
|
||||
stdin_task.cancel()
|
||||
ws_task.cancel()
|
||||
xiaozhi_task.cancel()
|
||||
if ota_task:
|
||||
ota_task.cancel()
|
||||
|
||||
# 等待任务终止(必须加超时)
|
||||
tasks_to_wait = [stdin_task, xiaozhi_task]
|
||||
if ota_task:
|
||||
tasks_to_wait.append(ota_task)
|
||||
|
||||
await asyncio.wait(
|
||||
[stdin_task, ws_task, ota_task] if ota_task else [stdin_task, ws_task],
|
||||
tasks_to_wait,
|
||||
timeout=3.0,
|
||||
return_when=asyncio.ALL_COMPLETED,
|
||||
)
|
||||
|
||||
@@ -31,16 +31,70 @@ server:
|
||||
auth:
|
||||
# 是否启用认证
|
||||
enabled: false
|
||||
# 设备的token,可以在编译固件的环节,写入你自己定义的token
|
||||
# 固件上的token和以下的token如果能对应,才能连接本服务端
|
||||
tokens:
|
||||
- token: "your-token1" # 设备1的token
|
||||
name: "your-device-name1" # 设备1标识
|
||||
- token: "your-token2" # 设备2的token
|
||||
name: "your-device-name2" # 设备2标识
|
||||
# 白名单设备ID列表
|
||||
# 如果属于白名单内的设备,不校验token,直接放行
|
||||
allowed_devices:
|
||||
- "11:22:33:44:55:66"
|
||||
# MQTT网关配置,用于通过OTA下发到设备,根据mqtt_gateway的.env文件配置,格式为host:port
|
||||
# MQTT网关配置,用于通过OTA下发到设备,根据mqtt_gateway的.env文件配置,格式为host:port
|
||||
mqtt_gateway: null
|
||||
# MQTT签名密钥,用于生成MQTT连接密码,根据mqtt_gateway的.env文件配置
|
||||
mqtt_signature_key: null
|
||||
# UDP网关配置
|
||||
udp_gateway: null
|
||||
|
||||
# #####################################################################################
|
||||
# #############################协议配置(Protocol Configuration)########################
|
||||
# 支持WebSocket和MQTT两种协议,可以单独启用或同时启用
|
||||
protocols:
|
||||
# 启用的协议列表,可选值: ["websocket", "mqtt"]
|
||||
enabled_protocols: ["websocket"] # 默认只启用WebSocket
|
||||
# WebSocket协议开关
|
||||
websocket_enabled: true
|
||||
# MQTT协议开关
|
||||
mqtt_enabled: false
|
||||
|
||||
# MQTT服务器配置(仅在mqtt_enabled为true时生效)
|
||||
mqtt_server:
|
||||
# 是否启用MQTT服务器
|
||||
enabled: false
|
||||
# MQTT服务器监听地址
|
||||
host: 0.0.0.0
|
||||
# MQTT服务器端口
|
||||
port: 1883
|
||||
# UDP音频传输端口(通常与MQTT端口相同)
|
||||
udp_port: 1883
|
||||
# 公网IP地址(用于UDP音频传输配置)
|
||||
# 如果使用docker部署或公网部署,请设置为实际的公网IP或域名
|
||||
public_ip: localhost
|
||||
# 最大连接数
|
||||
max_connections: 1000
|
||||
# 心跳检查间隔(秒)
|
||||
heartbeat_interval: 30
|
||||
# 最大消息载荷大小(字节)
|
||||
max_payload_size: 8192
|
||||
|
||||
# MQTT协议使用说明:
|
||||
# 1. 客户端ID格式:GID_test@@@mac_address@@@uuid 或 GID_test@@@mac_address
|
||||
# 例如:GID_test@@@aa:bb:cc:dd:ee:ff@@@unique_uuid_123
|
||||
# 2. 连接地址:mqtt://your.server.ip:1883
|
||||
# 3. 音频传输:通过UDP加密传输,配置信息在hello消息中返回
|
||||
# 4. 消息格式:JSON格式,支持hello、音频、文本等消息类型
|
||||
#
|
||||
# 启用MQTT的配置示例:
|
||||
# protocols:
|
||||
# enabled_protocols: ["websocket", "mqtt"] # 同时启用两种协议
|
||||
# mqtt_enabled: true
|
||||
# mqtt_server:
|
||||
# enabled: true
|
||||
# port: 1883
|
||||
# public_ip: "your.server.ip" # 替换为实际IP
|
||||
log:
|
||||
# 设置控制台输出的日志格式,时间、日志级别、标签、消息
|
||||
log_format: "<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{selected_module}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>"
|
||||
@@ -69,6 +123,9 @@ enable_greeting: true
|
||||
enable_stop_tts_notify: false
|
||||
# 说完话是否开启提示音,音效地址
|
||||
stop_tts_notify_voice: "config/assets/tts_notify.mp3"
|
||||
# 是否启用WebSocket心跳保活机制
|
||||
enable_websocket_ping: false
|
||||
|
||||
|
||||
# TTS音频发送延迟配置
|
||||
# tts_audio_send_delay: 控制音频包发送间隔
|
||||
@@ -113,6 +170,15 @@ wakeup_words:
|
||||
# MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
|
||||
# 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
|
||||
mcp_endpoint: 你的接入点 websocket地址
|
||||
|
||||
# 上下文源配置
|
||||
# 用于在系统提示词中注入动态数据,如健康数据、股票信息等
|
||||
# 可以添加多个上下文源
|
||||
context_providers:
|
||||
- url: ""
|
||||
headers:
|
||||
Authorization: ""
|
||||
|
||||
# 插件的基础配置
|
||||
plugins:
|
||||
# 获取天气插件的配置,这里填写你的api_key
|
||||
@@ -273,12 +339,15 @@ Memory:
|
||||
# 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型
|
||||
# 如果你的不想使用selected_module.LLM记忆存储,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
|
||||
llm: ChatGLMLLM
|
||||
|
||||
ASR:
|
||||
FunASR:
|
||||
type: fun_local
|
||||
model_dir: models/SenseVoiceSmall
|
||||
output_dir: tmp/
|
||||
# 队列最大大小(可选,默认100)
|
||||
# 当并发请求超过此值时,会返回"服务繁忙"提示
|
||||
# 建议根据服务器性能调整,GPU 服务器可适当增大
|
||||
queue_max_size: 100
|
||||
FunASRServer:
|
||||
# 独立部署FunASR,使用FunASR的API服务,只需要五句话
|
||||
# 第一句:mkdir -p ./funasr-runtime-resources/models
|
||||
@@ -334,6 +403,8 @@ ASR:
|
||||
# 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738
|
||||
boosting_table_name: (选填)你的热词文件名称
|
||||
correct_table_name: (选填)你的替换词文件名称
|
||||
# 静音判定时长(ms),默认200ms
|
||||
end_window_size: 200
|
||||
output_dir: tmp/
|
||||
TencentASR:
|
||||
# token申请地址:https://console.cloud.tencent.com/cam/capi
|
||||
@@ -462,7 +533,6 @@ ASR:
|
||||
domain: slm # 识别领域,iat:日常用语,medical:医疗,finance:金融等
|
||||
language: zh_cn # 语言,zh_cn:中文,en_us:英文
|
||||
accent: mandarin # 方言,mandarin:普通话
|
||||
dwa: wpgs # 动态修正,wpgs:实时返回中间结果
|
||||
# 调整音频处理参数以提高长语音识别质量
|
||||
output_dir: tmp/
|
||||
|
||||
@@ -669,6 +739,8 @@ TTS:
|
||||
access_token: 你的火山引擎语音合成服务access_token
|
||||
resource_id: volc.service_type.10029
|
||||
speaker: zh_female_wanwanxiaohe_moon_bigtts
|
||||
# 开启WebSocket连接复用,默认复用(注意:复用后设备处于聆听状态时空闲链接会占并发数)
|
||||
enable_ws_reuse: True
|
||||
speech_rate: 0
|
||||
loudness_rate: 0
|
||||
pitch: 0
|
||||
|
||||
@@ -1,8 +1,229 @@
|
||||
import os
|
||||
import yaml
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Dict, Optional, Type, TypeVar, Union, get_type_hints, get_origin, get_args
|
||||
from dataclasses import dataclass, field, fields, MISSING
|
||||
import inspect
|
||||
from config.manage_api_client import init_service, get_server_config, get_agent_models
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class ConfigDict(dict):
|
||||
"""增强的配置字典,支持点号访问和嵌套获取"""
|
||||
|
||||
def __init__(self, data: Dict[str, Any] = None):
|
||||
super().__init__()
|
||||
if data:
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
self[key] = ConfigDict(value)
|
||||
else:
|
||||
self[key] = value
|
||||
|
||||
def __getattr__(self, key: str) -> Any:
|
||||
"""支持点号访问"""
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{key}'")
|
||||
|
||||
def __setattr__(self, key: str, value: Any) -> None:
|
||||
"""支持点号设置"""
|
||||
self[key] = value
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""重写[]访问,支持嵌套路径,找不到抛出KeyError"""
|
||||
if '.' in key:
|
||||
keys = key.split('.')
|
||||
current = self
|
||||
for k in keys:
|
||||
if not isinstance(current, (dict, ConfigDict)):
|
||||
raise KeyError(f"Cannot access '{k}' on non-dict object")
|
||||
current = super(ConfigDict, current).__getitem__(k)
|
||||
return current
|
||||
return super().__getitem__(key)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""重写get方法,支持嵌套路径"""
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
"""重写[]设置,支持嵌套路径"""
|
||||
if '.' in key:
|
||||
keys = key.split('.')
|
||||
current = self
|
||||
for k in keys[:-1]:
|
||||
if k not in current:
|
||||
current[k] = ConfigDict()
|
||||
elif not isinstance(current[k], (dict, ConfigDict)):
|
||||
current[k] = ConfigDict()
|
||||
current = current[k]
|
||||
|
||||
if isinstance(value, dict) and not isinstance(value, ConfigDict):
|
||||
value = ConfigDict(value)
|
||||
super(ConfigDict, current).__setitem__(keys[-1], value)
|
||||
else:
|
||||
if isinstance(value, dict) and not isinstance(value, ConfigDict):
|
||||
value = ConfigDict(value)
|
||||
super().__setitem__(key, value)
|
||||
|
||||
|
||||
class ConfigField:
|
||||
"""配置字段,模仿dataclass的field功能"""
|
||||
|
||||
def __init__(self, default=MISSING, default_factory=MISSING, prefix: str = None):
|
||||
self.default = default
|
||||
self.default_factory = default_factory
|
||||
self.prefix = prefix
|
||||
|
||||
if default is not MISSING and default_factory is not MISSING:
|
||||
raise ValueError("Cannot specify both default and default_factory")
|
||||
|
||||
|
||||
def config_field(default=MISSING, default_factory=MISSING, prefix: str = None):
|
||||
"""创建配置字段"""
|
||||
return ConfigField(default, default_factory, prefix)
|
||||
|
||||
|
||||
def _is_config_class(cls: Type) -> bool:
|
||||
"""检查类是否是ConfigurationProperties装饰的配置类"""
|
||||
return hasattr(cls, '_config_prefix') and hasattr(cls, '_inject_config')
|
||||
|
||||
|
||||
def _create_nested_config_instance(config_class: Type, config: ConfigDict, config_path: str):
|
||||
"""创建嵌套配置类实例"""
|
||||
try:
|
||||
# 获取嵌套配置数据 - 直接传递整个config,让嵌套类自己处理前缀
|
||||
# 因为嵌套类有自己的prefix,它会从config中正确提取数据
|
||||
return config_class(config)
|
||||
except Exception as e:
|
||||
# 如果创建失败,返回None或抛出更详细的错误
|
||||
raise ValueError(f"Failed to create nested config instance for {config_class.__name__} at path '{config_path}': {e}")
|
||||
|
||||
|
||||
def ConfigurationProperties(prefix: str = "", auto_inject: bool = True):
|
||||
"""
|
||||
配置属性装饰器,模仿Spring Boot的@ConfigurationProperties
|
||||
|
||||
Args:
|
||||
prefix: 配置前缀,如 'server.database'
|
||||
auto_inject: 是否自动注入配置
|
||||
"""
|
||||
def decorator(cls: Type[T]) -> Type[T]:
|
||||
if not inspect.isclass(cls):
|
||||
raise TypeError("ConfigurationProperties can only be applied to classes")
|
||||
|
||||
# 保存原始的__init__方法
|
||||
original_init = cls.__init__ if hasattr(cls, '__init__') else None
|
||||
|
||||
# 获取类的类型注解
|
||||
type_hints = get_type_hints(cls)
|
||||
|
||||
def new_init(self, config: ConfigDict = None, **kwargs):
|
||||
# 如果有原始的__init__,先调用它
|
||||
if original_init and original_init is not object.__init__:
|
||||
try:
|
||||
original_init(self)
|
||||
except TypeError:
|
||||
# 如果原始__init__不接受参数,忽略
|
||||
pass
|
||||
|
||||
if config is None:
|
||||
# 如果没有传入config,尝试从全局获取
|
||||
config = getattr(self.__class__, '_global_config', None)
|
||||
if config is None:
|
||||
return
|
||||
|
||||
# 注入配置
|
||||
self._inject_config(config, prefix, **kwargs)
|
||||
|
||||
def _inject_config(self, config: ConfigDict, config_prefix: str = "", **overrides):
|
||||
"""注入配置到实例属性"""
|
||||
# 处理类属性
|
||||
for attr_name in dir(self.__class__):
|
||||
if attr_name.startswith('_'):
|
||||
continue
|
||||
|
||||
attr_value = getattr(self.__class__, attr_name)
|
||||
if isinstance(attr_value, ConfigField):
|
||||
# 确定配置路径
|
||||
field_prefix = attr_value.prefix or config_prefix
|
||||
config_path = f"{field_prefix}.{attr_name}" if field_prefix else attr_name
|
||||
|
||||
# 从overrides或config获取值
|
||||
if attr_name in overrides:
|
||||
value = overrides[attr_name]
|
||||
else:
|
||||
# 检查是否有类型注解,如果是嵌套配置类则特殊处理
|
||||
attr_type = type_hints.get(attr_name)
|
||||
if attr_type and inspect.isclass(attr_type) and _is_config_class(attr_type):
|
||||
# 嵌套配置类,创建实例
|
||||
try:
|
||||
value = _create_nested_config_instance(attr_type, config, config_path)
|
||||
except ValueError as e:
|
||||
# 如果创建失败,使用默认值
|
||||
print(f"Warning: {e}")
|
||||
if attr_value.default_factory is not MISSING:
|
||||
value = attr_value.default_factory()
|
||||
else:
|
||||
value = attr_value.default
|
||||
else:
|
||||
# 普通类型,使用默认值逻辑
|
||||
if attr_value.default_factory is not MISSING:
|
||||
default_val = attr_value.default_factory()
|
||||
else:
|
||||
default_val = attr_value.default
|
||||
|
||||
value = config.get(config_path, default_val)
|
||||
|
||||
setattr(self, attr_name, value)
|
||||
|
||||
# 处理类型注解的属性
|
||||
for attr_name, attr_type in type_hints.items():
|
||||
if hasattr(self, attr_name):
|
||||
continue # 已经通过ConfigField处理过了
|
||||
|
||||
config_path = f"{config_prefix}.{attr_name}" if config_prefix else attr_name
|
||||
|
||||
if attr_name in overrides:
|
||||
value = overrides[attr_name]
|
||||
else:
|
||||
# 检查是否是嵌套的ConfigurationProperties类
|
||||
if inspect.isclass(attr_type) and _is_config_class(attr_type):
|
||||
# 创建嵌套配置类实例
|
||||
try:
|
||||
value = _create_nested_config_instance(attr_type, config, config_path)
|
||||
except ValueError as e:
|
||||
# 如果创建失败,使用None或默认值
|
||||
print(f"Warning: {e}")
|
||||
value = None
|
||||
else:
|
||||
# 普通类型,直接从配置获取
|
||||
value = config.get(config_path)
|
||||
|
||||
if value is not None:
|
||||
setattr(self, attr_name, value)
|
||||
|
||||
# 添加方法到类
|
||||
cls.__init__ = new_init
|
||||
cls._inject_config = _inject_config
|
||||
cls._config_prefix = prefix
|
||||
|
||||
# 添加类方法用于设置全局配置
|
||||
@classmethod
|
||||
def set_global_config(cls, config: ConfigDict):
|
||||
cls._global_config = config
|
||||
|
||||
cls.set_global_config = set_global_config
|
||||
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_project_dir():
|
||||
"""获取项目根目录"""
|
||||
@@ -22,6 +243,9 @@ def load_config():
|
||||
# 检查缓存
|
||||
cached_config = cache_manager.get(CacheType.CONFIG, "main_config")
|
||||
if cached_config is not None:
|
||||
# 确保返回的是ConfigDict类型
|
||||
if not isinstance(cached_config, ConfigDict):
|
||||
cached_config = ConfigDict(cached_config)
|
||||
return cached_config
|
||||
|
||||
default_config_path = get_project_dir() + "config.yaml"
|
||||
@@ -45,6 +269,10 @@ def load_config():
|
||||
else:
|
||||
# 合并配置
|
||||
config = merge_configs(default_config, custom_config)
|
||||
|
||||
# 转换为ConfigDict
|
||||
config = ConfigDict(config)
|
||||
|
||||
# 初始化目录
|
||||
ensure_directories(config)
|
||||
|
||||
@@ -68,6 +296,7 @@ async def get_config_from_api_async(config):
|
||||
"url": config["manager-api"].get("url", ""),
|
||||
"secret": config["manager-api"].get("secret", ""),
|
||||
}
|
||||
auth_enabled = config_data.get("server", {}).get("auth", {}).get("enabled", False)
|
||||
# server的配置以本地为准
|
||||
if config.get("server"):
|
||||
config_data["server"] = {
|
||||
@@ -77,10 +306,11 @@ async def get_config_from_api_async(config):
|
||||
"vision_explain": config["server"].get("vision_explain", ""),
|
||||
"auth_key": config["server"].get("auth_key", ""),
|
||||
}
|
||||
config_data["server"]["auth"] = {"enabled": auth_enabled}
|
||||
# 如果服务器没有prompt_template,则从本地配置读取
|
||||
if not config_data.get("prompt_template"):
|
||||
config_data["prompt_template"] = config.get("prompt_template")
|
||||
return config_data
|
||||
return ConfigDict(config_data)
|
||||
|
||||
|
||||
async def get_private_config_from_api(config, device_id, client_id):
|
||||
@@ -158,3 +388,218 @@ def merge_configs(default_config, custom_config):
|
||||
merged[key] = value
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
# 导出主要的类和函数
|
||||
__all__ = [
|
||||
'ConfigDict',
|
||||
'ConfigField',
|
||||
'config_field',
|
||||
'ConfigurationProperties',
|
||||
'load_config',
|
||||
'get_project_dir',
|
||||
'merge_configs'
|
||||
]
|
||||
|
||||
|
||||
# 配置类定义
|
||||
|
||||
@ConfigurationProperties(prefix="server.database")
|
||||
class DatabaseConfig:
|
||||
"""数据库配置类"""
|
||||
host: str = config_field(default="localhost")
|
||||
port: int = config_field(default=3306)
|
||||
username: str = config_field(default="root")
|
||||
password: str = config_field(default="")
|
||||
database: str = config_field(default="xiaozhi")
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix="server.redis")
|
||||
class RedisConfig:
|
||||
"""Redis配置类"""
|
||||
host: str = config_field(default="localhost")
|
||||
port: int = config_field(default=6379)
|
||||
password: str = config_field(default="")
|
||||
db: int = config_field(default=0)
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix="mqtt_server")
|
||||
class MQTTServerConfig:
|
||||
"""MQTT服务器配置类"""
|
||||
enabled: bool = config_field(default=False)
|
||||
host: str = config_field(default="0.0.0.0")
|
||||
port: int = config_field(default=1883)
|
||||
udp_port: int = config_field(default=1883)
|
||||
public_ip: str = config_field(default="localhost")
|
||||
max_connections: int = config_field(default=1000)
|
||||
heartbeat_interval: int = config_field(default=30)
|
||||
max_payload_size: int = config_field(default=8192)
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix="server")
|
||||
class ServerConfig:
|
||||
"""服务器配置类"""
|
||||
ip: str = config_field(default="0.0.0.0")
|
||||
port: int = config_field(default=8080)
|
||||
http_port: int = config_field(default=8081)
|
||||
auth_key: str = config_field(default="")
|
||||
vision_explain: str = config_field(default="")
|
||||
|
||||
# 嵌套配置类
|
||||
database: DatabaseConfig = config_field(default_factory=lambda: DatabaseConfig())
|
||||
redis: RedisConfig = config_field(default_factory=lambda: RedisConfig())
|
||||
mqtt_server: MQTTServerConfig = config_field(default_factory=lambda: MQTTServerConfig())
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix="asr.whisper")
|
||||
class WhisperConfig:
|
||||
"""Whisper ASR配置类"""
|
||||
model: str = config_field(default="base")
|
||||
language: str = config_field(default="zh")
|
||||
device: str = config_field(default="cpu")
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix="asr")
|
||||
class ASRConfig:
|
||||
"""ASR配置类"""
|
||||
provider: str = config_field(default="whisper")
|
||||
# 嵌套配置
|
||||
whisper: WhisperConfig = config_field(default_factory=lambda: WhisperConfig())
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix="selected_module")
|
||||
class SelectedModuleConfig:
|
||||
"""选中模块配置类"""
|
||||
ASR: str = config_field(default="")
|
||||
TTS: str = config_field(default="")
|
||||
LLM: str = config_field(default="")
|
||||
VLLM: str = config_field(default="")
|
||||
VAD: str = config_field(default="")
|
||||
Memory: str = config_field(default="")
|
||||
Intent: str = config_field(default="")
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix="log")
|
||||
class LogConfig:
|
||||
"""日志配置类"""
|
||||
log_dir: str = config_field(default="tmp")
|
||||
level: str = config_field(default="INFO")
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix="protocols")
|
||||
class ProtocolConfig:
|
||||
"""协议配置类"""
|
||||
enabled_protocols: list = config_field(default_factory=lambda: ["websocket"])
|
||||
websocket_enabled: bool = config_field(default=True)
|
||||
mqtt_enabled: bool = config_field(default=False)
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix="")
|
||||
class MainConfig:
|
||||
"""主配置类,包含常用的顶级配置"""
|
||||
read_config_from_api: bool = config_field(default=False)
|
||||
exit_commands: list = config_field(default_factory=list)
|
||||
close_connection_no_voice_time: int = config_field(default=120)
|
||||
xiaozhi: str = config_field(default="")
|
||||
prompt: str = config_field(default="")
|
||||
delete_audio: bool = config_field(default=True)
|
||||
|
||||
# 协议配置
|
||||
protocols: ProtocolConfig = config_field(default_factory=lambda: ProtocolConfig())
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix="voiceprint")
|
||||
class VoiceprintConfig:
|
||||
"""声纹配置类"""
|
||||
enabled: bool = config_field(default=False)
|
||||
model_path: str = config_field(default="")
|
||||
threshold: float = config_field(default=0.5)
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
_global_config_dict: ConfigDict = None
|
||||
_server_config: ServerConfig = None
|
||||
_selected_module_config: SelectedModuleConfig = None
|
||||
_log_config: LogConfig = None
|
||||
_main_config: MainConfig = None
|
||||
_voiceprint_config: VoiceprintConfig = None
|
||||
_mqtt_server_config: MQTTServerConfig = None
|
||||
_protocol_config: ProtocolConfig = None
|
||||
|
||||
|
||||
def get_config_instance(config_class: Type[T]) -> T:
|
||||
"""获取配置类实例的工厂方法"""
|
||||
global _global_config_dict
|
||||
if _global_config_dict is None:
|
||||
_global_config_dict = load_config()
|
||||
|
||||
return config_class(_global_config_dict)
|
||||
|
||||
|
||||
def get_server_config() -> ServerConfig:
|
||||
"""获取服务器配置实例"""
|
||||
global _server_config
|
||||
if _server_config is None:
|
||||
_server_config = get_config_instance(ServerConfig)
|
||||
return _server_config
|
||||
|
||||
|
||||
def get_selected_module_config() -> SelectedModuleConfig:
|
||||
"""获取选中模块配置实例"""
|
||||
global _selected_module_config
|
||||
if _selected_module_config is None:
|
||||
_selected_module_config = get_config_instance(SelectedModuleConfig)
|
||||
return _selected_module_config
|
||||
|
||||
|
||||
def get_log_config() -> LogConfig:
|
||||
"""获取日志配置实例"""
|
||||
global _log_config
|
||||
if _log_config is None:
|
||||
_log_config = get_config_instance(LogConfig)
|
||||
return _log_config
|
||||
|
||||
|
||||
def get_main_config() -> MainConfig:
|
||||
"""获取主配置实例"""
|
||||
global _main_config
|
||||
if _main_config is None:
|
||||
_main_config = get_config_instance(MainConfig)
|
||||
return _main_config
|
||||
|
||||
|
||||
def get_voiceprint_config() -> VoiceprintConfig:
|
||||
"""获取声纹配置实例"""
|
||||
global _voiceprint_config
|
||||
if _voiceprint_config is None:
|
||||
_voiceprint_config = get_config_instance(VoiceprintConfig)
|
||||
return _voiceprint_config
|
||||
|
||||
|
||||
def get_mqtt_server_config() -> MQTTServerConfig:
|
||||
"""获取MQTT服务器配置实例"""
|
||||
global _mqtt_server_config
|
||||
if _mqtt_server_config is None:
|
||||
_mqtt_server_config = get_config_instance(MQTTServerConfig)
|
||||
return _mqtt_server_config
|
||||
|
||||
|
||||
def get_protocol_config() -> ProtocolConfig:
|
||||
"""获取协议配置实例"""
|
||||
global _protocol_config
|
||||
if _protocol_config is None:
|
||||
_protocol_config = get_config_instance(ProtocolConfig)
|
||||
return _protocol_config
|
||||
|
||||
|
||||
def refresh_config():
|
||||
"""刷新所有配置实例"""
|
||||
global _global_config_dict, _server_config, _selected_module_config, _log_config, _main_config, _voiceprint_config, _mqtt_server_config, _protocol_config
|
||||
_global_config_dict = None
|
||||
_server_config = None
|
||||
_selected_module_config = None
|
||||
_log_config = None
|
||||
_main_config = None
|
||||
_voiceprint_config = None
|
||||
_mqtt_server_config = None
|
||||
_protocol_config = None
|
||||
|
||||
@@ -5,7 +5,7 @@ from config.config_loader import load_config
|
||||
from config.settings import check_config_file
|
||||
from datetime import datetime
|
||||
|
||||
SERVER_VERSION = "0.8.9"
|
||||
SERVER_VERSION = "0.8.10"
|
||||
_logger_initialized = False
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ class ManageApiClient:
|
||||
async def _ensure_async_client(cls):
|
||||
"""确保异步客户端已创建(为每个事件循环创建独立的客户端)"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop_id = id(loop)
|
||||
@@ -115,6 +116,7 @@ class ManageApiClient:
|
||||
async def _execute_async_request(cls, method: str, endpoint: str, **kwargs) -> Dict:
|
||||
"""带重试机制的异步请求执行器"""
|
||||
import asyncio
|
||||
|
||||
retry_count = 0
|
||||
|
||||
while retry_count <= cls.max_retries:
|
||||
@@ -138,6 +140,7 @@ class ManageApiClient:
|
||||
def safe_close(cls):
|
||||
"""安全关闭所有异步连接池"""
|
||||
import asyncio
|
||||
|
||||
for client in list(cls._async_clients.values()):
|
||||
try:
|
||||
asyncio.run(client.aclose())
|
||||
@@ -149,7 +152,9 @@ class ManageApiClient:
|
||||
|
||||
async def get_server_config() -> Optional[Dict]:
|
||||
"""获取服务器基础配置"""
|
||||
return await ManageApiClient._instance._execute_async_request("POST", "/config/server-base")
|
||||
return await ManageApiClient._instance._execute_async_request(
|
||||
"POST", "/config/server-base"
|
||||
)
|
||||
|
||||
|
||||
async def get_agent_models(
|
||||
@@ -167,17 +172,15 @@ async def get_agent_models(
|
||||
)
|
||||
|
||||
|
||||
async def save_mem_local_short(mac_address: str, short_momery: str) -> Optional[Dict]:
|
||||
async def generate_and_save_chat_summary(session_id: str) -> Optional[Dict]:
|
||||
"""生成并保存聊天记录总结"""
|
||||
try:
|
||||
return await ManageApiClient._instance._execute_async_request(
|
||||
"PUT",
|
||||
f"/agent/saveMemory/" + mac_address,
|
||||
json={
|
||||
"summaryMemory": short_momery,
|
||||
},
|
||||
"POST",
|
||||
f"/agent/chat-summary/{session_id}/save",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"存储短期记忆到服务器失败: {e}")
|
||||
print(f"生成并保存聊天记录总结失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# MQTT协议配置示例
|
||||
# 将此配置添加到你的主配置文件中
|
||||
|
||||
# 协议配置
|
||||
protocols:
|
||||
enabled_protocols: ["websocket", "mqtt"] # 启用的协议列表
|
||||
websocket_enabled: true # WebSocket协议开关
|
||||
mqtt_enabled: true # MQTT协议开关
|
||||
|
||||
# MQTT服务器配置
|
||||
mqtt_server:
|
||||
enabled: true # 是否启用MQTT服务器
|
||||
host: "0.0.0.0" # 监听地址
|
||||
port: 1883 # MQTT端口
|
||||
udp_port: 1883 # UDP端口(用于音频传输)
|
||||
public_ip: "your.server.ip" # 公网IP地址
|
||||
max_connections: 1000 # 最大连接数
|
||||
heartbeat_interval: 30 # 心跳检查间隔(秒)
|
||||
max_payload_size: 8192 # 最大消息载荷大小
|
||||
|
||||
# 服务器配置(扩展)
|
||||
server:
|
||||
ip: "0.0.0.0"
|
||||
port: 8080 # WebSocket端口
|
||||
http_port: 8081
|
||||
auth_key: ""
|
||||
vision_explain: ""
|
||||
|
||||
# MQTT服务器配置(嵌套)
|
||||
mqtt_server:
|
||||
enabled: true
|
||||
host: "0.0.0.0"
|
||||
port: 1883
|
||||
udp_port: 1883
|
||||
public_ip: "localhost"
|
||||
max_connections: 1000
|
||||
heartbeat_interval: 30
|
||||
max_payload_size: 8192
|
||||
|
||||
# 使用示例:
|
||||
# 1. 启动多协议服务器:
|
||||
# python main_multi_protocol.py
|
||||
#
|
||||
# 2. WebSocket客户端连接:
|
||||
# ws://your.server.ip:8080/
|
||||
#
|
||||
# 3. MQTT客户端连接:
|
||||
# mqtt://your.server.ip:1883
|
||||
# 客户端ID格式:GID_test@@@mac_address@@@uuid
|
||||
# 或:GID_test@@@mac_address
|
||||
#
|
||||
# 4. UDP音频传输:
|
||||
# 客户端通过MQTT接收UDP配置后,使用UDP发送音频数据
|
||||
|
||||
@@ -10,7 +10,15 @@ class BaseHandler:
|
||||
def _add_cors_headers(self, response):
|
||||
"""添加CORS头信息"""
|
||||
response.headers["Access-Control-Allow-Headers"] = (
|
||||
"client-id, content-type, device-id"
|
||||
"client-id, content-type, device-id, authorization"
|
||||
)
|
||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
async def handle_options(self, request):
|
||||
"""处理OPTIONS请求,添加CORS头信息"""
|
||||
response = web.Response(body=b"", content_type="text/plain")
|
||||
self._add_cors_headers(response)
|
||||
# 添加允许的方法
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||
return response
|
||||
|
||||
@@ -3,15 +3,46 @@ import time
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import glob
|
||||
from typing import Dict, List, Tuple
|
||||
from aiohttp import web
|
||||
|
||||
from core.auth import AuthManager
|
||||
from core.utils.util import get_local_ip
|
||||
from core.utils.util import get_local_ip, get_vision_url
|
||||
from core.api.base_handler import BaseHandler
|
||||
|
||||
TAG = __name__
|
||||
|
||||
|
||||
def _safe_basename(filename: str) -> str:
|
||||
# Prevent directory traversal
|
||||
return os.path.basename(filename)
|
||||
|
||||
|
||||
def _parse_version(ver: str) -> Tuple[int, ...]:
|
||||
# conservative parser: split by non-digit, keep numeric parts
|
||||
parts = re.findall(r"\d+", ver)
|
||||
return tuple(int(p) for p in parts) if parts else (0,)
|
||||
|
||||
|
||||
def _is_higher_version(a: str, b: str) -> bool:
|
||||
"""Return True if version string a > b (semver-like numeric compare)."""
|
||||
ta = _parse_version(a)
|
||||
tb = _parse_version(b)
|
||||
# compare tuple lexicographically, but allow different lengths
|
||||
maxlen = max(len(ta), len(tb))
|
||||
for i in range(maxlen):
|
||||
ai = ta[i] if i < len(ta) else 0
|
||||
bi = tb[i] if i < len(tb) else 0
|
||||
if ai > bi:
|
||||
return True
|
||||
if ai < bi:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
class OTAHandler(BaseHandler):
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
@@ -23,6 +54,54 @@ class OTAHandler(BaseHandler):
|
||||
expire_seconds = auth_config.get("expire_seconds")
|
||||
self.auth = AuthManager(secret_key=secret_key, expire_seconds=expire_seconds)
|
||||
|
||||
# firmware storage
|
||||
self.bin_dir = os.path.join(os.getcwd(), "data", "bin")
|
||||
# cache structure: { 'updated_at': timestamp, 'ttl': seconds, 'files_by_model': { model: [(version, filename), ...] } }
|
||||
self._bin_cache: Dict = {
|
||||
"updated_at": 0,
|
||||
"ttl": config.get("firmware_cache_ttl", 30),
|
||||
"files_by_model": {},
|
||||
}
|
||||
|
||||
def _refresh_bin_cache_if_needed(self):
|
||||
now = int(time.time())
|
||||
ttl = int(self._bin_cache.get("ttl", 30))
|
||||
if now - int(
|
||||
self._bin_cache.get("updated_at", 0)
|
||||
) < ttl and self._bin_cache.get("files_by_model"):
|
||||
return
|
||||
|
||||
files_by_model: Dict[str, List[Tuple[str, str]]] = {}
|
||||
try:
|
||||
if not os.path.isdir(self.bin_dir):
|
||||
os.makedirs(self.bin_dir, exist_ok=True)
|
||||
|
||||
# match files like model_1.2.3.bin (allow dots, dashes, underscores in model and version)
|
||||
pattern = os.path.join(self.bin_dir, "*.bin")
|
||||
for path in glob.glob(pattern):
|
||||
fname = os.path.basename(path)
|
||||
# filename format: {model}_{version}.bin
|
||||
m = re.match(r"^(.+?)_([0-9][A-Za-z0-9\.\-_]*)\.bin$", fname)
|
||||
if not m:
|
||||
# skip files not conforming to naming rule
|
||||
continue
|
||||
model = m.group(1)
|
||||
version = m.group(2)
|
||||
files_by_model.setdefault(model, []).append((version, fname))
|
||||
|
||||
# sort versions for each model descending
|
||||
for model, items in files_by_model.items():
|
||||
items.sort(key=lambda it: _parse_version(it[0]), reverse=True)
|
||||
|
||||
self._bin_cache["files_by_model"] = files_by_model
|
||||
self._bin_cache["updated_at"] = now
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"Firmware cache refreshed: {len(files_by_model)} models"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"刷新固件缓存失败: {e}")
|
||||
# keep previous cache if any
|
||||
|
||||
def generate_password_signature(self, content: str, secret_key: str) -> str:
|
||||
"""生成MQTT密码签名
|
||||
|
||||
@@ -62,7 +141,14 @@ class OTAHandler(BaseHandler):
|
||||
return f"ws://{local_ip}:{port}/xiaozhi/v1/"
|
||||
|
||||
async def handle_post(self, request):
|
||||
"""处理 OTA POST 请求"""
|
||||
"""处理 OTA POST 请求
|
||||
|
||||
This handler will:
|
||||
- read device id/client id (as before)
|
||||
- attempt to determine device model and current firmware version (prefer headers, fallback to body)
|
||||
- check data/bin for newer firmware for that model
|
||||
- if found a newer firmware, set firmware.url to the download endpoint
|
||||
"""
|
||||
try:
|
||||
data = await request.text()
|
||||
self.logger.bind(tag=TAG).debug(f"OTA请求方法: {request.method}")
|
||||
@@ -81,33 +167,76 @@ class OTAHandler(BaseHandler):
|
||||
else:
|
||||
raise Exception("OTA请求ClientID为空")
|
||||
|
||||
data_json = json.loads(data)
|
||||
data_json = {}
|
||||
try:
|
||||
data_json = json.loads(data) if data else {}
|
||||
except Exception:
|
||||
data_json = {}
|
||||
|
||||
server_config = self.config["server"]
|
||||
port = int(server_config.get("port", 8000))
|
||||
# Distinguish ports:
|
||||
# - websocket_port is used to construct websocket URL (server["port"])
|
||||
# - http_port is used to construct OTA download URLs (server["http_port"])
|
||||
websocket_port = int(server_config.get("port", 8000))
|
||||
http_port = int(server_config.get("http_port", 8003))
|
||||
local_ip = get_local_ip()
|
||||
|
||||
# Determine device model (prefer headers)
|
||||
device_model = ""
|
||||
# header candidates
|
||||
for h in ("device-model", "device_model", "model"):
|
||||
if h in request.headers:
|
||||
device_model = request.headers.get(h, "").strip()
|
||||
break
|
||||
# body fallback
|
||||
if not device_model:
|
||||
try:
|
||||
if "board" in data_json and isinstance(data_json["board"], dict):
|
||||
device_model = data_json["board"].get("type", "")
|
||||
elif "model" in data_json:
|
||||
device_model = data_json.get("model", "")
|
||||
except Exception:
|
||||
device_model = ""
|
||||
if not device_model:
|
||||
device_model = "default"
|
||||
|
||||
# Determine device current version (prefer headers)
|
||||
device_version = ""
|
||||
for h in (
|
||||
"device-version",
|
||||
"device_version",
|
||||
"firmware-version",
|
||||
"app-version",
|
||||
"application-version",
|
||||
):
|
||||
if h in request.headers:
|
||||
device_version = request.headers.get(h, "").strip()
|
||||
break
|
||||
if not device_version:
|
||||
try:
|
||||
device_version = data_json.get("application", {}).get("version", "")
|
||||
except Exception:
|
||||
device_version = ""
|
||||
if not device_version:
|
||||
device_version = "0.0.0"
|
||||
|
||||
return_json = {
|
||||
"server_time": {
|
||||
"timestamp": int(round(time.time() * 1000)),
|
||||
"timezone_offset": server_config.get("timezone_offset", 8) * 60,
|
||||
},
|
||||
"firmware": {
|
||||
"version": data_json["application"].get("version", "1.0.0"),
|
||||
"version": device_version,
|
||||
"url": "",
|
||||
},
|
||||
}
|
||||
|
||||
# existing mqtt/websocket logic (unchanged)
|
||||
mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
|
||||
|
||||
if mqtt_gateway_endpoint: # 如果配置了非空字符串
|
||||
# 尝试从请求数据中获取设备型号
|
||||
device_model = "default"
|
||||
# 尝试从请求数据中获取设备型号(已解析 above)
|
||||
try:
|
||||
if "device" in data_json and isinstance(data_json["device"], dict):
|
||||
device_model = data_json["device"].get("model", "default")
|
||||
elif "model" in data_json:
|
||||
device_model = data_json["model"]
|
||||
group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_")
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"获取设备型号失败: {e}")
|
||||
@@ -159,20 +288,61 @@ class OTAHandler(BaseHandler):
|
||||
token = self.auth.generate_token(client_id, device_id)
|
||||
else:
|
||||
token = self.auth.generate_token(client_id, device_id)
|
||||
# NOTE: use websocket_port here
|
||||
return_json["websocket"] = {
|
||||
"url": self._get_websocket_url(local_ip, port),
|
||||
"url": self._get_websocket_url(local_ip, websocket_port),
|
||||
"token": token,
|
||||
}
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"未配置MQTT网关,为设备 {device_id} 下发WebSocket配置"
|
||||
)
|
||||
self.logger.bind(tag=TAG).info(f"{return_json}")
|
||||
|
||||
# Now check firmware files for updates
|
||||
try:
|
||||
self._refresh_bin_cache_if_needed()
|
||||
files_by_model = self._bin_cache.get("files_by_model", {})
|
||||
candidates = files_by_model.get(device_model, [])
|
||||
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"查找型号 {device_model} 的固件,找到 {len(candidates)} 个候选"
|
||||
)
|
||||
|
||||
chosen_url = ""
|
||||
chosen_version = device_version
|
||||
|
||||
# candidates are sorted descending by version
|
||||
for ver, fname in candidates:
|
||||
if _is_higher_version(ver, device_version):
|
||||
# build download url (only allow download via our download endpoint)
|
||||
chosen_version = ver
|
||||
# Use get_vision_url to get the base URL and replace the path
|
||||
vision_url = get_vision_url(self.config)
|
||||
# Replace the path from "/mcp/vision/explain" to "/xiaozhi/ota/download/{fname}"
|
||||
chosen_url = vision_url.replace(
|
||||
"/mcp/vision/explain", f"/xiaozhi/ota/download/{fname}"
|
||||
)
|
||||
break
|
||||
|
||||
if chosen_url:
|
||||
return_json["firmware"]["version"] = chosen_version
|
||||
return_json["firmware"]["url"] = chosen_url
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"为设备 {device_id} 下发固件 {chosen_version} [如果地址前缀有误,请检查配置文件中的server.vision_explain]-> {chosen_url} "
|
||||
)
|
||||
else:
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"设备 {device_id} 固件已是最新: {device_version}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"检查固件版本时出错: {e}")
|
||||
|
||||
response = web.Response(
|
||||
text=json.dumps(return_json, separators=(",", ":")),
|
||||
content_type="application/json",
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"OTA POST处理异常: {e}")
|
||||
return_json = {"success": False, "message": "request error."}
|
||||
response = web.Response(
|
||||
text=json.dumps(return_json, separators=(",", ":")),
|
||||
@@ -187,8 +357,9 @@ class OTAHandler(BaseHandler):
|
||||
try:
|
||||
server_config = self.config["server"]
|
||||
local_ip = get_local_ip()
|
||||
port = int(server_config.get("port", 8000))
|
||||
websocket_url = self._get_websocket_url(local_ip, port)
|
||||
# use websocket port for websocket URL
|
||||
websocket_port = int(server_config.get("port", 8000))
|
||||
websocket_url = self._get_websocket_url(local_ip, websocket_port)
|
||||
message = f"OTA接口运行正常,向设备发送的websocket地址是:{websocket_url}"
|
||||
response = web.Response(text=message, content_type="text/plain")
|
||||
except Exception as e:
|
||||
@@ -197,3 +368,48 @@ class OTAHandler(BaseHandler):
|
||||
finally:
|
||||
self._add_cors_headers(response)
|
||||
return response
|
||||
|
||||
async def handle_download(self, request):
|
||||
"""
|
||||
下载固件接口
|
||||
URL: /xiaozhi/ota/download/{filename}
|
||||
- 只允许下载 data/bin 目录下的 .bin 文件
|
||||
- filename 必须是 basename 且匹配安全的模式
|
||||
"""
|
||||
try:
|
||||
fname = request.match_info.get("filename", "")
|
||||
if not fname:
|
||||
raise web.HTTPBadRequest(text="filename required")
|
||||
|
||||
# sanitize
|
||||
fname = _safe_basename(fname)
|
||||
# pattern: allow letters, numbers, dot, underscore, dash
|
||||
if not re.match(r"^[A-Za-z0-9\.\-_]+\.bin$", fname):
|
||||
raise web.HTTPBadRequest(text="invalid filename")
|
||||
|
||||
file_path = os.path.join(self.bin_dir, fname)
|
||||
# ensure realpath is under bin_dir
|
||||
file_real = os.path.realpath(file_path)
|
||||
bin_dir_real = os.path.realpath(self.bin_dir)
|
||||
if (
|
||||
not file_real.startswith(bin_dir_real + os.sep)
|
||||
and file_real != bin_dir_real
|
||||
):
|
||||
raise web.HTTPForbidden(text="forbidden")
|
||||
|
||||
if not os.path.isfile(file_real):
|
||||
raise web.HTTPNotFound(text="file not found")
|
||||
|
||||
# use FileResponse to stream file
|
||||
resp = web.FileResponse(path=file_real)
|
||||
except web.HTTPError as e:
|
||||
resp = e
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"固件下载异常: {e}")
|
||||
resp = web.Response(text="download error", status=500)
|
||||
finally:
|
||||
try:
|
||||
self._add_cors_headers(resp)
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import copy
|
||||
from aiohttp import web
|
||||
from config.logger import setup_logging
|
||||
from core.api.base_handler import BaseHandler
|
||||
from core.utils.util import get_vision_url, is_valid_image_file
|
||||
from core.utils.vllm import create_instance
|
||||
from config.config_loader import get_private_config_from_api
|
||||
@@ -16,10 +17,9 @@ TAG = __name__
|
||||
MAX_FILE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
|
||||
class VisionHandler:
|
||||
class VisionHandler(BaseHandler):
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.logger = setup_logging()
|
||||
super().__init__(config)
|
||||
# 初始化认证工具
|
||||
self.auth = AuthToken(config["server"]["auth_key"])
|
||||
|
||||
@@ -172,11 +172,3 @@ class VisionHandler:
|
||||
finally:
|
||||
self._add_cors_headers(response)
|
||||
return response
|
||||
|
||||
def _add_cors_headers(self, response):
|
||||
"""添加CORS头信息"""
|
||||
response.headers["Access-Control-Allow-Headers"] = (
|
||||
"client-id, content-type, device-id"
|
||||
)
|
||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
@@ -10,6 +10,140 @@ class AuthenticationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuthMiddleware:
|
||||
"""
|
||||
认证中间件
|
||||
用于 WebSocket/MQTT 连接认证
|
||||
集成 AuthManager 的 token 验证逻辑,支持多种认证方式
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
"""
|
||||
初始化认证中间件
|
||||
|
||||
Args:
|
||||
config: 配置字典,包含认证相关配置
|
||||
"""
|
||||
self.config = config
|
||||
server_config = config.get("server", {})
|
||||
auth_config = server_config.get("auth", {})
|
||||
|
||||
self.enabled = auth_config.get("enabled", False)
|
||||
self.tokens = auth_config.get("tokens", [])
|
||||
self.allowed_devices = set(auth_config.get("allowed_devices", []))
|
||||
|
||||
# 获取 auth_key 用于 HMAC token 验证
|
||||
self.auth_key = server_config.get("auth_key", "")
|
||||
expire_seconds = auth_config.get("expire_seconds", None)
|
||||
|
||||
# 创建 AuthManager 实例用于 HMAC token 验证
|
||||
if self.auth_key:
|
||||
self._auth_manager = AuthManager(
|
||||
secret_key=self.auth_key,
|
||||
expire_seconds=expire_seconds
|
||||
)
|
||||
else:
|
||||
self._auth_manager = None
|
||||
|
||||
def authenticate(self, device_id: str, token: str = None, client_id: str = None) -> bool:
|
||||
"""
|
||||
验证设备认证(同步方法)
|
||||
|
||||
Args:
|
||||
device_id: 设备 ID
|
||||
token: 认证令牌(可以是静态 token 或 HMAC token)
|
||||
client_id: 客户端 ID(用于 HMAC token 验证)
|
||||
|
||||
Returns:
|
||||
bool: 认证是否通过
|
||||
"""
|
||||
if not self.enabled:
|
||||
return True
|
||||
|
||||
# 1. 检查白名单
|
||||
if device_id and device_id in self.allowed_devices:
|
||||
return True
|
||||
|
||||
# 2. 检查静态 token
|
||||
if token:
|
||||
# 移除 Bearer 前缀(如果有)
|
||||
if token.startswith("Bearer "):
|
||||
token = token[7:]
|
||||
|
||||
for token_config in self.tokens:
|
||||
if token_config.get("token") == token:
|
||||
return True
|
||||
|
||||
# 3. 检查 HMAC token(需要 AuthManager)
|
||||
if token and self._auth_manager and client_id and device_id:
|
||||
if self._auth_manager.verify_token(token, client_id, device_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def authenticate_async(self, headers: dict) -> bool:
|
||||
"""
|
||||
从 headers 中提取信息并进行异步认证
|
||||
|
||||
Args:
|
||||
headers: HTTP 请求头字典
|
||||
|
||||
Returns:
|
||||
bool: 认证是否通过
|
||||
|
||||
Raises:
|
||||
AuthenticationError: 认证失败时抛出
|
||||
"""
|
||||
if not self.enabled:
|
||||
return True
|
||||
|
||||
device_id = headers.get("device-id")
|
||||
client_id = headers.get("client-id")
|
||||
authorization = headers.get("authorization", "")
|
||||
|
||||
# 提取 token
|
||||
token = None
|
||||
if authorization:
|
||||
if authorization.startswith("Bearer "):
|
||||
token = authorization[7:]
|
||||
else:
|
||||
token = authorization
|
||||
|
||||
# 执行认证
|
||||
if self.authenticate(device_id, token, client_id):
|
||||
return True
|
||||
|
||||
raise AuthenticationError(f"认证失败: device_id={device_id}")
|
||||
|
||||
def authenticate_websocket(self, websocket) -> bool:
|
||||
"""
|
||||
WebSocket 连接认证
|
||||
|
||||
Args:
|
||||
websocket: WebSocket 连接对象
|
||||
|
||||
Returns:
|
||||
bool: 认证是否通过
|
||||
"""
|
||||
if not self.enabled:
|
||||
return True
|
||||
|
||||
headers = dict(websocket.request.headers)
|
||||
device_id = headers.get("device-id")
|
||||
client_id = headers.get("client-id")
|
||||
authorization = headers.get("authorization", "")
|
||||
|
||||
# 提取 token
|
||||
token = None
|
||||
if authorization:
|
||||
if authorization.startswith("Bearer "):
|
||||
token = authorization[7:]
|
||||
else:
|
||||
token = authorization
|
||||
|
||||
return self.authenticate(device_id, token, client_id)
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""
|
||||
统一授权认证管理器
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
from typing import Any, Dict
|
||||
from core.components.component_manager import Component, ComponentType, ComponentFactory
|
||||
from core.utils import asr
|
||||
from core.utils.modules_initialize import initialize_asr
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
TAG = __name__
|
||||
|
||||
|
||||
class ASRAdapter(Component):
|
||||
"""
|
||||
ASR组件适配器:将现有ASR组件包装为新的组件接口
|
||||
|
||||
支持两种模式:
|
||||
1. 共享实例模式:使用 SharedASRManager 的全局共享实例
|
||||
2. 独立实例模式:每个连接创建独立的 ASR 实例(原有逻辑)
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(ComponentType.ASR, config)
|
||||
self._asr_instance = None
|
||||
self._delete_audio = config.get("delete_audio", True)
|
||||
self._using_shared = False # 是否使用共享实例
|
||||
|
||||
async def _do_initialize(self, context: Any) -> None:
|
||||
"""初始化ASR组件"""
|
||||
try:
|
||||
# 获取ASR配置
|
||||
selected_module = self.config.get("selected_module", {}).get("ASR")
|
||||
if not selected_module:
|
||||
raise ValueError("未配置ASR模块")
|
||||
|
||||
# 检查是否有全局共享 ASR 管理器
|
||||
shared_manager = getattr(context, 'shared_asr_manager', None)
|
||||
|
||||
if shared_manager and shared_manager.is_ready():
|
||||
# 使用共享实例模式
|
||||
logger.bind(tag=TAG).info(f"使用共享 ASR 实例: {selected_module}")
|
||||
from core.providers.asr.shared_asr_proxy import SharedASRProxy
|
||||
self._asr_instance = SharedASRProxy(shared_manager)
|
||||
self._using_shared = True
|
||||
else:
|
||||
# 使用独立实例模式(原有逻辑)
|
||||
logger.bind(tag=TAG).info(f"使用独立 ASR 实例: {selected_module}")
|
||||
self._asr_instance = initialize_asr(self.config)
|
||||
self._using_shared = False
|
||||
|
||||
# 注册资源以便清理(仅非共享实例)
|
||||
if not self._using_shared:
|
||||
self.add_resource(self._asr_instance)
|
||||
|
||||
# 打开音频通道(如果需要)
|
||||
if hasattr(self._asr_instance, 'open_audio_channels'):
|
||||
await self._asr_instance.open_audio_channels(context)
|
||||
|
||||
logger.bind(tag=TAG).info(
|
||||
f"ASR组件初始化完成: {selected_module}, "
|
||||
f"共享模式: {self._using_shared}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"ASR组件初始化失败: {e}")
|
||||
raise
|
||||
|
||||
async def _do_cleanup(self) -> None:
|
||||
"""清理ASR组件"""
|
||||
if self._asr_instance:
|
||||
try:
|
||||
# 如果是共享实例,不需要关闭(由服务器统一管理)
|
||||
if self._using_shared:
|
||||
logger.bind(tag=TAG).debug("共享 ASR 实例,跳过清理")
|
||||
else:
|
||||
# 关闭独立 ASR 实例
|
||||
if hasattr(self._asr_instance, 'close'):
|
||||
await self._asr_instance.close()
|
||||
|
||||
# 清理音频文件
|
||||
if hasattr(self._asr_instance, 'cleanup_audio_files'):
|
||||
self._asr_instance.cleanup_audio_files()
|
||||
|
||||
logger.bind(tag=TAG).info("ASR组件清理完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"ASR组件清理失败: {e}")
|
||||
finally:
|
||||
self._asr_instance = None
|
||||
self._using_shared = False
|
||||
|
||||
@property
|
||||
def asr_instance(self):
|
||||
"""获取ASR实例"""
|
||||
return self._asr_instance
|
||||
|
||||
|
||||
class ASRFactory(ComponentFactory):
|
||||
"""ASR组件工厂"""
|
||||
|
||||
def create(self, config: Dict[str, Any]) -> Component:
|
||||
return ASRAdapter(config)
|
||||
|
||||
def get_component_type(self) -> ComponentType:
|
||||
return ComponentType.ASR
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from typing import Any, Dict
|
||||
from core.components.component_manager import Component, ComponentType, ComponentFactory
|
||||
from core.utils import intent
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class IntentAdapter(Component):
|
||||
"""Intent组件适配器:将现有Intent组件包装为新的组件接口"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(ComponentType.INTENT, config)
|
||||
self._intent_instance = None
|
||||
|
||||
async def _do_initialize(self, context: Any) -> None:
|
||||
"""初始化Intent组件"""
|
||||
try:
|
||||
# 获取Intent配置
|
||||
selected_module = self.config.get("selected_module", {}).get("Intent")
|
||||
if not selected_module:
|
||||
raise ValueError("未配置Intent模块")
|
||||
|
||||
# 获取Intent类型
|
||||
intent_type = (
|
||||
selected_module
|
||||
if "type" not in self.config["Intent"][selected_module]
|
||||
else self.config["Intent"][selected_module]["type"]
|
||||
)
|
||||
|
||||
# 创建Intent实例
|
||||
self._intent_instance = intent.create_instance(
|
||||
intent_type,
|
||||
self.config["Intent"][selected_module],
|
||||
)
|
||||
|
||||
# 注册资源以便清理
|
||||
self.add_resource(self._intent_instance)
|
||||
|
||||
# 设置LLM(如果需要)
|
||||
if intent_type in ["intent_llm", "function_call"]:
|
||||
llm_component = context.components.get('llm')
|
||||
if llm_component and hasattr(llm_component, 'llm_instance'):
|
||||
if hasattr(self._intent_instance, 'set_llm'):
|
||||
self._intent_instance.set_llm(llm_component.llm_instance)
|
||||
|
||||
logger.info(f"Intent组件初始化完成: {intent_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Intent组件初始化失败: {e}")
|
||||
raise
|
||||
|
||||
async def _do_cleanup(self) -> None:
|
||||
"""清理Intent组件"""
|
||||
if self._intent_instance:
|
||||
try:
|
||||
# 关闭Intent实例
|
||||
if hasattr(self._intent_instance, 'close'):
|
||||
await self._intent_instance.close()
|
||||
elif hasattr(self._intent_instance, 'cleanup'):
|
||||
await self._intent_instance.cleanup()
|
||||
|
||||
logger.info("Intent组件清理完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Intent组件清理失败: {e}")
|
||||
finally:
|
||||
self._intent_instance = None
|
||||
|
||||
@property
|
||||
def intent_instance(self):
|
||||
"""获取Intent实例"""
|
||||
return self._intent_instance
|
||||
|
||||
|
||||
class IntentFactory(ComponentFactory):
|
||||
"""Intent组件工厂"""
|
||||
|
||||
def create(self, config: Dict[str, Any]) -> Component:
|
||||
return IntentAdapter(config)
|
||||
|
||||
def get_component_type(self) -> ComponentType:
|
||||
return ComponentType.INTENT
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from typing import Any, Dict
|
||||
from core.components.component_manager import Component, ComponentType, ComponentFactory
|
||||
from core.utils import llm
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class LLMAdapter(Component):
|
||||
"""LLM组件适配器:将现有LLM组件包装为新的组件接口"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(ComponentType.LLM, config)
|
||||
self._llm_instance = None
|
||||
|
||||
async def _do_initialize(self, context: Any) -> None:
|
||||
"""初始化LLM组件"""
|
||||
try:
|
||||
# 获取LLM配置
|
||||
selected_module = self.config.get("selected_module", {}).get("LLM")
|
||||
if not selected_module:
|
||||
raise ValueError("未配置LLM模块")
|
||||
|
||||
# 获取LLM类型
|
||||
llm_type = (
|
||||
selected_module
|
||||
if "type" not in self.config["LLM"][selected_module]
|
||||
else self.config["LLM"][selected_module]["type"]
|
||||
)
|
||||
|
||||
# 创建LLM实例
|
||||
self._llm_instance = llm.create_instance(
|
||||
llm_type,
|
||||
self.config["LLM"][selected_module],
|
||||
)
|
||||
|
||||
# 注册资源以便清理
|
||||
self.add_resource(self._llm_instance)
|
||||
|
||||
logger.info(f"LLM组件初始化完成: {llm_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM组件初始化失败: {e}")
|
||||
raise
|
||||
|
||||
async def _do_cleanup(self) -> None:
|
||||
"""清理LLM组件"""
|
||||
if self._llm_instance:
|
||||
try:
|
||||
# 关闭LLM实例
|
||||
if hasattr(self._llm_instance, 'close'):
|
||||
await self._llm_instance.close()
|
||||
elif hasattr(self._llm_instance, 'cleanup'):
|
||||
await self._llm_instance.cleanup()
|
||||
|
||||
logger.info("LLM组件清理完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM组件清理失败: {e}")
|
||||
finally:
|
||||
self._llm_instance = None
|
||||
|
||||
@property
|
||||
def llm_instance(self):
|
||||
"""获取LLM实例"""
|
||||
return self._llm_instance
|
||||
|
||||
|
||||
class LLMFactory(ComponentFactory):
|
||||
"""LLM组件工厂"""
|
||||
|
||||
def create(self, config: Dict[str, Any]) -> Component:
|
||||
return LLMAdapter(config)
|
||||
|
||||
def get_component_type(self) -> ComponentType:
|
||||
return ComponentType.LLM
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from typing import Any, Dict
|
||||
from core.components.component_manager import Component, ComponentType, ComponentFactory
|
||||
from core.utils import memory
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class MemoryAdapter(Component):
|
||||
"""Memory组件适配器:将现有Memory组件包装为新的组件接口"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(ComponentType.MEMORY, config)
|
||||
self._memory_instance = None
|
||||
|
||||
async def _do_initialize(self, context: Any) -> None:
|
||||
"""初始化Memory组件"""
|
||||
try:
|
||||
# 获取Memory配置
|
||||
selected_module = self.config.get("selected_module", {}).get("Memory")
|
||||
if not selected_module:
|
||||
raise ValueError("未配置Memory模块")
|
||||
|
||||
# 获取Memory类型
|
||||
memory_type = (
|
||||
selected_module
|
||||
if "type" not in self.config["Memory"][selected_module]
|
||||
else self.config["Memory"][selected_module]["type"]
|
||||
)
|
||||
|
||||
# 创建Memory实例
|
||||
self._memory_instance = memory.create_instance(
|
||||
memory_type,
|
||||
self.config["Memory"][selected_module],
|
||||
self.config.get("summaryMemory", None),
|
||||
)
|
||||
|
||||
# 注册资源以便清理
|
||||
self.add_resource(self._memory_instance)
|
||||
|
||||
# 初始化记忆模块
|
||||
if hasattr(self._memory_instance, 'init_memory'):
|
||||
# 需要LLM实例来初始化记忆
|
||||
llm_component = context.components.get('llm')
|
||||
if llm_component and hasattr(llm_component, 'llm_instance'):
|
||||
self._memory_instance.init_memory(
|
||||
role_id=context.device_id,
|
||||
llm=llm_component.llm_instance,
|
||||
summary_memory=self.config.get("summaryMemory", None),
|
||||
save_to_file=not self.config.get("read_config_from_api", False),
|
||||
)
|
||||
|
||||
logger.info(f"Memory组件初始化完成: {memory_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Memory组件初始化失败: {e}")
|
||||
raise
|
||||
|
||||
async def _do_cleanup(self) -> None:
|
||||
"""清理Memory组件"""
|
||||
if self._memory_instance:
|
||||
try:
|
||||
# 保存记忆
|
||||
if hasattr(self._memory_instance, 'save_memory'):
|
||||
# 这里需要获取对话历史,暂时跳过
|
||||
pass
|
||||
|
||||
# 关闭Memory实例
|
||||
if hasattr(self._memory_instance, 'close'):
|
||||
await self._memory_instance.close()
|
||||
elif hasattr(self._memory_instance, 'cleanup'):
|
||||
await self._memory_instance.cleanup()
|
||||
|
||||
logger.info("Memory组件清理完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Memory组件清理失败: {e}")
|
||||
finally:
|
||||
self._memory_instance = None
|
||||
|
||||
@property
|
||||
def memory_instance(self):
|
||||
"""获取Memory实例"""
|
||||
return self._memory_instance
|
||||
|
||||
|
||||
class MemoryFactory(ComponentFactory):
|
||||
"""Memory组件工厂"""
|
||||
|
||||
def create(self, config: Dict[str, Any]) -> Component:
|
||||
return MemoryAdapter(config)
|
||||
|
||||
def get_component_type(self) -> ComponentType:
|
||||
return ComponentType.MEMORY
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from typing import Any, Dict
|
||||
from core.components.component_manager import Component, ComponentType, ComponentFactory
|
||||
from core.utils import tts
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class TTSAdapter(Component):
|
||||
"""TTS组件适配器:将现有TTS组件包装为新的组件接口"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(ComponentType.TTS, config)
|
||||
self._tts_instance = None
|
||||
self._delete_audio = config.get("delete_audio", True)
|
||||
|
||||
async def _do_initialize(self, context: Any) -> None:
|
||||
"""初始化TTS组件"""
|
||||
try:
|
||||
# 获取TTS配置
|
||||
selected_module = self.config.get("selected_module", {}).get("TTS")
|
||||
if not selected_module:
|
||||
raise ValueError("未配置TTS模块")
|
||||
|
||||
# 获取TTS类型
|
||||
tts_type = (
|
||||
selected_module
|
||||
if "type" not in self.config["TTS"][selected_module]
|
||||
else self.config["TTS"][selected_module]["type"]
|
||||
)
|
||||
|
||||
# 创建TTS实例
|
||||
self._tts_instance = tts.create_instance(
|
||||
tts_type,
|
||||
self.config["TTS"][selected_module],
|
||||
str(self._delete_audio).lower() in ("true", "1", "yes"),
|
||||
)
|
||||
|
||||
# 注册资源以便清理
|
||||
self.add_resource(self._tts_instance)
|
||||
|
||||
# 打开音频通道
|
||||
if hasattr(self._tts_instance, 'open_audio_channels'):
|
||||
await self._tts_instance.open_audio_channels(context)
|
||||
|
||||
# 设置兼容属性(用于向后兼容)
|
||||
if hasattr(context, 'tts'):
|
||||
context.tts = self._tts_instance
|
||||
|
||||
logger.info(f"TTS组件初始化完成: {tts_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"TTS组件初始化失败: {e}")
|
||||
raise
|
||||
|
||||
async def _do_cleanup(self) -> None:
|
||||
"""清理TTS组件"""
|
||||
if self._tts_instance:
|
||||
try:
|
||||
# 关闭TTS实例
|
||||
if hasattr(self._tts_instance, 'close'):
|
||||
await self._tts_instance.close()
|
||||
|
||||
# 清理音频文件
|
||||
if hasattr(self._tts_instance, 'cleanup_audio_files'):
|
||||
self._tts_instance.cleanup_audio_files()
|
||||
|
||||
logger.info("TTS组件清理完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"TTS组件清理失败: {e}")
|
||||
finally:
|
||||
self._tts_instance = None
|
||||
|
||||
@property
|
||||
def tts_instance(self):
|
||||
"""获取TTS实例"""
|
||||
return self._tts_instance
|
||||
|
||||
|
||||
class TTSFactory(ComponentFactory):
|
||||
"""TTS组件工厂"""
|
||||
|
||||
def create(self, config: Dict[str, Any]) -> Component:
|
||||
return TTSAdapter(config)
|
||||
|
||||
def get_component_type(self) -> ComponentType:
|
||||
return ComponentType.TTS
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from typing import Any, Dict
|
||||
from core.components.component_manager import Component, ComponentType, ComponentFactory
|
||||
from core.utils import vad
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class VADAdapter(Component):
|
||||
"""VAD组件适配器:将现有VAD组件包装为新的组件接口"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(ComponentType.VAD, config)
|
||||
self._vad_instance = None
|
||||
|
||||
async def _do_initialize(self, context: Any) -> None:
|
||||
"""初始化VAD组件"""
|
||||
try:
|
||||
# 获取VAD配置
|
||||
selected_module = self.config.get("selected_module", {}).get("VAD")
|
||||
if not selected_module:
|
||||
raise ValueError("未配置VAD模块")
|
||||
|
||||
# 获取VAD类型
|
||||
vad_type = (
|
||||
selected_module
|
||||
if "type" not in self.config["VAD"][selected_module]
|
||||
else self.config["VAD"][selected_module]["type"]
|
||||
)
|
||||
|
||||
# 创建VAD实例
|
||||
self._vad_instance = vad.create_instance(
|
||||
vad_type,
|
||||
self.config["VAD"][selected_module],
|
||||
)
|
||||
|
||||
# 注册资源以便清理
|
||||
self.add_resource(self._vad_instance)
|
||||
|
||||
logger.info(f"VAD组件初始化完成: {vad_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"VAD组件初始化失败: {e}")
|
||||
raise
|
||||
|
||||
async def _do_cleanup(self) -> None:
|
||||
"""清理VAD组件"""
|
||||
if self._vad_instance:
|
||||
try:
|
||||
# 关闭VAD实例
|
||||
if hasattr(self._vad_instance, 'close'):
|
||||
await self._vad_instance.close()
|
||||
elif hasattr(self._vad_instance, 'cleanup'):
|
||||
await self._vad_instance.cleanup()
|
||||
|
||||
logger.info("VAD组件清理完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"VAD组件清理失败: {e}")
|
||||
finally:
|
||||
self._vad_instance = None
|
||||
|
||||
@property
|
||||
def vad_instance(self):
|
||||
"""获取VAD实例"""
|
||||
return self._vad_instance
|
||||
|
||||
|
||||
class VADFactory(ComponentFactory):
|
||||
"""VAD组件工厂"""
|
||||
|
||||
def create(self, config: Dict[str, Any]) -> Component:
|
||||
return VADAdapter(config)
|
||||
|
||||
def get_component_type(self) -> ComponentType:
|
||||
return ComponentType.VAD
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import asyncio
|
||||
import weakref
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, Dict, Optional, Type, TypeVar, Generic
|
||||
from enum import Enum
|
||||
from config.logger import setup_logging
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class ComponentType(Enum):
|
||||
"""组件类型枚举"""
|
||||
TTS = "tts"
|
||||
ASR = "asr"
|
||||
VAD = "vad"
|
||||
LLM = "llm"
|
||||
MEMORY = "memory"
|
||||
INTENT = "intent"
|
||||
|
||||
|
||||
class ComponentState(Enum):
|
||||
"""组件状态枚举"""
|
||||
UNINITIALIZED = "uninitialized"
|
||||
INITIALIZING = "initializing"
|
||||
READY = "ready"
|
||||
ERROR = "error"
|
||||
CLEANING = "cleaning"
|
||||
CLEANED = "cleaned"
|
||||
|
||||
|
||||
class Component(ABC):
|
||||
"""组件基类:定义统一的组件接口和生命周期管理"""
|
||||
|
||||
def __init__(self, component_type: ComponentType, config: Dict[str, Any]):
|
||||
self.component_type = component_type
|
||||
self.config = config
|
||||
self.state = ComponentState.UNINITIALIZED
|
||||
self._initialization_lock = asyncio.Lock()
|
||||
self._cleanup_lock = asyncio.Lock()
|
||||
self._dependencies: Dict[str, 'Component'] = {}
|
||||
self._dependents: weakref.WeakSet['Component'] = weakref.WeakSet()
|
||||
self._resources: list = [] # 存储需要清理的资源
|
||||
|
||||
@abstractmethod
|
||||
async def _do_initialize(self, context: Any) -> None:
|
||||
"""子类实现具体的初始化逻辑"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def _do_cleanup(self) -> None:
|
||||
"""子类实现具体的清理逻辑"""
|
||||
pass
|
||||
|
||||
async def initialize(self, context: Any) -> None:
|
||||
"""初始化组件(带锁保护)"""
|
||||
async with self._initialization_lock:
|
||||
if self.state != ComponentState.UNINITIALIZED:
|
||||
return
|
||||
|
||||
try:
|
||||
self.state = ComponentState.INITIALIZING
|
||||
logger.info(f"正在初始化组件: {self.component_type.value}")
|
||||
|
||||
# 初始化依赖组件
|
||||
await self._initialize_dependencies(context)
|
||||
|
||||
# 执行具体初始化
|
||||
await self._do_initialize(context)
|
||||
|
||||
self.state = ComponentState.READY
|
||||
logger.info(f"组件初始化完成: {self.component_type.value}")
|
||||
|
||||
except Exception as e:
|
||||
self.state = ComponentState.ERROR
|
||||
logger.error(f"组件初始化失败: {self.component_type.value}, 错误: {e}")
|
||||
raise
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""清理组件(带锁保护)"""
|
||||
async with self._cleanup_lock:
|
||||
if self.state in [ComponentState.CLEANING, ComponentState.CLEANED]:
|
||||
return
|
||||
|
||||
try:
|
||||
self.state = ComponentState.CLEANING
|
||||
logger.info(f"正在清理组件: {self.component_type.value}")
|
||||
|
||||
# 清理依赖此组件的其他组件
|
||||
await self._cleanup_dependents()
|
||||
|
||||
# 执行具体清理
|
||||
await self._do_cleanup()
|
||||
|
||||
# 清理资源
|
||||
await self._cleanup_resources()
|
||||
|
||||
self.state = ComponentState.CLEANED
|
||||
logger.info(f"组件清理完成: {self.component_type.value}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"组件清理失败: {self.component_type.value}, 错误: {e}")
|
||||
# 即使清理失败,也标记为已清理,避免重复清理
|
||||
self.state = ComponentState.CLEANED
|
||||
|
||||
def add_dependency(self, name: str, component: 'Component') -> None:
|
||||
"""添加依赖组件"""
|
||||
self._dependencies[name] = component
|
||||
component._dependents.add(self)
|
||||
|
||||
def add_resource(self, resource: Any) -> None:
|
||||
"""添加需要清理的资源"""
|
||||
self._resources.append(resource)
|
||||
|
||||
async def _initialize_dependencies(self, context: Any) -> None:
|
||||
"""初始化依赖组件"""
|
||||
for name, dep in self._dependencies.items():
|
||||
if dep.state == ComponentState.UNINITIALIZED:
|
||||
await dep.initialize(context)
|
||||
|
||||
async def _cleanup_dependents(self) -> None:
|
||||
"""清理依赖此组件的其他组件"""
|
||||
for dependent in list(self._dependents):
|
||||
await dependent.cleanup()
|
||||
|
||||
async def _cleanup_resources(self) -> None:
|
||||
"""清理所有注册的资源"""
|
||||
for resource in self._resources:
|
||||
try:
|
||||
if hasattr(resource, 'close'):
|
||||
if asyncio.iscoroutinefunction(resource.close):
|
||||
await resource.close()
|
||||
else:
|
||||
resource.close()
|
||||
elif hasattr(resource, 'cleanup'):
|
||||
if asyncio.iscoroutinefunction(resource.cleanup):
|
||||
await resource.cleanup()
|
||||
else:
|
||||
resource.cleanup()
|
||||
except Exception as e:
|
||||
logger.warning(f"清理资源时出错: {e}")
|
||||
self._resources.clear()
|
||||
|
||||
|
||||
class ComponentFactory(ABC):
|
||||
"""组件工厂基类"""
|
||||
|
||||
@abstractmethod
|
||||
def create(self, config: Dict[str, Any]) -> Component:
|
||||
"""创建组件实例"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_component_type(self) -> ComponentType:
|
||||
"""获取组件类型"""
|
||||
pass
|
||||
|
||||
|
||||
class ComponentManager:
|
||||
"""
|
||||
组件管理器:统一管理连接期内的组件实例生命周期。
|
||||
支持分类管理、依赖注入、按需懒加载与统一清理。
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self._config = config
|
||||
self._components: Dict[str, Component] = {}
|
||||
self._factories: Dict[ComponentType, ComponentFactory] = {}
|
||||
self._initialization_order: list[ComponentType] = []
|
||||
|
||||
def register_factory(self, factory: ComponentFactory) -> None:
|
||||
"""注册组件工厂"""
|
||||
component_type = factory.get_component_type()
|
||||
self._factories[component_type] = factory
|
||||
logger.debug(f"注册组件工厂: {component_type.value}")
|
||||
|
||||
def set_initialization_order(self, order: list[ComponentType]) -> None:
|
||||
"""设置组件初始化顺序"""
|
||||
self._initialization_order = order
|
||||
|
||||
async def get_component(self, component_type: ComponentType, context: Any) -> Optional[Component]:
|
||||
"""获取组件实例(按需创建)"""
|
||||
key = component_type.value
|
||||
|
||||
if key not in self._components:
|
||||
factory = self._factories.get(component_type)
|
||||
if factory is None:
|
||||
logger.warning(f"未找到组件工厂: {component_type.value}")
|
||||
return None
|
||||
|
||||
try:
|
||||
instance = factory.create(self._config)
|
||||
await instance.initialize(context)
|
||||
self._components[key] = instance
|
||||
logger.info(f"组件创建并初始化完成: {component_type.value}")
|
||||
except Exception as e:
|
||||
logger.error(f"组件创建失败: {component_type.value}, 错误: {e}")
|
||||
return None
|
||||
|
||||
return self._components.get(key)
|
||||
|
||||
def get(self, component_name: str) -> Optional[Component]:
|
||||
"""获取已初始化的组件实例(兼容接口)"""
|
||||
return self._components.get(component_name)
|
||||
|
||||
async def initialize_all(self, context: Any) -> None:
|
||||
"""按顺序初始化所有组件"""
|
||||
for component_type in self._initialization_order:
|
||||
await self.get_component(component_type, context)
|
||||
|
||||
async def cleanup_all(self) -> None:
|
||||
"""清理所有组件(逆序清理)"""
|
||||
# 按逆序清理,确保依赖关系正确
|
||||
for component_type in reversed(self._initialization_order):
|
||||
key = component_type.value
|
||||
if key in self._components:
|
||||
component = self._components[key]
|
||||
await component.cleanup()
|
||||
del self._components[key]
|
||||
|
||||
# 清理可能遗漏的组件
|
||||
remaining_components = list(self._components.values())
|
||||
for component in remaining_components:
|
||||
await component.cleanup()
|
||||
|
||||
self._components.clear()
|
||||
logger.info("所有组件已清理完成")
|
||||
|
||||
def get_component_status(self) -> Dict[str, str]:
|
||||
"""获取所有组件状态"""
|
||||
return {
|
||||
name: component.state.value
|
||||
for name, component in self._components.items()
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
from typing import Dict, Any
|
||||
from core.components.component_manager import ComponentManager, ComponentType
|
||||
from core.components.adapters.tts_adapter import TTSFactory
|
||||
from core.components.adapters.asr_adapter import ASRFactory
|
||||
from core.components.adapters.vad_adapter import VADFactory
|
||||
from core.components.adapters.llm_adapter import LLMFactory
|
||||
from core.components.adapters.memory_adapter import MemoryFactory
|
||||
from core.components.adapters.intent_adapter import IntentFactory
|
||||
from config.logger import setup_logging
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class ComponentRegistry:
|
||||
"""组件注册器:统一管理所有组件工厂的注册"""
|
||||
|
||||
_factories_registered = False
|
||||
|
||||
@classmethod
|
||||
def create_component_manager(cls, config: Dict[str, Any]) -> ComponentManager:
|
||||
"""创建并配置组件管理器"""
|
||||
manager = ComponentManager(config)
|
||||
|
||||
# 只在第一次时记录注册日志
|
||||
if not cls._factories_registered:
|
||||
logger.info("注册组件工厂")
|
||||
cls._factories_registered = True
|
||||
|
||||
# 注册所有组件工厂(每个manager都需要注册,但不重复记录日志)
|
||||
manager.register_factory(TTSFactory())
|
||||
manager.register_factory(ASRFactory())
|
||||
manager.register_factory(VADFactory())
|
||||
manager.register_factory(LLMFactory())
|
||||
manager.register_factory(MemoryFactory())
|
||||
manager.register_factory(IntentFactory())
|
||||
|
||||
# 设置组件初始化顺序(考虑依赖关系)
|
||||
# VAD -> ASR -> LLM -> Memory/Intent -> TTS
|
||||
initialization_order = [
|
||||
ComponentType.VAD,
|
||||
ComponentType.ASR,
|
||||
ComponentType.LLM,
|
||||
ComponentType.MEMORY,
|
||||
ComponentType.INTENT,
|
||||
ComponentType.TTS,
|
||||
]
|
||||
manager.set_initialization_order(initialization_order)
|
||||
|
||||
if not cls._factories_registered:
|
||||
logger.info("组件管理器创建完成,已注册所有组件工厂")
|
||||
|
||||
return manager
|
||||
|
||||
@staticmethod
|
||||
def get_required_components(config: Dict[str, Any]) -> list[ComponentType]:
|
||||
"""根据配置获取需要的组件类型"""
|
||||
required = []
|
||||
selected_modules = config.get("selected_module", {})
|
||||
|
||||
if selected_modules.get("VAD"):
|
||||
required.append(ComponentType.VAD)
|
||||
if selected_modules.get("ASR"):
|
||||
required.append(ComponentType.ASR)
|
||||
if selected_modules.get("LLM"):
|
||||
required.append(ComponentType.LLM)
|
||||
if selected_modules.get("TTS"):
|
||||
required.append(ComponentType.TTS)
|
||||
if selected_modules.get("Memory"):
|
||||
required.append(ComponentType.MEMORY)
|
||||
if selected_modules.get("Intent"):
|
||||
required.append(ComponentType.INTENT)
|
||||
|
||||
return required
|
||||
|
||||
|
||||
@@ -24,23 +24,25 @@ from core.utils.modules_initialize import (
|
||||
initialize_tts,
|
||||
initialize_asr,
|
||||
)
|
||||
from core.handle.reportHandle import report
|
||||
# from core.handle.reportHandle import report # 旧架构,已被新架构替代
|
||||
from core.providers.tts.default import DefaultTTS
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from core.utils.dialogue import Message, Dialogue
|
||||
from core.providers.asr.dto.dto import InterfaceType
|
||||
from core.handle.textHandle import handleTextMessage
|
||||
# from core.handle.textHandle import handleTextMessage # 旧架构,已被新架构替代
|
||||
from core.providers.tools.unified_tool_handler import UnifiedToolHandler
|
||||
from plugins_func.loadplugins import auto_import_modules
|
||||
from plugins_func.register import Action
|
||||
from core.auth import AuthenticationError
|
||||
from config.config_loader import get_private_config_from_api
|
||||
from plugins_func.register import Action, ActionResponse
|
||||
from core.auth import AuthMiddleware, AuthenticationError
|
||||
from config.config_loader import get_private_config_from_api, ConfigDict
|
||||
from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType
|
||||
from config.logger import setup_logging, build_module_string, create_connection_logger
|
||||
from config.manage_api_client import DeviceNotFoundException, DeviceBindException
|
||||
from core.utils.prompt_manager import PromptManager
|
||||
from core.utils.voiceprint_provider import VoiceprintProvider
|
||||
from core.utils import textUtils
|
||||
from core.context.session_context import SessionContext
|
||||
from core.components.component_registry import ComponentRegistry
|
||||
|
||||
TAG = __name__
|
||||
|
||||
@@ -69,6 +71,7 @@ class ConnectionHandler:
|
||||
self.server = server # 保存server实例的引用
|
||||
|
||||
self.need_bind = False # 是否需要绑定设备
|
||||
self.bind_completed_event = asyncio.Event()
|
||||
self.bind_code = None # 绑定设备的验证码
|
||||
self.last_bind_prompt_time = 0 # 上次播放绑定提示的时间戳(秒)
|
||||
self.bind_prompt_interval = 60 # 绑定提示播放间隔(秒)
|
||||
@@ -143,7 +146,7 @@ class ConnectionHandler:
|
||||
self.iot_descriptors = {}
|
||||
self.func_handler = None
|
||||
|
||||
self.cmd_exit = self.config["exit_commands"]
|
||||
self.cmd_exit = self.config.get("exit_commands", [])
|
||||
|
||||
# 是否在聊天结束后关闭连接
|
||||
self.close_after_chat = False
|
||||
@@ -162,7 +165,12 @@ class ConnectionHandler:
|
||||
self.conn_from_mqtt_gateway = False
|
||||
|
||||
# 初始化提示词管理器
|
||||
self.prompt_manager = PromptManager(config, self.logger)
|
||||
self.prompt_manager = PromptManager(self.config, self.logger)
|
||||
|
||||
# 新增:会话上下文与组件管理器(会话级清理)
|
||||
self.session_context: SessionContext = SessionContext()
|
||||
self.session_context.config = self.config
|
||||
self.component_manager = ComponentRegistry.create_component_manager(self.config)
|
||||
|
||||
async def handle_connection(self, ws):
|
||||
try:
|
||||
@@ -186,6 +194,11 @@ class ConnectionHandler:
|
||||
|
||||
# 认证通过,继续处理
|
||||
self.websocket = ws
|
||||
|
||||
# 更新会话上下文关键信息
|
||||
self.session_context.headers = self.headers
|
||||
self.session_context.device_id = self.device_id
|
||||
self.session_context.client_ip = self.client_ip
|
||||
|
||||
# 检查是否来自MQTT连接
|
||||
request_path = ws.request.path
|
||||
@@ -200,7 +213,8 @@ class ConnectionHandler:
|
||||
# 启动超时检查任务
|
||||
self.timeout_task = asyncio.create_task(self._check_timeout())
|
||||
|
||||
self.welcome_msg = self.config["xiaozhi"]
|
||||
# 新的配置访问方式 - 使用点号访问
|
||||
self.welcome_msg = self.config.xiaozhi
|
||||
self.welcome_msg["session_id"] = self.session_id
|
||||
|
||||
# 在后台初始化配置和组件(完全不阻塞主循环)
|
||||
@@ -231,6 +245,18 @@ class ConnectionHandler:
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"强制关闭连接时出错: {close_error}"
|
||||
)
|
||||
finally:
|
||||
# 会话级组件与回调清理(容错)
|
||||
try:
|
||||
if hasattr(self, "component_manager") and self.component_manager:
|
||||
await self.component_manager.cleanup_all()
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"组件清理失败: {e}")
|
||||
try:
|
||||
if hasattr(self, "session_context") and self.session_context:
|
||||
await self.session_context.run_cleanup()
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"会话清理回调执行失败: {e}")
|
||||
|
||||
async def _save_and_close(self, ws):
|
||||
"""保存记忆并关闭连接"""
|
||||
@@ -243,7 +269,9 @@ class ConnectionHandler:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(
|
||||
self.memory.save_memory(self.dialogue.dialogue)
|
||||
self.memory.save_memory(
|
||||
self.dialogue.dialogue, self.session_id
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
|
||||
@@ -266,30 +294,43 @@ class ConnectionHandler:
|
||||
f"保存记忆后关闭连接失败: {close_error}"
|
||||
)
|
||||
|
||||
async def _discard_message_with_bind_prompt(self):
|
||||
"""丢弃消息并检查是否需要播放绑定提示"""
|
||||
current_time = time.time()
|
||||
# 检查是否需要播放绑定提示
|
||||
if current_time - self.last_bind_prompt_time >= self.bind_prompt_interval:
|
||||
self.last_bind_prompt_time = current_time
|
||||
# 复用现有的绑定提示逻辑
|
||||
from core.handle.receiveAudioHandle import check_bind_device
|
||||
|
||||
asyncio.create_task(check_bind_device(self))
|
||||
|
||||
async def _route_message(self, message):
|
||||
"""消息路由"""
|
||||
# 检查是否已经获取到真实的绑定状态
|
||||
if not self.bind_completed_event.is_set():
|
||||
# 还没有获取到真实状态,等待直到获取到真实状态或超时
|
||||
try:
|
||||
await asyncio.wait_for(self.bind_completed_event.wait(), timeout=1)
|
||||
except asyncio.TimeoutError:
|
||||
# 超时仍未获取到真实状态,丢弃消息
|
||||
await self._discard_message_with_bind_prompt()
|
||||
return
|
||||
|
||||
# 已经获取到真实状态,检查是否需要绑定
|
||||
if self.need_bind:
|
||||
# 需要绑定,丢弃消息
|
||||
await self._discard_message_with_bind_prompt()
|
||||
return
|
||||
|
||||
# 不需要绑定,继续处理消息
|
||||
|
||||
if isinstance(message, str):
|
||||
await handleTextMessage(self, message)
|
||||
elif isinstance(message, bytes):
|
||||
if self.vad is None or self.asr is None:
|
||||
return
|
||||
|
||||
# 未绑定设备直接丢弃所有音频,不进行ASR处理
|
||||
if self.need_bind:
|
||||
current_time = time.time()
|
||||
# 检查是否需要播放绑定提示
|
||||
if (
|
||||
current_time - self.last_bind_prompt_time
|
||||
>= self.bind_prompt_interval
|
||||
):
|
||||
self.last_bind_prompt_time = current_time
|
||||
# 复用现有的绑定提示逻辑
|
||||
from core.handle.receiveAudioHandle import check_bind_device
|
||||
|
||||
asyncio.create_task(check_bind_device(self))
|
||||
# 直接丢弃音频,不进行ASR处理
|
||||
return
|
||||
|
||||
# 处理来自MQTT网关的音频包
|
||||
if self.conn_from_mqtt_gateway and len(message) >= 16:
|
||||
handled = await self._process_mqtt_audio_message(message)
|
||||
@@ -413,6 +454,15 @@ class ConnectionHandler:
|
||||
|
||||
def _initialize_components(self):
|
||||
try:
|
||||
if self.tts is None:
|
||||
self.tts = self._initialize_tts()
|
||||
# 打开语音合成通道
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.tts.open_audio_channels(self), self.loop
|
||||
)
|
||||
if self.need_bind:
|
||||
self.bind_completed_event.set()
|
||||
return
|
||||
self.selected_module_str = build_module_string(
|
||||
self.config.get("selected_module", {})
|
||||
)
|
||||
@@ -436,17 +486,10 @@ class ConnectionHandler:
|
||||
|
||||
# 初始化声纹识别
|
||||
self._initialize_voiceprint()
|
||||
|
||||
# 打开语音识别通道
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.asr.open_audio_channels(self), self.loop
|
||||
)
|
||||
if self.tts is None:
|
||||
self.tts = self._initialize_tts()
|
||||
# 打开语音合成通道
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.tts.open_audio_channels(self), self.loop
|
||||
)
|
||||
|
||||
"""加载记忆"""
|
||||
self._initialize_memory()
|
||||
@@ -461,6 +504,7 @@ class ConnectionHandler:
|
||||
self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}")
|
||||
|
||||
def _init_prompt_enhancement(self):
|
||||
|
||||
# 更新上下文信息
|
||||
self.prompt_manager.update_context_info(self, self.client_ip)
|
||||
enhanced_prompt = self.prompt_manager.build_enhanced_prompt(
|
||||
@@ -496,7 +540,11 @@ class ConnectionHandler:
|
||||
|
||||
def _initialize_asr(self):
|
||||
"""初始化ASR"""
|
||||
if self._asr.interface_type == InterfaceType.LOCAL:
|
||||
if (
|
||||
self._asr is not None
|
||||
and hasattr(self._asr, "interface_type")
|
||||
and self._asr.interface_type == InterfaceType.LOCAL
|
||||
):
|
||||
# 如果公共ASR是本地服务,则直接返回
|
||||
# 因为本地一个实例ASR,可以被多个连接共享
|
||||
asr = self._asr
|
||||
@@ -536,6 +584,8 @@ class ConnectionHandler:
|
||||
async def _initialize_private_config_async(self):
|
||||
"""从接口异步获取差异化配置(异步版本,不阻塞主循环)"""
|
||||
if not self.read_config_from_api:
|
||||
self.need_bind = False
|
||||
self.bind_completed_event.set()
|
||||
return
|
||||
try:
|
||||
begin_time = time.time()
|
||||
@@ -548,6 +598,8 @@ class ConnectionHandler:
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"{time.time() - begin_time} 秒,异步获取差异化配置成功: {json.dumps(filter_sensitive_info(private_config), ensure_ascii=False)}"
|
||||
)
|
||||
self.need_bind = False
|
||||
self.bind_completed_event.set()
|
||||
except DeviceNotFoundException as e:
|
||||
self.need_bind = True
|
||||
private_config = {}
|
||||
@@ -572,7 +624,8 @@ class ConnectionHandler:
|
||||
|
||||
if init_vad:
|
||||
self.config["VAD"] = private_config["VAD"]
|
||||
self.config["selected_module"]["VAD"] = private_config["selected_module"][
|
||||
# 新的配置访问方式 - 使用嵌套路径设置
|
||||
self.config["selected_module.VAD"] = private_config["selected_module"][
|
||||
"VAD"
|
||||
]
|
||||
if init_asr:
|
||||
@@ -630,6 +683,8 @@ class ConnectionHandler:
|
||||
self.chat_history_conf = int(private_config["chat_history_conf"])
|
||||
if private_config.get("mcp_endpoint", None) is not None:
|
||||
self.config["mcp_endpoint"] = private_config["mcp_endpoint"]
|
||||
if private_config.get("context_providers", None) is not None:
|
||||
self.config["context_providers"] = private_config["context_providers"]
|
||||
|
||||
# 使用 run_in_executor 在线程池中执行 initialize_modules,避免阻塞主循环
|
||||
try:
|
||||
@@ -674,17 +729,16 @@ class ConnectionHandler:
|
||||
|
||||
# 获取记忆总结配置
|
||||
memory_config = self.config["Memory"]
|
||||
memory_type = self.config["Memory"][self.config["selected_module"]["Memory"]][
|
||||
"type"
|
||||
]
|
||||
# 新的配置访问方式 - 使用嵌套get方法
|
||||
memory_module = self.config.get("selected_module.Memory")
|
||||
memory_type = self.config.get(f"Memory.{memory_module}.type")
|
||||
# 如果使用 nomen,直接返回
|
||||
if memory_type == "nomem":
|
||||
return
|
||||
# 使用 mem_local_short 模式
|
||||
elif memory_type == "mem_local_short":
|
||||
memory_llm_name = memory_config[self.config["selected_module"]["Memory"]][
|
||||
"llm"
|
||||
]
|
||||
# 新的配置访问方式 - 使用嵌套get和f-string
|
||||
memory_llm_name = self.config.get(f"Memory.{memory_module}.llm")
|
||||
if memory_llm_name and memory_llm_name in self.config["LLM"]:
|
||||
# 如果配置了专用LLM,则创建独立的LLM实例
|
||||
from core.utils import llm as llm_utils
|
||||
@@ -1049,6 +1103,7 @@ class ConnectionHandler:
|
||||
"""处理上报任务"""
|
||||
try:
|
||||
# 执行异步上报(在事件循环中运行)
|
||||
from core.handle.reportHandle import report
|
||||
asyncio.run(report(self, type, text, audio_data, report_time))
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"上报处理异常: {e}")
|
||||
@@ -1169,6 +1224,11 @@ class ConnectionHandler:
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# 重置音频流控器(取消后台任务并清空队列)
|
||||
if hasattr(self, "audio_rate_controller") and self.audio_rate_controller:
|
||||
self.audio_rate_controller.reset()
|
||||
self.logger.bind(tag=TAG).debug("已重置音频流控器")
|
||||
|
||||
self.logger.bind(tag=TAG).debug(
|
||||
f"清理结束: TTS队列大小={self.tts.tts_text_queue.qsize()}, 音频队列大小={self.tts.tts_audio_queue.qsize()}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import copy
|
||||
import uuid
|
||||
import time
|
||||
import queue
|
||||
import asyncio
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, List, Callable, Awaitable, Union
|
||||
from collections import deque
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from core.utils.dialogue import Dialogue
|
||||
from core.auth import AuthMiddleware
|
||||
from core.utils.prompt_manager import PromptManager
|
||||
from core.utils.voiceprint_provider import VoiceprintProvider
|
||||
from config.logger import setup_logging
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionContext:
|
||||
"""
|
||||
会话上下文:完全替换ConnectionHandler的所有功能
|
||||
承载单连接生命周期内的状态、组件、资源管理
|
||||
与传输层解耦,支持WebSocket/MQTT/UDP等多协议
|
||||
"""
|
||||
|
||||
# === 基础标识 ===
|
||||
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
device_id: Optional[str] = None
|
||||
client_ip: Optional[str] = None
|
||||
headers: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# === 配置管理 ===
|
||||
config: Dict[str, Any] = field(default_factory=dict)
|
||||
common_config: Dict[str, Any] = field(default_factory=dict)
|
||||
private_config: Dict[str, Any] = field(default_factory=dict)
|
||||
selected_module_str: str = ""
|
||||
|
||||
# === 认证与绑定 ===
|
||||
is_authenticated: bool = False
|
||||
need_bind: bool = False
|
||||
bind_code: Optional[str] = None
|
||||
read_config_from_api: bool = False
|
||||
max_output_size: int = 0
|
||||
chat_history_conf: int = 0
|
||||
|
||||
# === 会话状态 ===
|
||||
is_speaking: bool = False
|
||||
listen_mode: str = "auto"
|
||||
abort_requested: bool = False
|
||||
close_after_chat: bool = False
|
||||
just_woken_up: bool = False
|
||||
load_function_plugin: bool = False
|
||||
intent_type: str = "nointent"
|
||||
|
||||
# === 音频相关 ===
|
||||
audio_format: str = "opus"
|
||||
client_have_voice: bool = False
|
||||
client_voice_stop: bool = False
|
||||
client_audio_buffer: bytearray = field(default_factory=bytearray)
|
||||
client_voice_window: deque = field(default_factory=lambda: deque(maxlen=5))
|
||||
last_is_voice: bool = False
|
||||
audio_flow_control: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# === ASR相关 ===
|
||||
asr_audio: List[bytes] = field(default_factory=list)
|
||||
asr_audio_queue: queue.Queue = field(default_factory=queue.Queue)
|
||||
asr_priority_thread: Optional[threading.Thread] = None
|
||||
|
||||
# === LLM相关 ===
|
||||
llm_finish_task: bool = True
|
||||
dialogue: Optional[Dialogue] = None
|
||||
current_speaker: Optional[str] = None
|
||||
sentence_id: Optional[str] = None
|
||||
|
||||
# === TTS相关 ===
|
||||
tts_MessageText: str = ""
|
||||
|
||||
# === IoT相关 ===
|
||||
iot_descriptors: Dict[str, Any] = field(default_factory=dict)
|
||||
func_handler: Optional[Any] = None
|
||||
|
||||
# === 时间管理 ===
|
||||
last_activity_time_ms: float = field(default_factory=lambda: time.time() * 1000)
|
||||
created_at: float = field(default_factory=lambda: time.time())
|
||||
timeout_seconds: int = 180 # 默认超时时间
|
||||
timeout_task: Optional[asyncio.Task] = None
|
||||
|
||||
# === 组件实例 ===
|
||||
# components属性通过@property方法提供,指向component_manager
|
||||
|
||||
# === 其他状态 ===
|
||||
welcome_msg: Optional[Dict[str, Any]] = None
|
||||
prompt: Optional[str] = None
|
||||
features: Optional[Dict[str, Any]] = None
|
||||
mcp_client: Optional[Any] = None
|
||||
cmd_exit: List[str] = field(default_factory=list)
|
||||
|
||||
# === 线程与并发 ===
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
stop_event: Optional[threading.Event] = None
|
||||
executor: Optional[ThreadPoolExecutor] = None
|
||||
|
||||
# === 队列管理 ===
|
||||
report_queue: queue.Queue = field(default_factory=queue.Queue)
|
||||
report_thread: Optional[threading.Thread] = None
|
||||
report_asr_enable: bool = False
|
||||
report_tts_enable: bool = False
|
||||
|
||||
# === 组件管理器 ===
|
||||
component_manager: Optional[Any] = None
|
||||
|
||||
# === 兼容属性(用于向后兼容TTS处理) ===
|
||||
tts: Optional[Any] = None
|
||||
websocket: Optional[Any] = None # 兼容旧TTS组件
|
||||
transport: Optional[Any] = None # 新的transport接口
|
||||
|
||||
# === 工具类 ===
|
||||
auth: Optional[AuthMiddleware] = None
|
||||
prompt_manager: Optional[PromptManager] = None
|
||||
voiceprint_provider: Optional[VoiceprintProvider] = None
|
||||
server: Optional[Any] = None # WebSocket服务器引用
|
||||
|
||||
# === 会话级清理回调 ===
|
||||
_cleanup_callbacks: List[Callable[[], Union[None, Awaitable[None]]]] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
"""初始化后处理"""
|
||||
# 深拷贝配置避免污染
|
||||
if self.config:
|
||||
self.common_config = self.config
|
||||
self.config = copy.deepcopy(self.config)
|
||||
|
||||
# 从配置中读取相关设置
|
||||
self.read_config_from_api = self.config.get("read_config_from_api", False)
|
||||
self.max_output_size = self.config.get("max_output_size", 0)
|
||||
self.chat_history_conf = self.config.get("chat_history_conf", 0)
|
||||
self.cmd_exit = self.config.get("exit_commands", [])
|
||||
self.timeout_seconds = int(self.config.get("close_connection_no_voice_time", 120)) + 60
|
||||
|
||||
# 初始化认证中间件
|
||||
self.auth = AuthMiddleware(self.config)
|
||||
|
||||
# 初始化提示词管理器
|
||||
self.prompt_manager = PromptManager(self.config, setup_logging())
|
||||
|
||||
# 初始化对话管理
|
||||
if not self.dialogue:
|
||||
self.dialogue = Dialogue()
|
||||
|
||||
# 初始化线程相关
|
||||
if not self.loop:
|
||||
try:
|
||||
self.loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
self.loop = asyncio.new_event_loop()
|
||||
|
||||
if not self.stop_event:
|
||||
self.stop_event = threading.Event()
|
||||
|
||||
if not self.executor:
|
||||
self.executor = ThreadPoolExecutor(max_workers=5)
|
||||
|
||||
# 初始化上报设置
|
||||
self.report_asr_enable = self.read_config_from_api
|
||||
self.report_tts_enable = self.read_config_from_api
|
||||
|
||||
def update_activity(self) -> None:
|
||||
"""刷新最后活跃时间"""
|
||||
self.last_activity_time_ms = time.time() * 1000
|
||||
|
||||
def clearSpeakStatus(self) -> None:
|
||||
"""清除服务端讲话状态(兼容方法)"""
|
||||
self.is_speaking = False
|
||||
logger = setup_logging()
|
||||
logger.debug("清除服务端讲话状态")
|
||||
|
||||
def reset_vad_states(self) -> None:
|
||||
"""重置VAD状态(兼容方法)"""
|
||||
self.client_audio_buffer = bytearray()
|
||||
self.client_have_voice = False
|
||||
self.client_voice_stop = False
|
||||
logger = setup_logging()
|
||||
logger.debug("VAD states reset.")
|
||||
|
||||
def is_timeout(self, timeout_seconds: int) -> bool:
|
||||
"""检查是否超时"""
|
||||
now_ms = time.time() * 1000
|
||||
return (now_ms - self.last_activity_time_ms) > (timeout_seconds * 1000)
|
||||
|
||||
def register_cleanup(self, callback: Callable[[], Union[None, Awaitable[None]]]) -> None:
|
||||
"""注册会话结束时需要执行的清理回调"""
|
||||
self._cleanup_callbacks.append(callback)
|
||||
|
||||
async def run_cleanup(self) -> None:
|
||||
"""执行所有注册的清理回调"""
|
||||
logger = setup_logging()
|
||||
logger.info(f"Session {self.session_id} 开始执行会话级清理 ({len(self._cleanup_callbacks)} 个回调)")
|
||||
|
||||
# 停止所有线程
|
||||
if self.stop_event:
|
||||
self.stop_event.set()
|
||||
|
||||
# 关闭线程池
|
||||
if self.executor:
|
||||
self.executor.shutdown(wait=False)
|
||||
|
||||
# 取消超时任务
|
||||
if self.timeout_task and not self.timeout_task.done():
|
||||
self.timeout_task.cancel()
|
||||
|
||||
# 执行清理回调
|
||||
for callback in reversed(self._cleanup_callbacks):
|
||||
try:
|
||||
result = callback()
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.error(f"Session {self.session_id} 清理回调执行失败: {e}", exc_info=True)
|
||||
|
||||
self._cleanup_callbacks.clear()
|
||||
logger.info(f"Session {self.session_id} 会话级清理完成")
|
||||
|
||||
# === 兼容旧代码的属性访问 ===
|
||||
@property
|
||||
def client_is_speaking(self) -> bool:
|
||||
"""兼容旧代码的属性名"""
|
||||
return self.is_speaking
|
||||
|
||||
@client_is_speaking.setter
|
||||
def client_is_speaking(self, value: bool):
|
||||
self.is_speaking = value
|
||||
|
||||
@property
|
||||
def client_listen_mode(self) -> str:
|
||||
"""兼容旧代码的属性名"""
|
||||
return self.listen_mode
|
||||
|
||||
@client_listen_mode.setter
|
||||
def client_listen_mode(self, value: str):
|
||||
self.listen_mode = value
|
||||
|
||||
@property
|
||||
def client_abort(self) -> bool:
|
||||
"""兼容旧代码的属性名"""
|
||||
return self.abort_requested
|
||||
|
||||
@client_abort.setter
|
||||
def client_abort(self, value: bool):
|
||||
self.abort_requested = value
|
||||
|
||||
@property
|
||||
def components(self):
|
||||
"""组件访问器(兼容属性)"""
|
||||
return self.component_manager
|
||||
|
||||
@components.setter
|
||||
def components(self, value):
|
||||
"""组件设置器(兼容属性)- 实际设置到component_manager"""
|
||||
# 如果尝试设置components,我们忽略它或者给出警告
|
||||
# 因为components应该通过component_manager管理
|
||||
logger = setup_logging()
|
||||
logger.warning("尝试直接设置components属性,请使用component_manager")
|
||||
|
||||
@property
|
||||
def last_activity_time(self) -> float:
|
||||
"""兼容旧代码:返回毫秒级时间戳"""
|
||||
return self.last_activity_time_ms
|
||||
|
||||
@last_activity_time.setter
|
||||
def last_activity_time(self, value: float):
|
||||
"""兼容旧代码:接受毫秒级时间戳"""
|
||||
self.last_activity_time_ms = value
|
||||
|
||||
# === 日志相关 ===
|
||||
@property
|
||||
def logger(self):
|
||||
"""获取日志记录器"""
|
||||
return setup_logging()
|
||||
|
||||
# === 工具方法 ===
|
||||
def get_component(self, component_name: str) -> Optional[Any]:
|
||||
"""获取组件实例"""
|
||||
return self.components.get(component_name)
|
||||
|
||||
def set_component(self, component_name: str, component_instance: Any) -> None:
|
||||
"""设置组件实例"""
|
||||
if self.component_manager:
|
||||
self.component_manager._components[component_name] = component_instance
|
||||
|
||||
def has_component(self, component_name: str) -> bool:
|
||||
"""检查是否有指定组件"""
|
||||
return component_name in self.components
|
||||
|
||||
def clear_audio_buffer(self) -> None:
|
||||
"""清空音频缓冲区"""
|
||||
self.client_audio_buffer.clear()
|
||||
self.asr_audio.clear()
|
||||
|
||||
# 清空队列
|
||||
try:
|
||||
while not self.asr_audio_queue.empty():
|
||||
self.asr_audio_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def reset_voice_state(self) -> None:
|
||||
"""重置语音状态"""
|
||||
self.client_have_voice = False
|
||||
self.client_voice_stop = False
|
||||
self.last_is_voice = False
|
||||
self.client_voice_window.clear()
|
||||
|
||||
def initialize_private_config(self) -> None:
|
||||
"""初始化差异化配置(从ConnectionHandler迁移)"""
|
||||
from config.config_loader import get_private_config_from_api
|
||||
from config.manage_api_client import DeviceNotFoundException, DeviceBindException
|
||||
|
||||
if not self.read_config_from_api:
|
||||
return
|
||||
|
||||
try:
|
||||
# 获取设备私有配置
|
||||
private_config = get_private_config_from_api(
|
||||
self.config, self.device_id, self.headers.get("client-id")
|
||||
)
|
||||
|
||||
if private_config:
|
||||
self.private_config = private_config
|
||||
# 合并私有配置到主配置
|
||||
self.config.update(private_config)
|
||||
|
||||
except DeviceNotFoundException:
|
||||
self.logger.error(f"设备 {self.device_id} 未找到")
|
||||
self.need_bind = True
|
||||
except DeviceBindException as e:
|
||||
self.logger.error(f"设备绑定异常: {e}")
|
||||
self.need_bind = True
|
||||
self.bind_code = str(e)
|
||||
except Exception as e:
|
||||
self.logger.error(f"获取私有配置失败: {e}")
|
||||
|
||||
async def initialize_components(self) -> None:
|
||||
"""异步初始化组件(从ConnectionHandler迁移)"""
|
||||
if not self.component_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
# 初始化各个组件
|
||||
from core.components.component_registry import ComponentType
|
||||
|
||||
# 按依赖顺序初始化组件
|
||||
component_types = [
|
||||
ComponentType.VAD,
|
||||
ComponentType.ASR,
|
||||
ComponentType.LLM,
|
||||
ComponentType.MEMORY,
|
||||
ComponentType.INTENT,
|
||||
ComponentType.TTS
|
||||
]
|
||||
|
||||
for component_type in component_types:
|
||||
try:
|
||||
component = await self.component_manager.get_component(component_type, self)
|
||||
if component:
|
||||
self.logger.info(f"组件 {component_type} 初始化成功")
|
||||
except Exception as e:
|
||||
self.logger.error(f"组件 {component_type} 初始化失败: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"组件初始化失败: {e}")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"SessionContext(session_id={self.session_id}, device_id={self.device_id})"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ async def checkWakeupWords(conn, text):
|
||||
}
|
||||
|
||||
# 获取音频数据
|
||||
opus_packets = audio_to_data(response.get("file_path"))
|
||||
opus_packets = await audio_to_data(response.get("file_path"), use_cache=False)
|
||||
# 播放唤醒词回复
|
||||
conn.client_abort = False
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ async def max_out_size(conn):
|
||||
text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!"
|
||||
await send_stt_message(conn, text)
|
||||
file_path = "config/assets/max_output_size.wav"
|
||||
opus_packets = audio_to_data(file_path)
|
||||
opus_packets = await audio_to_data(file_path)
|
||||
conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text))
|
||||
conn.close_after_chat = True
|
||||
|
||||
@@ -142,7 +142,7 @@ async def check_bind_device(conn):
|
||||
|
||||
# 播放提示音
|
||||
music_path = "config/assets/bind_code.wav"
|
||||
opus_packets = audio_to_data(music_path)
|
||||
opus_packets = await audio_to_data(music_path)
|
||||
conn.tts.tts_audio_queue.put((SentenceType.FIRST, opus_packets, text))
|
||||
|
||||
# 逐个播放数字
|
||||
@@ -150,7 +150,7 @@ async def check_bind_device(conn):
|
||||
try:
|
||||
digit = conn.bind_code[i]
|
||||
num_path = f"config/assets/bind_code/{digit}.wav"
|
||||
num_packets = audio_to_data(num_path)
|
||||
num_packets = await audio_to_data(num_path)
|
||||
conn.tts.tts_audio_queue.put((SentenceType.MIDDLE, num_packets, None))
|
||||
except Exception as e:
|
||||
conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
|
||||
@@ -162,5 +162,5 @@ async def check_bind_device(conn):
|
||||
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
|
||||
await send_stt_message(conn, text)
|
||||
music_path = "config/assets/bind_not_found.wav"
|
||||
opus_packets = audio_to_data(music_path)
|
||||
opus_packets = await audio_to_data(music_path)
|
||||
conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text))
|
||||
|
||||
@@ -10,7 +10,6 @@ TTS上报功能已集成到ConnectionHandler类中。
|
||||
"""
|
||||
|
||||
import time
|
||||
import gc
|
||||
import opuslib_next
|
||||
|
||||
from config.manage_api_client import report as manage_report
|
||||
|
||||
@@ -7,6 +7,10 @@ from core.providers.tts.dto.dto import SentenceType
|
||||
from core.utils.audioRateController import AudioRateController
|
||||
|
||||
TAG = __name__
|
||||
# 音频帧时长(毫秒)
|
||||
AUDIO_FRAME_DURATION = 60
|
||||
# 预缓冲包数量,直接发送以减少延迟
|
||||
PRE_BUFFER_COUNT = 5
|
||||
|
||||
|
||||
async def sendAudioMessage(conn, sentenceType, audios, text):
|
||||
@@ -16,7 +20,19 @@ async def sendAudioMessage(conn, sentenceType, audios, text):
|
||||
await send_tts_message(conn, "start", None)
|
||||
|
||||
if sentenceType == SentenceType.FIRST:
|
||||
await send_tts_message(conn, "sentence_start", text)
|
||||
# 同一句子的后续消息加入流控队列,其他情况立即发送
|
||||
if (
|
||||
hasattr(conn, "audio_rate_controller")
|
||||
and conn.audio_rate_controller
|
||||
and getattr(conn, "audio_flow_control", {}).get("sentence_id")
|
||||
== conn.sentence_id
|
||||
):
|
||||
conn.audio_rate_controller.add_message(
|
||||
lambda: send_tts_message(conn, "sentence_start", text)
|
||||
)
|
||||
else:
|
||||
# 新句子或流控器未初始化,立即发送
|
||||
await send_tts_message(conn, "sentence_start", text)
|
||||
|
||||
await sendAudio(conn, audios)
|
||||
# 发送句子开始消息
|
||||
@@ -31,6 +47,29 @@ async def sendAudioMessage(conn, sentenceType, audios, text):
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _wait_for_audio_completion(conn):
|
||||
"""
|
||||
等待音频队列清空并等待预缓冲包播放完成
|
||||
|
||||
Args:
|
||||
conn: 连接对象
|
||||
"""
|
||||
if hasattr(conn, "audio_rate_controller") and conn.audio_rate_controller:
|
||||
rate_controller = conn.audio_rate_controller
|
||||
conn.logger.bind(tag=TAG).debug(
|
||||
f"等待音频发送完成,队列中还有 {len(rate_controller.queue)} 个包"
|
||||
)
|
||||
await rate_controller.queue_empty_event.wait()
|
||||
|
||||
# 等待预缓冲包播放完成
|
||||
# 前N个包直接发送,增加2个网络抖动包,需要额外等待它们在客户端播放完成
|
||||
frame_duration_ms = rate_controller.frame_duration
|
||||
pre_buffer_playback_time = (PRE_BUFFER_COUNT + 2) * frame_duration_ms / 1000.0
|
||||
await asyncio.sleep(pre_buffer_playback_time)
|
||||
|
||||
conn.logger.bind(tag=TAG).debug("音频发送完成")
|
||||
|
||||
|
||||
async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence):
|
||||
"""
|
||||
发送带16字节头部的opus数据包给mqtt_gateway
|
||||
@@ -53,137 +92,133 @@ async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence):
|
||||
await conn.websocket.send(complete_packet)
|
||||
|
||||
|
||||
# 播放音频 - 使用 AudioRateController 进行精确流控
|
||||
async def sendAudio(conn, audios, frame_duration=60):
|
||||
async def sendAudio(conn, audios, frame_duration=AUDIO_FRAME_DURATION):
|
||||
"""
|
||||
发送音频包,使用 AudioRateController 进行精确的流量控制
|
||||
|
||||
Args:
|
||||
conn: 连接对象
|
||||
audios: 单个opus包(bytes) 或 opus包列表
|
||||
frame_duration: 帧时长(毫秒),默认60ms
|
||||
|
||||
改进点:
|
||||
1. 使用单一时间基准,避免累积误差
|
||||
2. 每次检查队列时重新计算 elapsed_ms,更精准
|
||||
3. 支持高并发而不产生时间偏差
|
||||
frame_duration: 帧时长(毫秒),默认使用全局常量AUDIO_FRAME_DURATION
|
||||
"""
|
||||
if audios is None or len(audios) == 0:
|
||||
return
|
||||
|
||||
# 获取发送延迟配置
|
||||
send_delay = conn.config.get("tts_audio_send_delay", -1) / 1000.0
|
||||
is_single_packet = isinstance(audios, bytes)
|
||||
|
||||
if isinstance(audios, bytes):
|
||||
# 单个 opus 包处理
|
||||
await _sendAudio_single(conn, audios, send_delay, frame_duration)
|
||||
else:
|
||||
# 音频列表处理(如文件型音频)
|
||||
await _sendAudio_list(conn, audios, send_delay, frame_duration)
|
||||
# 初始化或获取 RateController
|
||||
rate_controller, flow_control = _get_or_create_rate_controller(
|
||||
conn, frame_duration, is_single_packet
|
||||
)
|
||||
|
||||
# 统一转换为列表处理
|
||||
audio_list = [audios] if is_single_packet else audios
|
||||
|
||||
# 发送音频包
|
||||
await _send_audio_with_rate_control(
|
||||
conn, audio_list, rate_controller, flow_control, send_delay
|
||||
)
|
||||
|
||||
|
||||
async def _sendAudio_single(conn, opus_packet, send_delay, frame_duration=60):
|
||||
def _get_or_create_rate_controller(conn, frame_duration, is_single_packet):
|
||||
"""
|
||||
发送单个 opus 包
|
||||
使用 AudioRateController 进行流控
|
||||
获取或创建 RateController 和 flow_control
|
||||
|
||||
Args:
|
||||
conn: 连接对象
|
||||
frame_duration: 帧时长
|
||||
is_single_packet: 是否单包模式(True: TTS流式单包, False: 批量包)
|
||||
|
||||
Returns:
|
||||
(rate_controller, flow_control)
|
||||
"""
|
||||
# 重置流控状态,第一次读取和会话发生转变时
|
||||
if not hasattr(conn, "audio_rate_controller") or conn.audio_flow_control.get("sentence_id") != conn.sentence_id:
|
||||
if hasattr(conn, "audio_rate_controller"):
|
||||
conn.audio_rate_controller.reset()
|
||||
else:
|
||||
# 判断是否需要重置:单包模式且 sentence_id 变化,或者控制器不存在
|
||||
need_reset = (
|
||||
is_single_packet
|
||||
and getattr(conn, "audio_flow_control", {}).get("sentence_id")
|
||||
!= conn.sentence_id
|
||||
) or not hasattr(conn, "audio_rate_controller")
|
||||
|
||||
if need_reset:
|
||||
# 创建或获取 rate_controller
|
||||
if not hasattr(conn, "audio_rate_controller"):
|
||||
conn.audio_rate_controller = AudioRateController(frame_duration)
|
||||
else:
|
||||
conn.audio_rate_controller.reset()
|
||||
|
||||
# 初始化 flow_control
|
||||
conn.audio_flow_control = {
|
||||
"packet_count": 0,
|
||||
"sequence": 0,
|
||||
"sentence_id": conn.sentence_id,
|
||||
}
|
||||
|
||||
if conn.client_abort:
|
||||
return
|
||||
# 启动后台发送循环
|
||||
_start_background_sender(
|
||||
conn, conn.audio_rate_controller, conn.audio_flow_control
|
||||
)
|
||||
|
||||
conn.last_activity_time = time.time() * 1000
|
||||
|
||||
rate_controller = conn.audio_rate_controller
|
||||
flow_control = conn.audio_flow_control
|
||||
packet_count = flow_control["packet_count"]
|
||||
|
||||
# 预缓冲:前5个包直接发送,不做延迟
|
||||
pre_buffer_count = 5
|
||||
|
||||
if packet_count < pre_buffer_count or send_delay > 0:
|
||||
# 预缓冲阶段或固定延迟模式,直接发送
|
||||
await _do_send_audio(conn, opus_packet, flow_control, frame_duration)
|
||||
|
||||
if send_delay > 0 and packet_count >= pre_buffer_count:
|
||||
await asyncio.sleep(send_delay)
|
||||
else:
|
||||
# 使用流控器进行精确的速率控制
|
||||
rate_controller.add_audio(opus_packet)
|
||||
|
||||
async def send_callback(packet):
|
||||
await _do_send_audio(conn, packet, flow_control, frame_duration)
|
||||
|
||||
await rate_controller.check_queue(send_callback)
|
||||
|
||||
# 更新流控状态
|
||||
flow_control["packet_count"] += 1
|
||||
flow_control["sequence"] += 1
|
||||
return conn.audio_rate_controller, conn.audio_flow_control
|
||||
|
||||
|
||||
async def _sendAudio_list(conn, audios, send_delay, frame_duration=60):
|
||||
def _start_background_sender(conn, rate_controller, flow_control):
|
||||
"""
|
||||
发送音频列表(如文件型音频)
|
||||
启动后台发送循环任务
|
||||
|
||||
Args:
|
||||
conn: 连接对象
|
||||
rate_controller: 速率控制器
|
||||
flow_control: 流控状态
|
||||
"""
|
||||
if not audios:
|
||||
return
|
||||
|
||||
rate_controller = AudioRateController(frame_duration)
|
||||
rate_controller.reset()
|
||||
|
||||
flow_control = {
|
||||
"packet_count": 0,
|
||||
"sequence": 0,
|
||||
}
|
||||
|
||||
# 预缓冲:前5个包直接发送
|
||||
pre_buffer_frames = min(5, len(audios))
|
||||
for i in range(pre_buffer_frames):
|
||||
async def send_callback(packet):
|
||||
# 检查是否应该中止
|
||||
if conn.client_abort:
|
||||
return
|
||||
await _do_send_audio(conn, audios[i], flow_control, frame_duration)
|
||||
raise asyncio.CancelledError("客户端已中止")
|
||||
|
||||
conn.last_activity_time = time.time() * 1000
|
||||
await _do_send_audio(conn, packet, flow_control)
|
||||
conn.client_is_speaking = True
|
||||
|
||||
remaining_audios = audios[pre_buffer_frames:]
|
||||
# 使用 start_sending 启动后台循环
|
||||
rate_controller.start_sending(send_callback)
|
||||
|
||||
# 处理剩余音频帧
|
||||
for i, opus_packet in enumerate(remaining_audios):
|
||||
|
||||
async def _send_audio_with_rate_control(
|
||||
conn, audio_list, rate_controller, flow_control, send_delay
|
||||
):
|
||||
"""
|
||||
使用 rate_controller 发送音频包
|
||||
|
||||
Args:
|
||||
conn: 连接对象
|
||||
audio_list: 音频包列表
|
||||
rate_controller: 速率控制器
|
||||
flow_control: 流控状态
|
||||
send_delay: 固定延迟(秒),-1表示使用动态流控
|
||||
"""
|
||||
for packet in audio_list:
|
||||
if conn.client_abort:
|
||||
break
|
||||
return
|
||||
|
||||
conn.last_activity_time = time.time() * 1000
|
||||
|
||||
if send_delay > 0:
|
||||
# 预缓冲:前N个包直接发送
|
||||
if flow_control["packet_count"] < PRE_BUFFER_COUNT:
|
||||
await _do_send_audio(conn, packet, flow_control)
|
||||
conn.client_is_speaking = True
|
||||
elif send_delay > 0:
|
||||
# 固定延迟模式
|
||||
await asyncio.sleep(send_delay)
|
||||
else:
|
||||
# 使用流控器进行精确延迟
|
||||
rate_controller.add_audio(opus_packet)
|
||||
|
||||
async def send_callback(packet):
|
||||
await _do_send_audio(conn, packet, flow_control, frame_duration)
|
||||
|
||||
await rate_controller.check_queue(send_callback)
|
||||
await _do_send_audio(conn, packet, flow_control)
|
||||
conn.client_is_speaking = True
|
||||
continue
|
||||
|
||||
await _do_send_audio(conn, opus_packet, flow_control, frame_duration)
|
||||
conn.client_is_speaking = True
|
||||
else:
|
||||
# 动态流控模式:仅添加到队列,由后台循环负责发送
|
||||
rate_controller.add_audio(packet)
|
||||
|
||||
|
||||
async def _do_send_audio(conn, opus_packet, flow_control, frame_duration=60):
|
||||
async def _do_send_audio(conn, opus_packet, flow_control):
|
||||
"""
|
||||
执行实际的音频发送
|
||||
"""
|
||||
@@ -220,8 +255,10 @@ async def send_tts_message(conn, state, text=None):
|
||||
stop_tts_notify_voice = conn.config.get(
|
||||
"stop_tts_notify_voice", "config/assets/tts_notify.mp3"
|
||||
)
|
||||
audios = audio_to_data(stop_tts_notify_voice, is_opus=True)
|
||||
audios = await audio_to_data(stop_tts_notify_voice, is_opus=True)
|
||||
await sendAudio(conn, audios)
|
||||
# 等待所有音频包发送完成
|
||||
await _wait_for_audio_completion(conn)
|
||||
# 清除服务端讲话状态
|
||||
conn.clearSpeakStatus()
|
||||
|
||||
|
||||