Merge branch 'main' into py_tts_huoshan

This commit is contained in:
CGD
2025-12-26 14:46:51 +08:00
committed by GitHub
134 changed files with 5839 additions and 1176 deletions
+7 -7
View File
@@ -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 | - | | **最简化安装** | 智能对话、单智能体管理 | 低配置环境,数据存储在配置文件,无需数据库 | [①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_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) 常见问题及相关教程,可参考[这个链接](./docs/FAQ.md)
@@ -211,10 +211,10 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
| 模块名称 | 入门全免费设置 | 流式配置 | | 模块名称 | 入门全免费设置 | 流式配置 |
|:---:|:---:|:---:| |:---:|:---:|:---:|
| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) | | ASR(语音识别) | FunASR(本地) | 👍XunfeiStreamASR(讯飞流式) |
| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) 或 👍DoubaoLLM(doubao-1-5-pro-32k-250115) | | LLM(大模型) | glm-4-flash(智谱) | 👍qwen-flash(阿里百炼) |
| VLLM(视觉大模型) | ChatGLMVLLM(智谱glm-4v-flash) | 👍QwenVLVLLM(千问qwen2.5-vl-3b-instructh) | | VLLM(视觉大模型) | glm-4v-flash(智谱) | 👍qwen2.5-vl-3b-instructh(阿里百炼) |
| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山流式语音合成) 或 👍AliyunStreamTTS(阿里云流式语音合成) | | TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山流式) |
| Intent(意图识别) | function_call(函数调用) | function_call(函数调用) | | Intent(意图识别) | function_call(函数调用) | function_call(函数调用) |
| Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) | | 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)
--- ---
+7 -7
View File
@@ -181,8 +181,8 @@ Dieses Projekt bietet zwei Bereitstellungsmethoden. Bitte wählen Sie basierend
#### 🚀 Auswahl der Bereitstellungsmethode #### 🚀 Auswahl der Bereitstellungsmethode
| Bereitstellungsmethode | Funktionen | Anwendungsszenarien | Deployment-Dokumente | Konfigurationsanforderungen | Video-Tutorials | | 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 | - | | **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, 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) | | **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) 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 | | Modulname | Einstiegslevel Kostenlose Einstellungen | Streaming-Konfiguration |
|:---:|:---:|:---:| |:---:|:---:|:---:|
| ASR (Spracherkennung) | FunASR (Lokal) | 👍FunASR (Lokaler GPU-Modus) | | ASR (Spracherkennung) | FunASR (Lokal) | 👍XunfeiStreamASR (Xunfei-Streaming) |
| LLM (Großes Modell) | ChatGLMLLM (Zhipu glm-4-flash) | 👍AliLLM (qwen3-235b-a22b-instruct-2507) oder 👍DoubaoLLM (doubao-1-5-pro-32k-250115) | | LLM (Großes Modell) | glm-4-flash (Zhipu) | 👍qwen-flash (Alibaba Bailian) |
| VLLM (Vision Large Model) | ChatGLMVLLM (Zhipu glm-4v-flash) | 👍QwenVLVLLM (Qwen qwen2.5-vl-3b-instructh) | | VLLM (Vision Large Model) | glm-4v-flash (Zhipu) | 👍qwen2.5-vl-3b-instructh (Alibaba Bailian) |
| TTS (Sprachsynthese) | ✅LinkeraiTTS (Lingxi-Streaming) | 👍HuoshanDoubleStreamTTS (Volcano Dual-Stream-Sprachsynthese) oder 👍AliyunStreamTTS (Alibaba Cloud Streaming-Sprachsynthese) | | TTS (Sprachsynthese) | ✅LinkeraiTTS (Lingxi-Streaming) | 👍HuoshanDoubleStreamTTS (Volcano-Streaming) |
| Intent (Absichtserkennung) | function_call (Funktionsaufruf) | function_call (Funktionsaufruf) | | Intent (Absichtserkennung) | function_call (Funktionsaufruf) | function_call (Funktionsaufruf) |
| Memory (Gedächtnisfunktion) | mem_local_short (Lokales Kurzzeitgedächtnis) | mem_local_short (Lokales Kurzzeitgedächtnis) | | 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 👬 ## 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
--- ---
+7 -7
View File
@@ -181,8 +181,8 @@ This project provides two deployment methods. Please choose based on your specif
#### 🚀 Deployment Method Selection #### 🚀 Deployment Method Selection
| Deployment Method | Features | Applicable Scenarios | Deployment Docs | Configuration Requirements | Video Tutorials | | 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 | - | | **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, 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) | | **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. > 💡 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 | | Module Name | Entry Level Free Settings | Streaming Configuration |
|:---:|:---:|:---:| |:---:|:---:|:---:|
| ASR(Speech Recognition) | FunASR(Local) | 👍FunASRServer or 👍DoubaoStreamASR | | ASR(Speech Recognition) | FunASR(Local) | 👍XunfeiStreamASR(Xunfei Streaming) |
| LLM(Large Model) | ChatGLMLLM(Zhipu glm-4-flash) | 👍DoubaoLLM(Volcano doubao-1-5-pro-32k-250115) | | LLM(Large Model) | glm-4-flash(Zhipu) | 👍qwen-flash(Alibaba Bailian) |
| VLLM(Vision Large Model) | ChatGLMVLLM(Zhipu glm-4v-flash) | 👍QwenVLVLLM(Qwen qwen2.5-vl-3b-instructh) | | VLLM(Vision Large Model) | glm-4v-flash(Zhipu) | 👍qwen2.5-vl-3b-instructh(Alibaba Bailian) |
| TTS(Speech Synthesis) | ✅LinkeraiTTS(Lingxi streaming) | 👍HuoshanDoubleStreamTTS(Volcano dual-stream speech synthesis) | | TTS(Speech Synthesis) | ✅LinkeraiTTS(Lingxi streaming) | 👍HuoshanDoubleStreamTTS(Volcano Streaming) |
| Intent(Intent Recognition) | function_call(Function calling) | function_call(Function calling) | | 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) | | 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 👬 ## 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 | | Project Name | Project Address | Project Description |
|:---------------------|:--------|:--------| |:---------------------|:--------|:--------|
+7 -7
View File
@@ -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 #### 🚀 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 | | 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 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, IOT, điểm truy cập MCP, nhận dng 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 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) 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 | | 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ộ) | | ASR(Nhận dạng giọng nói) | FunASR(Local) | 👍XunfeiStreamASR(Xunfei Streaming) |
| 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) | | LLM(Mô hình lớn) | glm-4-flash(Zhipu) | 👍qwen-flash(Alibaba Bailian) |
| VLLM(Mô hình lớn thị giác) | ChatGLMVLLM(Zhipu glm-4v-flash) | 👍QwenVLVLLM(Qwen qwen2.5-vl-3b-instructh) | | 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(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) | | 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) | | 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ộ) | | 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 👬 ## 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
--- ---
+6 -4
View File
@@ -38,10 +38,10 @@ conda install conda-forge::ffmpeg
| 模块名称 | 入门全免费设置 | 流式配置 | | 模块名称 | 入门全免费设置 | 流式配置 |
|:---:|:---:|:---:| |:---:|:---:|:---:|
| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) | | ASR(语音识别) | FunASR(本地) | 👍XunfeiStreamASR(讯飞流式) |
| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) 或 👍DoubaoLLM(doubao-1-5-pro-32k-250115) | | LLM(大模型) | glm-4-flash(智谱) | 👍qwen-flash(阿里百炼) |
| VLLM(视觉大模型) | ChatGLMVLLM(智谱glm-4v-flash) | 👍QwenVLVLLM(千问qwen2.5-vl-3b-instructh) | | VLLM(视觉大模型) | glm-4v-flash(智谱) | 👍qwen2.5-vl-3b-instructh(阿里百炼) |
| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山流式语音合成) 或 👍AliyunStreamTTS(阿里云流式语音合成) | | TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山流式) |
| Intent(意图识别) | function_call(函数调用) | function_call(函数调用) | | Intent(意图识别) | function_call(函数调用) | function_call(函数调用) |
| Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) | | Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) |
@@ -69,6 +69,7 @@ VAD:
### 9、编译固件相关教程 ### 9、编译固件相关教程
1、[如何自己编译小智固件](./firmware-build.md)<br/> 1、[如何自己编译小智固件](./firmware-build.md)<br/>
2、[如何基于虾哥编译好的固件修改OTA地址](./firmware-setting.md)<br/> 2、[如何基于虾哥编译好的固件修改OTA地址](./firmware-setting.md)<br/>
3、[单模块部署如何配置固件OTA自动升级](./ota-upgrade-guide.md)<br/>
### 10、拓展相关教程 ### 10、拓展相关教程
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/> 1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/>
@@ -80,6 +81,7 @@ VAD:
7、[如何开启声纹识别](./voiceprint-integration.md)<br/> 7、[如何开启声纹识别](./voiceprint-integration.md)<br/>
8、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/> 8、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
9、[知识库ragflow集成指南](./ragflow-integration.md)<br/> 9、[知识库ragflow集成指南](./ragflow-integration.md)<br/>
10、[如何部署上下文源](./context-provider-integration.md)<br/>
### 11、语音克隆、本地语音部署相关教程 ### 11、语音克隆、本地语音部署相关教程
1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/> 1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/>
+224
View File
@@ -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服务已停止")
```
+2
View File
@@ -23,6 +23,8 @@
### 2.将音色资源ID分配给系统账号 ### 2.将音色资源ID分配给系统账号
使用超级管理员账号登录智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`音色克隆`,点击保存配置。即可在顶部菜单看到`音色克隆`按钮。
使用超级管理员账号登录智控台,点击顶部【音色克隆】、【音色资源】。 使用超级管理员账号登录智控台,点击顶部【音色克隆】、【音色资源】。
点击新增按钮,在【平台名称】选择“火山双流式语音合成”; 点击新增按钮,在【平台名称】选择“火山双流式语音合成”;
+1
View File
@@ -71,6 +71,7 @@ docker logs -f mcp-endpoint-server
请你保留好上面两个`接口地址`,下一步要用到。 请你保留好上面两个`接口地址`,下一步要用到。
# 2、全模块部署时,怎么配置MCP接入点 # 2、全模块部署时,怎么配置MCP接入点
首先,你要开启MCP接入点功能。在智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`MCP接入点`,点击`保存配置`。在`角色配置`页面,点击`编辑功能`按钮,即可看到`mcp接入点`功能。
如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。
+8
View File
@@ -76,6 +76,7 @@ MQTT_PORT=1883 # MQTT服务器端口
UDP_PORT=8884 # UDP服务器端口 UDP_PORT=8884 # UDP服务器端口
API_PORT=8007 # 管理API端口 API_PORT=8007 # 管理API端口
MQTT_SIGNATURE_KEY=test # MQTT签名密钥 MQTT_SIGNATURE_KEY=test # MQTT签名密钥
SERVER_SECRET=Te1st12134 # 服务器密钥,请保持和智控台(server.secret)一致或者和xiaozhi-server里(server.auth_key)保持一致
``` ```
请注意`PUBLIC_IP`配置,确保其与实际公网IP一致,如果有域名就填域名。 请注意`PUBLIC_IP`配置,确保其与实际公网IP一致,如果有域名就填域名。
@@ -85,6 +86,13 @@ MQTT_SIGNATURE_KEY=test # MQTT签名密钥
- 注意不要用简单的密码,比如`123456`、`test`等。 - 注意不要用简单的密码,比如`123456`、`test`等。
- 注意不要用简单的密码,比如`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网关 6. 启动MQTT网关
``` ```
# 启动服务 # 启动服务
+142
View File
@@ -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`配置为更短的时间
+12 -2
View File
@@ -156,6 +156,14 @@ services:
编辑`ragflow/docker`文件夹下的`.env`文件,找到以下配置,逐个搜索,逐个修改!逐个搜索,逐个修改! 编辑`ragflow/docker`文件夹下的`.env`文件,找到以下配置,逐个搜索,逐个修改!逐个搜索,逐个修改!
下面对于`.env`文件的修改,60%的人会忽略`MYSQL_USER`配置导致ragflow启动不成功,因此,需要强调三次:
强调第一次:如果你的`.env`文件如果没有`MYSQL_USER`配置,请在配置文件增加这项!
强调第二次:如果你的`.env`文件如果没有`MYSQL_USER`配置,请在配置文件增加这项!
强调第三次:如果你的`.env`文件如果没有`MYSQL_USER`配置,请在配置文件增加这项!
``` env ``` env
# 端口设置 # 端口设置
SVR_WEB_HTTP_PORT=8008 # HTTP端口 SVR_WEB_HTTP_PORT=8008 # HTTP端口
@@ -230,9 +238,11 @@ docker-compose -f docker-compose.yml up -d
在弹框中,点击"Create new Key"按钮,生成一个API Key。复制这个`API Key`,你稍后会用到。 在弹框中,点击"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`。 在`服务地址`中,填写`http://你的ragflow服务的局域网IP:8008`,例如我的ragflow服务的局域网IP是`192.168.1.100`,那么我就填写`http://192.168.1.100:8008`。
+2
View File
@@ -164,6 +164,8 @@ http://192.168.1.25:8005/voiceprint/health?key=abcd
# 2、全模块部署时,怎么配置声纹识别 # 2、全模块部署时,怎么配置声纹识别
## 第一步 配置接口 ## 第一步 配置接口
首先,你要开启声纹识别功能。在智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`声纹识别`,点击`保存配置`。即可在新建智能体的卡片上看到`声纹识别`按钮。
如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。
然后搜索参数`server.voice_print`,此时,它的值应该是`null`值。 然后搜索参数`server.voice_print`,此时,它的值应该是`null`值。
@@ -141,6 +141,11 @@ public interface Constant {
*/ */
String SERVER_MQTT_SECRET = "server.mqtt_signature_key"; 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.8"; public static final String VERSION = "0.8.10";
/** /**
* 无效固件URL * 无效固件URL
@@ -159,4 +159,11 @@ public class RedisKeys {
public static String getKnowledgeBaseCacheKey(String datasetId) { public static String getKnowledgeBaseCacheKey(String datasetId) {
return "knowledge:base:" + 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.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentChatAudioService; import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService; 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.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.agent.service.AgentTemplateService;
@@ -64,6 +66,8 @@ public class AgentController {
private final AgentChatHistoryService agentChatHistoryService; private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService; private final AgentChatAudioService agentChatAudioService;
private final AgentPluginMappingService agentPluginMappingService; private final AgentPluginMappingService agentPluginMappingService;
private final AgentContextProviderService agentContextProviderService;
private final AgentChatSummaryService agentChatSummaryService;
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
@GetMapping("/list") @GetMapping("/list")
@@ -117,6 +121,27 @@ public class AgentController {
return new Result<>(); 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}") @PutMapping("/{id}")
@Operation(summary = "更新智能体") @Operation(summary = "更新智能体")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
@@ -135,6 +160,8 @@ public class AgentController {
agentChatHistoryService.deleteByAgentId(id, true, true); agentChatHistoryService.deleteByAgentId(id, true, true);
// 删除关联的插件 // 删除关联的插件
agentPluginMappingService.deleteByAgentId(id); agentPluginMappingService.deleteByAgentId(id);
// 删除关联的上下文源配置
agentContextProviderService.deleteByAgentId(id);
// 再删除智能体 // 再删除智能体
agentService.deleteById(id); agentService.deleteById(id);
return new Result<>(); return new Result<>();
@@ -182,6 +209,7 @@ public class AgentController {
List<AgentChatHistoryDTO> result = agentChatHistoryService.getChatHistoryBySessionId(id, sessionId); List<AgentChatHistoryDTO> result = agentChatHistoryService.getChatHistoryBySessionId(id, sessionId);
return new Result<List<AgentChatHistoryDTO>>().ok(result); return new Result<List<AgentChatHistoryDTO>>().ok(result);
} }
@GetMapping("/{id}/chat-history/user") @GetMapping("/{id}/chat-history/user")
@Operation(summary = "获取智能体聊天记录(用户)") @Operation(summary = "获取智能体聊天记录(用户)")
@RequiresPermissions("sys:role:normal") @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; package xiaozhi.modules.agent.dao;
import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
@@ -15,12 +18,6 @@ import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
*/ */
@Mapper @Mapper
public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity> { public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity> {
/**
* 根据智能体ID删除音频
*
* @param agentId 智能体ID
*/
void deleteAudioByAgentId(String agentId);
/** /**
* 根据智能体ID删除聊天历史记录 * 根据智能体ID删除聊天历史记录
@@ -35,4 +32,19 @@ public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity
* @param agentId 智能体ID * @param agentId 智能体ID
*/ */
void deleteAudioIdByAgentId(String agentId); 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) @Schema(description = "排序", example = "1", nullable = true)
private Integer sort; private Integer sort;
@Schema(description = "上下文源配置", nullable = true)
private List<ContextProviderDTO> contextProviders;
@Data @Data
@Schema(description = "插件函数信息") @Schema(description = "插件函数信息")
public static class FunctionInfo implements Serializable { 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.entity.AgentEntity;
import xiaozhi.modules.agent.service.AgentChatAudioService; import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService; import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentChatSummaryService;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService; import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
import xiaozhi.modules.device.entity.DeviceEntity; import xiaozhi.modules.device.entity.DeviceEntity;
@@ -36,6 +37,7 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
private final AgentService agentService; private final AgentService agentService;
private final AgentChatHistoryService agentChatHistoryService; private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService; private final AgentChatAudioService agentChatAudioService;
private final AgentChatSummaryService agentChatSummaryService;
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
private final DeviceService deviceService; private final DeviceService deviceService;
@@ -50,7 +52,8 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
public Boolean report(AgentChatHistoryReportDTO report) { public Boolean report(AgentChatHistoryReportDTO report) {
String macAddress = report.getMacAddress(); String macAddress = report.getMacAddress();
Byte chatType = report.getChatType(); 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); log.info("小智设备聊天上报请求: macAddress={}, type={} reportTime={}", macAddress, chatType, reportTimeMillis);
// 根据设备MAC地址查询对应的默认智能体,判断是否需要上报 // 根据设备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() AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
.macAddress(macAddress) .macAddress(macAddress)
@@ -84,7 +84,16 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) { public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
if (deleteAudio) { 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) { if (deleteAudio && !deleteText) {
baseMapper.deleteAudioIdByAgentId(agentId); baseMapper.deleteAudioIdByAgentId(agentId);
@@ -107,7 +116,7 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
// 添加此行,确保查询结果按照创建时间降序排列 // 添加此行,确保查询结果按照创建时间降序排列
// 使用id的原因:数据形式,id越大的创建时间就越晚,所以使用id的结果和创建时间降序排列结果一样 // 使用id的原因:数据形式,id越大的创建时间就越晚,所以使用id的结果和创建时间降序排列结果一样
// id作为降序排列的优势,性能高,有主键索引,不用在排序的时候重新进行排除扫描比较 // id作为降序排列的优势,性能高,有主键索引,不用在排序的时候重新进行排除扫描比较
.orderByDesc(AgentChatHistoryEntity::getId); .orderByDesc(AgentChatHistoryEntity::getId);
// 构建分页查询,查询前50页数据 // 构建分页查询,查询前50页数据
Page<AgentChatHistoryEntity> pageParam = new Page<>(0, 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.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO; import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentPluginMapping;
import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentChatHistoryService; import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.agent.service.AgentTemplateService;
@@ -62,6 +64,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
private final AgentChatHistoryService agentChatHistoryService; private final AgentChatHistoryService agentChatHistoryService;
private final AgentTemplateService agentTemplateService; private final AgentTemplateService agentTemplateService;
private final ModelProviderService modelProviderService; private final ModelProviderService modelProviderService;
private final AgentContextProviderService agentContextProviderService;
@Override @Override
public PageData<AgentEntity> adminAgentList(Map<String, Object> params) { 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()); agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode());
} }
} }
// 查询上下文源配置
AgentContextProviderEntity contextProviderEntity = agentContextProviderService.getByAgentId(id);
if (contextProviderEntity != null) {
agent.setContextProviders(contextProviderEntity.getContextProviders());
}
// 无需额外查询插件列表,已通过SQL查询出来 // 无需额外查询插件列表,已通过SQL查询出来
return agent; return agent;
} }
@@ -331,6 +341,14 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, false); 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()); boolean b = validateLLMIntentParams(dto.getLlmModelId(), dto.getIntentModelId());
if (!b) { if (!b) {
throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH); 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import xiaozhi.modules.agent.dto.ContextProviderDTO;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentPluginMapping;
@@ -21,4 +22,7 @@ public class AgentInfoVO extends AgentEntity
@Schema(description = "插件列表Id") @Schema(description = "插件列表Id")
@TableField(typeHandler = JacksonTypeHandler.class) @TableField(typeHandler = JacksonTypeHandler.class)
private List<AgentPluginMapping> functions; 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.ConvertUtils;
import xiaozhi.common.utils.JsonUtils; import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.dao.AgentVoicePrintDao; import xiaozhi.modules.agent.dao.AgentVoicePrintDao;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentPluginMapping;
import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.entity.AgentVoicePrintEntity; import xiaozhi.modules.agent.entity.AgentVoicePrintEntity;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentMcpAccessPointService; import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
@@ -53,6 +55,7 @@ public class ConfigServiceImpl implements ConfigService {
private final TimbreService timbreService; private final TimbreService timbreService;
private final AgentPluginMappingService agentPluginMappingService; private final AgentPluginMappingService agentPluginMappingService;
private final AgentMcpAccessPointService agentMcpAccessPointService; private final AgentMcpAccessPointService agentMcpAccessPointService;
private final AgentContextProviderService agentContextProviderService;
private final VoiceCloneService cloneVoiceService; private final VoiceCloneService cloneVoiceService;
private final AgentVoicePrintDao agentVoicePrintDao; private final AgentVoicePrintDao agentVoicePrintDao;
@@ -103,6 +106,15 @@ public class ConfigServiceImpl implements ConfigService {
@Override @Override
public Map<String, Object> getAgentModels(String macAddress, Map<String, String> selectedModule) { 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地址查找设备 // 根据MAC地址查找设备
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress); DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device == null) { if (device == null) {
@@ -178,6 +190,13 @@ public class ConfigServiceImpl implements ConfigService {
mcpEndpoint = mcpEndpoint.replace("/mcp/", "/call/"); mcpEndpoint = mcpEndpoint.replace("/mcp/", "/call/");
result.put("mcp_endpoint", mcpEndpoint); 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); buildVoiceprintConfig(agent.getId(), result);
@@ -72,10 +72,12 @@ public class DeviceController {
return new Result<String>().error(ErrorCode.MCA_NOT_NULL); return new Result<String>().error(ErrorCode.MCA_NOT_NULL);
} }
// 生成六位验证码 // 生成六位验证码
String code = String.valueOf(Math.random()).substring(2, 8); String code;
String key = RedisKeys.getDeviceCaptchaKey(code); String key;
String existsMac = null; String existsMac = null;
do { do {
code = String.valueOf(Math.random()).substring(2, 8);
key = RedisKeys.getDeviceCaptchaKey(code);
existsMac = (String) redisUtils.get(key); existsMac = (String) redisUtils.get(key);
} while (StringUtils.isNotBlank(existsMac)); } while (StringUtils.isNotBlank(existsMac));
@@ -98,4 +98,14 @@ public interface DeviceService extends BaseService<DeviceEntity> {
*/ */
void updateDeviceConnectionInfo(String agentId, String deviceId, String appVersion); 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; package xiaozhi.modules.device.service.impl;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant; import java.time.Instant;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
@@ -169,7 +171,22 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket(); DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
// 从系统参数获取WebSocket URL,如果未配置则使用默认值 // 从系统参数获取WebSocket URL,如果未配置则使用默认值
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true); 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")) { if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置"); log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/"; wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
@@ -189,7 +206,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
// 添加MQTT UDP配置 // 添加MQTT UDP配置
// 从系统参数获取MQTT Gateway地址,仅在配置有效时使用 // 从系统参数获取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()) { if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
try { try {
String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard() 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); 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配置信息 * 构建MQTT配置信息
* *
@@ -504,7 +555,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
private DeviceReportRespDTO.MQTT buildMqttConfig(String macAddress, String groupId) private DeviceReportRespDTO.MQTT buildMqttConfig(String macAddress, String groupId)
throws Exception { throws Exception {
// 从环境变量或系统参数获取签名密钥 // 从环境变量或系统参数获取签名密钥
String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", false); String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", true);
if (StringUtils.isBlank(signatureKey)) { if (StringUtils.isBlank(signatureKey)) {
log.warn("缺少MQTT_SIGNATURE_KEY,跳过MQTT配置生成"); log.warn("缺少MQTT_SIGNATURE_KEY,跳过MQTT配置生成");
return null; 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) * @return TTS平台列表(id和modelName)
*/ */
List<Map<String, Object>> getTtsPlatformList(); 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() { public List<Map<String, Object>> getTtsPlatformList() {
return modelConfigDao.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("/config/**", "server");
filterMap.put("/agent/chat-history/report", "server"); filterMap.put("/agent/chat-history/report", "server");
filterMap.put("/agent/chat-history/download/**", "anon"); 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("/agent/play/**", "anon");
filterMap.put("/voiceClone/play/**", "anon"); filterMap.put("/voiceClone/play/**", "anon");
filterMap.put("/**", "oauth2"); filterMap.put("/**", "oauth2");
@@ -6,6 +6,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
@@ -23,7 +24,9 @@ import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.TokenDTO; import xiaozhi.common.page.TokenDTO;
import xiaozhi.common.user.UserDetail; import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
import xiaozhi.common.utils.Sm2DecryptUtil;
import xiaozhi.common.validator.AssertUtils; import xiaozhi.common.validator.AssertUtils;
import xiaozhi.common.validator.ValidatorUtils; import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.security.dto.LoginDTO; 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.CaptchaService;
import xiaozhi.modules.security.service.SysUserTokenService; import xiaozhi.modules.security.service.SysUserTokenService;
import xiaozhi.modules.security.user.SecurityUser; 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.PasswordDTO;
import xiaozhi.modules.sys.dto.RetrievePasswordDTO; import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO; import xiaozhi.modules.sys.dto.SysUserDTO;
@@ -89,13 +90,13 @@ public class LoginController {
@Operation(summary = "登录") @Operation(summary = "登录")
public Result<TokenDTO> login(@RequestBody LoginDTO login) { public Result<TokenDTO> login(@RequestBody LoginDTO login) {
String password = login.getPassword(); String password = login.getPassword();
// 使用工具类解密并验证验证码 // 使用工具类解密并验证验证码
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
password, login.getCaptchaId(), captchaService, sysParamsService); password, login.getCaptchaId(), captchaService, sysParamsService);
login.setPassword(actualPassword); login.setPassword(actualPassword);
// 按照用户名获取用户 // 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername()); SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
// 判断用户是否存在 // 判断用户是否存在
@@ -108,8 +109,6 @@ public class LoginController {
} }
return sysUserTokenService.createToken(userDTO.getId()); return sysUserTokenService.createToken(userDTO.getId());
} }
@PostMapping("/register") @PostMapping("/register")
@Operation(summary = "注册") @Operation(summary = "注册")
@@ -117,15 +116,15 @@ public class LoginController {
if (!sysUserService.getAllowUserRegister()) { if (!sysUserService.getAllowUserRegister()) {
throw new RenException(ErrorCode.USER_REGISTER_DISABLED); throw new RenException(ErrorCode.USER_REGISTER_DISABLED);
} }
String password = login.getPassword(); String password = login.getPassword();
// 使用工具类解密并验证验证码 // 使用工具类解密并验证验证码
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
password, login.getCaptchaId(), captchaService, sysParamsService); password, login.getCaptchaId(), captchaService, sysParamsService);
login.setPassword(actualPassword); login.setPassword(actualPassword);
// 是否开启手机注册 // 是否开启手机注册
Boolean isMobileRegister = sysParamsService Boolean isMobileRegister = sysParamsService
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class); .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
@@ -204,11 +203,11 @@ public class LoginController {
} }
String password = dto.getPassword(); String password = dto.getPassword();
// 使用工具类解密并验证验证码 // 使用工具类解密并验证验证码
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
password, dto.getCaptchaId(), captchaService, sysParamsService); password, dto.getCaptchaId(), captchaService, sysParamsService);
dto.setPassword(actualPassword); dto.setPassword(actualPassword);
sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword()); 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("beianIcpNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_ICP_NUM.getValue(), true));
config.put("beianGaNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_GA_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)); config.put("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true));
// SM2公钥 // SM2公钥
String publicKey = sysParamsService.getValue(Constant.SM2_PUBLIC_KEY, true); String publicKey = sysParamsService.getValue(Constant.SM2_PUBLIC_KEY, true);
if (StringUtils.isBlank(publicKey)) { if (StringUtils.isBlank(publicKey)) {
@@ -237,6 +236,12 @@ public class LoginController {
} }
config.put("sm2PublicKey", publicKey); 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); 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.enums.ServerActionEnum;
import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.utils.WebSocketClientManager; 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 @AllArgsConstructor
public class ServerSideManageController { public class ServerSideManageController {
private final SysParamsService sysParamsService; private final SysParamsService sysParamsService;
private final DeviceService deviceService;
private final RedisUtils redisUtils;
private static final ObjectMapper objectMapper; private static final ObjectMapper objectMapper;
static { static {
objectMapper = new ObjectMapper(); objectMapper = new ObjectMapper();
@@ -85,9 +89,22 @@ public class ServerSideManageController {
return false; return false;
} }
String serverSK = sysParamsService.getValue(Constant.SERVER_SECRET, true); 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(); WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
headers.add("device-id", UUID.randomUUID().toString()); headers.add("device-id", deviceId);
headers.add("client-id", UUID.randomUUID().toString()); 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() try (WebSocketClientManager client = new WebSocketClientManager.Builder()
.connectTimeout(3, TimeUnit.SECONDS) .connectTimeout(3, TimeUnit.SECONDS)
@@ -174,7 +174,7 @@ public class SysParamsController {
return; return;
} }
if (StringUtils.isBlank(url) || url.equals("null")) { if (StringUtils.isBlank(url) || url.equals("null")) {
throw new RenException(ErrorCode.OTA_URL_EMPTY); return;
} }
// 检查是否包含localhost或127.0.0.1 // 检查是否包含localhost或127.0.0.1
@@ -211,7 +211,7 @@ public class SysParamsController {
return; return;
} }
if (StringUtils.isBlank(url) || url.equals("null")) { if (StringUtils.isBlank(url) || url.equals("null")) {
throw new RenException(ErrorCode.MCP_URL_EMPTY); return;
} }
if (url.contains("localhost") || url.contains("127.0.0.1")) { if (url.contains("localhost") || url.contains("127.0.0.1")) {
throw new RenException(ErrorCode.MCP_URL_LOCALHOST); throw new RenException(ErrorCode.MCP_URL_LOCALHOST);
@@ -242,7 +242,7 @@ public class SysParamsController {
return; return;
} }
if (StringUtils.isBlank(url) || url.equals("null")) { if (StringUtils.isBlank(url) || url.equals("null")) {
throw new RenException(ErrorCode.VOICEPRINT_URL_EMPTY); return;
} }
if (url.contains("localhost") || url.contains("127.0.0.1")) { if (url.contains("localhost") || url.contains("127.0.0.1")) {
throw new RenException(ErrorCode.VOICEPRINT_URL_LOCALHOST); throw new RenException(ErrorCode.VOICEPRINT_URL_LOCALHOST);
@@ -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;
@@ -430,3 +430,46 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202511221450.sql 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 created_at, updated_at
</sql> </sql>
<delete id="deleteAudioByAgentId"> <select id="getAudioIdsByAgentId" resultType="java.lang.String">
DELETE FROM ai_agent_chat_audio SELECT DISTINCT audio_id
WHERE id IN ( FROM ai_agent_chat_history
SELECT audio_id WHERE agent_id = #{agentId} AND audio_id IS NOT NULL
FROM ai_agent_chat_history </select>
WHERE agent_id = #{agentId}
) <delete id="deleteAudioByIds">
DELETE FROM ai_agent_chat_audio
WHERE id IN
<foreach collection="audioIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete> </delete>
<update id="deleteAudioIdByAgentId"> <update id="deleteAudioIdByAgentId">
@@ -235,7 +235,7 @@ function showAbout() {
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }), title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
content: t('settings.aboutContent', { content: t('settings.aboutContent', {
appName: import.meta.env.VITE_APP_TITLE, appName: import.meta.env.VITE_APP_TITLE,
version: '0.8.8' version: '0.8.10'
}), }),
showCancel: false, showCancel: false,
confirmText: t('common.confirm'), confirmText: t('common.confirm'),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -404,7 +404,7 @@ export default {
.custom-input-bg .el-input__inner, .custom-input-bg .el-input__inner,
.custom-input-bg .el-textarea__inner { .custom-input-bg .el-textarea__inner {
background-color: #f6f8fc; background-color: #ffffff;
} }
@@ -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>
+10 -2
View File
@@ -23,7 +23,7 @@
<div class="settings-btn" @click="handleConfigure"> <div class="settings-btn" @click="handleConfigure">
{{ $t('home.configureRole') }} {{ $t('home.configureRole') }}
</div> </div>
<div class="settings-btn" @click="handleVoicePrint"> <div v-if="featureStatus.voiceprintRecognition" class="settings-btn" @click="handleVoicePrint">
{{ $t('home.voiceprintRecognition') }} {{ $t('home.voiceprintRecognition') }}
</div> </div>
<div class="settings-btn" @click="handleDeviceManage"> <div class="settings-btn" @click="handleDeviceManage">
@@ -49,7 +49,15 @@ import i18n from '@/i18n';
export default { export default {
name: 'DeviceItem', name: 'DeviceItem',
props: { props: {
device: { type: Object, required: true } device: { type: Object, required: true },
featureStatus: {
type: Object,
default: () => ({
voiceprintRecognition: false,
voiceClone: false,
knowledgeBase: false
})
}
}, },
data() { data() {
return { switchValue: false } return { switchValue: false }
@@ -106,7 +106,7 @@
</div> </div>
<!-- MCP区域 --> <!-- MCP区域 -->
<div class="mcp-access-point"> <div class="mcp-access-point" v-if="featureStatus.mcpAccessPoint">
<div class="mcp-container"> <div class="mcp-container">
<!-- 左侧区域 --> <!-- 左侧区域 -->
<div class="mcp-left"> <div class="mcp-left">
@@ -171,6 +171,7 @@
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
import i18n from '@/i18n'; import i18n from '@/i18n';
import featureManager from '@/utils/featureManager';
export default { export default {
i18n, i18n,
@@ -205,6 +206,11 @@ export default {
mcpUrl: "", mcpUrl: "",
mcpStatus: "disconnected", mcpStatus: "disconnected",
mcpTools: [], mcpTools: [],
// 功能状态
featureStatus: {
mcpAccessPoint: false
}
} }
}, },
computed: { computed: {
@@ -249,6 +255,9 @@ export default {
// 右侧默认指向第一个 // 右侧默认指向第一个
this.currentFunction = this.selectedList[0] || null; this.currentFunction = this.selectedList[0] || null;
// 加载功能状态
this.loadFeatureStatus();
// 加载MCP数据 // 加载MCP数据
this.loadMcpAddress(); this.loadMcpAddress();
this.loadMcpTools(); this.loadMcpTools();
@@ -259,6 +268,19 @@ export default {
} }
}, },
methods: { methods: {
/**
* 加载功能状态
*/
async loadFeatureStatus() {
// 确保featureManager已初始化完成
await featureManager.waitForInitialization();
const config = featureManager.getConfig();
this.featureStatus = {
mcpAccessPoint: config.mcpAccessPoint || false
};
},
copyUrl() { copyUrl() {
const textarea = document.createElement('textarea'); const textarea = document.createElement('textarea');
textarea.value = this.mcpUrl; textarea.value = this.mcpUrl;
@@ -456,6 +478,7 @@ export default {
.function-column { .function-column {
position: relative; position: relative;
width: auto; width: auto;
height:700px;
padding: 10px; padding: 10px;
overflow-y: auto; overflow-y: auto;
border-right: 1px solid #EBEEF5; border-right: 1px solid #EBEEF5;
@@ -463,6 +486,12 @@ export default {
overflow-x: hidden; overflow-x: hidden;
} }
.mcp-access-point {
position: relative;
z-index: 1;
background: white;
}
.function-column::-webkit-scrollbar { .function-column::-webkit-scrollbar {
display: none; display: none;
} }
+52 -7
View File
@@ -4,7 +4,7 @@
<!-- 左侧元素 --> <!-- 左侧元素 -->
<div class="header-left" @click="goHome"> <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-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> </div>
<!-- 中间导航菜单 --> <!-- 中间导航菜单 -->
@@ -26,7 +26,7 @@
<span class="nav-text">{{ $t("header.smartManagement") }}</span> <span class="nav-text">{{ $t("header.smartManagement") }}</span>
</div> </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"> :class="{ 'active-tab': $route.path === '/voice-clone-management' }" @click="goVoiceCloneManagement">
<img loading="lazy" alt="" src="@/assets/header/voice.png" :style="{ <img loading="lazy" alt="" src="@/assets/header/voice.png" :style="{
filter: filter:
@@ -38,7 +38,7 @@
</div> </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': 'active-tab':
$route.path === '/voice-clone-management' || $route.path === '/voice-clone-management' ||
$route.path === '/voice-resource-management', $route.path === '/voice-resource-management',
@@ -72,7 +72,7 @@
}" /> }" />
<span class="nav-text">{{ $t("header.modelConfig") }}</span> <span class="nav-text">{{ $t("header.modelConfig") }}</span>
</div> </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' }" :class="{ 'active-tab': $route.path === '/knowledge-base-management' || $route.path === '/knowledge-file-upload' }"
@click="goKnowledgeBaseManagement"> @click="goKnowledgeBaseManagement">
<img loading="lazy" alt="" src="@/assets/header/knowledge_base.png" :style="{ <img loading="lazy" alt="" src="@/assets/header/knowledge_base.png" :style="{
@@ -89,7 +89,8 @@
$route.path === '/server-side-management' || $route.path === '/server-side-management' ||
$route.path === '/agent-template-management' || $route.path === '/agent-template-management' ||
$route.path === '/ota-management' || $route.path === '/ota-management' ||
$route.path === '/user-management', $route.path === '/user-management' ||
$route.path === '/feature-management',
}" @visible-change="handleParamDropdownVisibleChange"> }" @visible-change="handleParamDropdownVisibleChange">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
<img loading="lazy" alt="" src="@/assets/header/param_management.png" :style="{ <img loading="lazy" alt="" src="@/assets/header/param_management.png" :style="{
@@ -100,7 +101,8 @@
$route.path === '/server-side-management' || $route.path === '/server-side-management' ||
$route.path === '/agent-template-management' || $route.path === '/agent-template-management' ||
$route.path === '/ota-management' || $route.path === '/ota-management' ||
$route.path === '/user-management' $route.path === '/user-management' ||
$route.path === '/feature-management'
? 'brightness(0) invert(1)' ? 'brightness(0) invert(1)'
: 'None', : 'None',
}" /> }" />
@@ -129,6 +131,9 @@
<el-dropdown-item @click.native="goServerSideManagement"> <el-dropdown-item @click.native="goServerSideManagement">
{{ $t("header.serverSideManagement") }} {{ $t("header.serverSideManagement") }}
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item @click.native="goFeatureManagement">
{{ $t("header.featureManagement") }}
</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</el-dropdown> </el-dropdown>
</div> </div>
@@ -186,6 +191,7 @@ import userApi from "@/apis/module/user";
import i18n, { changeLanguage } from "@/i18n"; import i18n, { changeLanguage } from "@/i18n";
import { mapActions, mapGetters } from "vuex"; import { mapActions, mapGetters } from "vuex";
import ChangePasswordDialog from "./ChangePasswordDialog.vue"; // 引入修改密码弹窗组件 import ChangePasswordDialog from "./ChangePasswordDialog.vue"; // 引入修改密码弹窗组件
import featureManager from "@/utils/featureManager"; // 引入功能管理工具类
export default { export default {
name: "HeaderBar", name: "HeaderBar",
@@ -217,6 +223,11 @@ export default {
label: "label", label: "label",
children: "children", children: "children",
}, },
// 功能状态
featureStatus: {
voiceClone: false, // 音色克隆功能状态
knowledgeBase: false, // 知识库功能状态
},
}; };
}, },
computed: { computed: {
@@ -246,6 +257,24 @@ export default {
return this.$t("language.zhCN"); 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() { userMenuOptions() {
return [ return [
@@ -286,12 +315,14 @@ export default {
]; ];
}, },
}, },
mounted() { async mounted() {
this.fetchUserInfo(); this.fetchUserInfo();
this.checkScreenSize(); this.checkScreenSize();
window.addEventListener("resize", this.checkScreenSize); window.addEventListener("resize", this.checkScreenSize);
// 从localStorage加载搜索历史 // 从localStorage加载搜索历史
this.loadSearchHistory(); this.loadSearchHistory();
// 等待featureManager初始化完成后再加载功能状态
await this.loadFeatureStatus();
}, },
//移除事件监听器 //移除事件监听器
beforeDestroy() { beforeDestroy() {
@@ -338,6 +369,20 @@ export default {
goAgentTemplateManagement() { goAgentTemplateManagement() {
this.$router.push("/agent-template-management"); 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() { fetchUserInfo() {
userApi.getUserInfo(({ data }) => { userApi.getUserInfo(({ data }) => {
+55 -3
View File
@@ -22,6 +22,7 @@ export default {
'header.clearHistory': 'Verlauf löschen', 'header.clearHistory': 'Verlauf löschen',
'header.providerManagement': 'Anbieterverwaltung', 'header.providerManagement': 'Anbieterverwaltung',
'header.serverSideManagement': 'Serververwaltung', 'header.serverSideManagement': 'Serververwaltung',
'header.featureManagement': 'Systemfunktionsverwaltung',
'header.changePassword': 'Passwort ändern', 'header.changePassword': 'Passwort ändern',
'header.logout': 'Abmelden', 'header.logout': 'Abmelden',
'header.searchPlaceholder': 'Nach Namen suchen..', 'header.searchPlaceholder': 'Nach Namen suchen..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': 'Bitte Namen eingeben', 'voicePrintDialog.requiredName': 'Bitte Namen eingeben',
'voicePrintDialog.requiredAudioVector': 'Bitte Audio-Vektor auswählen', '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 // Voice print page related
'voicePrint.pageTitle': 'Stimmabdruck-Erkennung', 'voicePrint.pageTitle': 'Stimmabdruck-Erkennung',
'voicePrint.name': 'Name', 'voicePrint.name': 'Name',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': 'Löschen fehlgeschlagen, bitte versuchen Sie es erneut', 'paramManagement.deleteFailed': 'Löschen fehlgeschlagen, bitte versuchen Sie es erneut',
'paramManagement.operationCancelled': 'Löschen abgebrochen', 'paramManagement.operationCancelled': 'Löschen abgebrochen',
'paramManagement.operationClosed': 'Operation geschlossen', '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.addSuccess': 'Hinzufügen erfolgreich',
'paramManagement.updateFailed': 'Aktualisierung fehlgeschlagen', 'paramManagement.updateFailed': 'Aktualisierung fehlgeschlagen',
'paramManagement.addFailed': 'Hinzufügen fehlgeschlagen', 'paramManagement.addFailed': 'Hinzufügen fehlgeschlagen',
@@ -800,7 +821,7 @@ export default {
'modelConfig.rag': 'RAG', 'modelConfig.rag': 'RAG',
'modelConfig.modelId': 'Modell-ID', 'modelConfig.modelId': 'Modell-ID',
'modelConfig.modelName': 'Modellname', 'modelConfig.modelName': 'Modellname',
'modelConfig.provider': 'Anbieter', 'modelConfig.provider': 'Schnittstellentyp',
'modelConfig.unknown': 'Unbekannt', 'modelConfig.unknown': 'Unbekannt',
'modelConfig.isEnabled': 'Aktiviert', 'modelConfig.isEnabled': 'Aktiviert',
'modelConfig.isDefault': 'Standard', 'modelConfig.isDefault': 'Standard',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': 'Aktivieren erfolgreich', 'modelConfig.enableSuccess': 'Aktivieren erfolgreich',
'modelConfig.disableSuccess': 'Deaktivieren erfolgreich', 'modelConfig.disableSuccess': 'Deaktivieren erfolgreich',
'modelConfig.operationFailed': 'Operation fehlgeschlagen', '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.itemsPerPage': '{items} Einträge/Seite',
'modelConfig.firstPage': 'Erste Seite', 'modelConfig.firstPage': 'Erste Seite',
'modelConfig.prevPage': 'Vorherige Seite', 'modelConfig.prevPage': 'Vorherige Seite',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': 'Inhalt:', 'knowledgeFileUpload.content': 'Inhalt:',
'knowledgeFileUpload.testQuestionRequired': 'Bitte Testfrage eingeben', 'knowledgeFileUpload.testQuestionRequired': 'Bitte Testfrage eingeben',
'knowledgeBaseDialog.descriptionRequired': 'Bitte Wissensbasis-Beschreibung 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',
} }
+55 -3
View File
@@ -22,6 +22,7 @@ export default {
'header.clearHistory': 'Clear History', 'header.clearHistory': 'Clear History',
'header.providerManagement': 'Provider Management', 'header.providerManagement': 'Provider Management',
'header.serverSideManagement': 'Server Management', 'header.serverSideManagement': 'Server Management',
'header.featureManagement': 'System Feature Management',
'header.changePassword': 'Change Password', 'header.changePassword': 'Change Password',
'header.logout': 'Logout', 'header.logout': 'Logout',
'header.searchPlaceholder': 'Search by name..', 'header.searchPlaceholder': 'Search by name..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': 'Please enter name', 'voicePrintDialog.requiredName': 'Please enter name',
'voicePrintDialog.requiredAudioVector': 'Please select audio vector', '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 // Voice print page related
'voicePrint.pageTitle': 'Voice Print Recognition', 'voicePrint.pageTitle': 'Voice Print Recognition',
'voicePrint.name': 'Name', 'voicePrint.name': 'Name',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': 'Deletion failed, please try again', 'paramManagement.deleteFailed': 'Deletion failed, please try again',
'paramManagement.operationCancelled': 'Deletion cancelled', 'paramManagement.operationCancelled': 'Deletion cancelled',
'paramManagement.operationClosed': 'Operation closed', '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.addSuccess': 'Add successful',
'paramManagement.updateFailed': 'Update failed', 'paramManagement.updateFailed': 'Update failed',
'paramManagement.addFailed': 'Add failed', 'paramManagement.addFailed': 'Add failed',
@@ -800,7 +821,7 @@ export default {
'modelConfig.rag': 'RAG', 'modelConfig.rag': 'RAG',
'modelConfig.modelId': 'Model ID', 'modelConfig.modelId': 'Model ID',
'modelConfig.modelName': 'Model Name', 'modelConfig.modelName': 'Model Name',
'modelConfig.provider': 'Provider', 'modelConfig.provider': 'Interface Type',
'modelConfig.unknown': 'Unknown', 'modelConfig.unknown': 'Unknown',
'modelConfig.isEnabled': 'Enabled', 'modelConfig.isEnabled': 'Enabled',
'modelConfig.isDefault': 'Default', 'modelConfig.isDefault': 'Default',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': 'Enable successful', 'modelConfig.enableSuccess': 'Enable successful',
'modelConfig.disableSuccess': 'Disable successful', 'modelConfig.disableSuccess': 'Disable successful',
'modelConfig.operationFailed': 'Operation failed', '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.itemsPerPage': '{items} items/page',
'modelConfig.firstPage': 'First Page', 'modelConfig.firstPage': 'First Page',
'modelConfig.prevPage': 'Previous Page', 'modelConfig.prevPage': 'Previous Page',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': 'Content:', 'knowledgeFileUpload.content': 'Content:',
'knowledgeFileUpload.testQuestionRequired': 'Please enter test question', 'knowledgeFileUpload.testQuestionRequired': 'Please enter test question',
'knowledgeBaseDialog.descriptionRequired': 'Please enter knowledge base description', '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',
} }
+56 -4
View File
@@ -22,6 +22,7 @@ export default {
'header.clearHistory': 'Xóa lịch sử', 'header.clearHistory': 'Xóa lịch sử',
'header.providerManagement': 'Quản lý nhà cung cấp', 'header.providerManagement': 'Quản lý nhà cung cấp',
'header.serverSideManagement': 'Quản lý máy chủ', '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.changePassword': 'Đổi mật khẩu',
'header.logout': 'Đăng xuất', 'header.logout': 'Đăng xuất',
'header.searchPlaceholder': 'Tìm kiếm theo tên..', '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.requiredName': 'Vui lòng nhập tên',
'voicePrintDialog.requiredAudioVector': 'Vui lòng chọn vector âm thanh', '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 // Voice print page related
'voicePrint.pageTitle': 'Nhận dạng dấu giọng nói', 'voicePrint.pageTitle': 'Nhận dạng dấu giọng nói',
'voicePrint.name': 'Tên', 'voicePrint.name': 'Tên',
@@ -329,7 +350,7 @@ export default {
'paramDialog.jsonType': 'Đối tượng JSON', 'paramDialog.jsonType': 'Đối tượng JSON',
// Login page text // Login page text
'login.title': 'Đăng nhập', 'login.title': 'DN',
'login.welcome': 'CHÀO MỪNG ĐẾN VỚI ĐĂNG NHẬP', 'login.welcome': 'CHÀO MỪNG ĐẾN VỚI ĐĂNG NHẬP',
'login.username': 'Tên đăng nhập', 'login.username': 'Tên đăng nhập',
'login.usernamePlaceholder': 'Vui lòng nhập tên đăng nhập', 'login.usernamePlaceholder': 'Vui lòng nhập tên đăng nhập',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': 'Xóa thất bại, vui lòng thử lại', 'paramManagement.deleteFailed': 'Xóa thất bại, vui lòng thử lại',
'paramManagement.operationCancelled': 'Đã hủy xóa', 'paramManagement.operationCancelled': 'Đã hủy xóa',
'paramManagement.operationClosed': 'Đã đóng thao tác', '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.addSuccess': 'Thêm thành công',
'paramManagement.updateFailed': 'Cập nhật thất bại', 'paramManagement.updateFailed': 'Cập nhật thất bại',
'paramManagement.addFailed': 'Thêm thất bại', 'paramManagement.addFailed': 'Thêm thất bại',
@@ -800,7 +821,7 @@ export default {
'modelConfig.rag': 'RAG', 'modelConfig.rag': 'RAG',
'modelConfig.modelId': 'ID mô hình', 'modelConfig.modelId': 'ID mô hình',
'modelConfig.modelName': 'Tên 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.unknown': 'Không xác định',
'modelConfig.isEnabled': 'Đã bật', 'modelConfig.isEnabled': 'Đã bật',
'modelConfig.isDefault': 'Mặc định', 'modelConfig.isDefault': 'Mặc định',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': 'Bật thành công', 'modelConfig.enableSuccess': 'Bật thành công',
'modelConfig.disableSuccess': 'Tắt thành công', 'modelConfig.disableSuccess': 'Tắt thành công',
'modelConfig.operationFailed': 'Thao tác thất bại', '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.itemsPerPage': '{items} mục/trang',
'modelConfig.firstPage': 'Trang đầu', 'modelConfig.firstPage': 'Trang đầu',
'modelConfig.prevPage': 'Trang trước', 'modelConfig.prevPage': 'Trang trước',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': 'Nội dung:', 'knowledgeFileUpload.content': 'Nội dung:',
'knowledgeFileUpload.testQuestionRequired': 'Vui lòng nhập câu hỏi kiểm tra', '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', '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',
} }
+55 -3
View File
@@ -22,6 +22,7 @@ export default {
'header.clearHistory': '清空历史', 'header.clearHistory': '清空历史',
'header.providerManagement': '字段管理', 'header.providerManagement': '字段管理',
'header.serverSideManagement': '服务端管理', 'header.serverSideManagement': '服务端管理',
'header.featureManagement': '系统功能配置',
'header.changePassword': '修改密码', 'header.changePassword': '修改密码',
'header.logout': '退出登录', 'header.logout': '退出登录',
'header.searchPlaceholder': '输入名称搜索..', 'header.searchPlaceholder': '输入名称搜索..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': '请输入姓名', 'voicePrintDialog.requiredName': '请输入姓名',
'voicePrintDialog.requiredAudioVector': '请选择音频向量', '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.pageTitle': '声纹识别',
'voicePrint.name': '姓名', 'voicePrint.name': '姓名',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': '删除失败,请重试', 'paramManagement.deleteFailed': '删除失败,请重试',
'paramManagement.operationCancelled': '已取消删除操作', 'paramManagement.operationCancelled': '已取消删除操作',
'paramManagement.operationClosed': '操作已关闭', 'paramManagement.operationClosed': '操作已关闭',
'paramManagement.updateSuccess': '修改成功', 'paramManagement.updateSuccess': '修改成功,部分配置需重启xiaozhi-server模块才生效',
'paramManagement.addSuccess': '新增成功', 'paramManagement.addSuccess': '新增成功',
'paramManagement.updateFailed': '更新失败', 'paramManagement.updateFailed': '更新失败',
'paramManagement.addFailed': '新增失败', 'paramManagement.addFailed': '新增失败',
@@ -800,7 +821,7 @@ export default {
'modelConfig.rag': '知识库', 'modelConfig.rag': '知识库',
'modelConfig.modelId': '模型ID', 'modelConfig.modelId': '模型ID',
'modelConfig.modelName': '模型名称', 'modelConfig.modelName': '模型名称',
'modelConfig.provider': '提供商', 'modelConfig.provider': '接口类型',
'modelConfig.unknown': '未知', 'modelConfig.unknown': '未知',
'modelConfig.isEnabled': '是否启用', 'modelConfig.isEnabled': '是否启用',
'modelConfig.isDefault': '是否默认', 'modelConfig.isDefault': '是否默认',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': '启用成功', 'modelConfig.enableSuccess': '启用成功',
'modelConfig.disableSuccess': '禁用成功', 'modelConfig.disableSuccess': '禁用成功',
'modelConfig.operationFailed': '操作失败', 'modelConfig.operationFailed': '操作失败',
'modelConfig.setDefaultSuccess': '设置默认模型成功', 'modelConfig.setDefaultSuccess': '设置默认模型成功,请及时手动重启xiaozhi-server模块',
'modelConfig.itemsPerPage': '{items}条/页', 'modelConfig.itemsPerPage': '{items}条/页',
'modelConfig.firstPage': '首页', 'modelConfig.firstPage': '首页',
'modelConfig.prevPage': '上一页', 'modelConfig.prevPage': '上一页',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': '内容:', 'knowledgeFileUpload.content': '内容:',
'knowledgeFileUpload.testQuestionRequired': '请输入测试问题', 'knowledgeFileUpload.testQuestionRequired': '请输入测试问题',
'knowledgeBaseDialog.descriptionRequired': '请输入知识库描述', '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': '将语音转换为文本,实现自然语言交互功能',
} }
+55 -3
View File
@@ -22,6 +22,7 @@ export default {
'header.clearHistory': '清空歷史', 'header.clearHistory': '清空歷史',
'header.providerManagement': '字段管理', 'header.providerManagement': '字段管理',
'header.serverSideManagement': '服務端管理', 'header.serverSideManagement': '服務端管理',
'header.featureManagement': '系統功能配置',
'header.changePassword': '修改密碼', 'header.changePassword': '修改密碼',
'header.logout': '退出登錄', 'header.logout': '退出登錄',
'header.searchPlaceholder': '輸入名稱搜索..', 'header.searchPlaceholder': '輸入名稱搜索..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': '請輸入姓名', 'voicePrintDialog.requiredName': '請輸入姓名',
'voicePrintDialog.requiredAudioVector': '請選擇音頻向量', '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.pageTitle': '聲紋識別',
'voicePrint.name': '姓名', 'voicePrint.name': '姓名',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': '刪除失敗,請重試', 'paramManagement.deleteFailed': '刪除失敗,請重試',
'paramManagement.operationCancelled': '已取消刪除操作', 'paramManagement.operationCancelled': '已取消刪除操作',
'paramManagement.operationClosed': '操作已關閉', 'paramManagement.operationClosed': '操作已關閉',
'paramManagement.updateSuccess': '修改成功', 'paramManagement.updateSuccess': '修改成功,部分配置需重啟xiaozhi-server模組才生效',
'paramManagement.addSuccess': '新增成功', 'paramManagement.addSuccess': '新增成功',
'paramManagement.updateFailed': '更新失敗', 'paramManagement.updateFailed': '更新失敗',
'paramManagement.addFailed': '新增失敗', 'paramManagement.addFailed': '新增失敗',
@@ -800,7 +821,7 @@ export default {
'modelConfig.rag': '知識庫', 'modelConfig.rag': '知識庫',
'modelConfig.modelId': '模型ID', 'modelConfig.modelId': '模型ID',
'modelConfig.modelName': '模型名稱', 'modelConfig.modelName': '模型名稱',
'modelConfig.provider': '提供商', 'modelConfig.provider': '接口類型',
'modelConfig.unknown': '未知', 'modelConfig.unknown': '未知',
'modelConfig.isEnabled': '是否啟用', 'modelConfig.isEnabled': '是否啟用',
'modelConfig.isDefault': '是否默認', 'modelConfig.isDefault': '是否默認',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': '啟用成功', 'modelConfig.enableSuccess': '啟用成功',
'modelConfig.disableSuccess': '禁用成功', 'modelConfig.disableSuccess': '禁用成功',
'modelConfig.operationFailed': '操作失敗', 'modelConfig.operationFailed': '操作失敗',
'modelConfig.setDefaultSuccess': '設置默認模型成功', 'modelConfig.setDefaultSuccess': '設置默認模型成功,請及時手動重啟xiaozhi-server模組',
'modelConfig.itemsPerPage': '{items}條/頁', 'modelConfig.itemsPerPage': '{items}條/頁',
'modelConfig.firstPage': '首頁', 'modelConfig.firstPage': '首頁',
'modelConfig.prevPage': '上一頁', 'modelConfig.prevPage': '上一頁',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': '內容:', 'knowledgeFileUpload.content': '內容:',
'knowledgeFileUpload.testQuestionRequired': '請輸入測試問題', 'knowledgeFileUpload.testQuestionRequired': '請輸入測試問題',
'knowledgeBaseDialog.descriptionRequired': '請輸入知识库描述', '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': '將語音轉換為文本,實現自然語言交互功能',
} }
+1
View File
@@ -8,6 +8,7 @@ import store from './store';
import i18n from './i18n'; import i18n from './i18n';
import './styles/global.scss'; import './styles/global.scss';
import { register as registerServiceWorker } from './registerServiceWorker'; import { register as registerServiceWorker } from './registerServiceWorker';
import featureManager from './utils/featureManager';
// 创建事件总线,用于组件间通信 // 创建事件总线,用于组件间通信
Vue.prototype.$eventBus = new Vue(); Vue.prototype.$eventBus = new Vue();
+18 -3
View File
@@ -76,7 +76,8 @@ const routes = [
return import('../views/ModelConfig.vue') return import('../views/ModelConfig.vue')
} }
}, },
{ path: '/params-management', {
path: '/params-management',
name: 'ParamsManagement', name: 'ParamsManagement',
component: function () { component: function () {
return import('../views/ParamsManagement.vue') return import('../views/ParamsManagement.vue')
@@ -86,7 +87,8 @@ const routes = [
title: '参数管理' title: '参数管理'
} }
}, },
{ path: '/knowledge-base-management', {
path: '/knowledge-base-management',
name: 'KnowledgeBaseManagement', name: 'KnowledgeBaseManagement',
component: function () { component: function () {
return import('../views/KnowledgeBaseManagement.vue') return import('../views/KnowledgeBaseManagement.vue')
@@ -96,7 +98,8 @@ const routes = [
title: '知识库管理' title: '知识库管理'
} }
}, },
{ path: '/knowledge-file-upload', {
path: '/knowledge-file-upload',
name: 'KnowledgeFileUpload', name: 'KnowledgeFileUpload',
component: function () { component: function () {
return import('../views/KnowledgeFileUpload.vue') return import('../views/KnowledgeFileUpload.vue')
@@ -181,6 +184,18 @@ const routes = [
return import('../views/TemplateQuickConfig.vue') 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({ const router = new VueRouter({
base: process.env.VUE_APP_PUBLIC_PATH || '/', 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;
@@ -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>
@@ -214,7 +214,7 @@
<el-dialog :title="$t('knowledgeFileUpload.retrievalTest')" :visible.sync="retrievalTestDialogVisible" <el-dialog :title="$t('knowledgeFileUpload.retrievalTest')" :visible.sync="retrievalTestDialogVisible"
width="1200px" class="retrieval-test-dialog"> width="1200px" class="retrieval-test-dialog">
<div class="retrieval-test-form"> <div class="retrieval-test-form">
<el-form :model="retrievalTestForm" label-width="80px"> <el-form :model="retrievalTestForm" label-width="80px" @submit.native.prevent="runRetrievalTest">
<el-form-item :label="$t('knowledgeFileUpload.testQuestion')" required> <el-form-item :label="$t('knowledgeFileUpload.testQuestion')" required>
<el-input v-model="retrievalTestForm.question" <el-input v-model="retrievalTestForm.question"
:placeholder="$t('knowledgeFileUpload.testQuestionPlaceholder')" style="width: 100%; max-height: 80px;" :placeholder="$t('knowledgeFileUpload.testQuestionPlaceholder')" style="width: 100%; max-height: 80px;"
+2 -2
View File
@@ -38,11 +38,11 @@
position: absolute; position: absolute;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
right: 15%; right: 18%;
background-color: #fff; background-color: #fff;
border-radius: 20px; border-radius: 20px;
padding: 35px 0; padding: 35px 0;
width: 550px; width: 450px;
box-sizing: border-box; box-sizing: border-box;
} }
+24 -4
View File
@@ -39,8 +39,9 @@
</template> </template>
<template v-else> <template v-else>
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" @configure="goToRoleConfig" <DeviceItem v-for="(item, index) in devices" :key="index" :device="item" :feature-status="featureStatus"
@deviceManage="handleDeviceManage" @delete="handleDeleteAgent" @chat-history="handleShowChatHistory" /> @configure="goToRoleConfig" @deviceManage="handleDeviceManage" @delete="handleDeleteAgent"
@chat-history="handleShowChatHistory" />
</template> </template>
</div> </div>
</div> </div>
@@ -61,6 +62,7 @@ import ChatHistoryDialog from '@/components/ChatHistoryDialog.vue';
import DeviceItem from '@/components/DeviceItem.vue'; import DeviceItem from '@/components/DeviceItem.vue';
import HeaderBar from '@/components/HeaderBar.vue'; import HeaderBar from '@/components/HeaderBar.vue';
import VersionFooter from '@/components/VersionFooter.vue'; import VersionFooter from '@/components/VersionFooter.vue';
import featureManager from '@/utils/featureManager';
export default { export default {
name: 'HomePage', name: 'HomePage',
@@ -76,15 +78,33 @@ export default {
skeletonCount: localStorage.getItem('skeletonCount') || 8, skeletonCount: localStorage.getItem('skeletonCount') || 8,
showChatHistory: false, showChatHistory: false,
currentAgentId: '', currentAgentId: '',
currentAgentName: '' currentAgentName: '',
// 功能状态
featureStatus: {
voiceprintRecognition: false,
voiceClone: false,
knowledgeBase: false
}
} }
}, },
mounted() { async mounted() {
this.fetchAgentList(); this.fetchAgentList();
await this.loadFeatureStatus();
}, },
methods: { methods: {
// 加载功能状态
async loadFeatureStatus() {
await featureManager.waitForInitialization();
const config = featureManager.getConfig();
this.featureStatus = {
voiceprintRecognition: config.voiceprintRecognition,
voiceClone: config.voiceClone,
knowledgeBase: config.knowledgeBase
};
},
showAddDialog() { showAddDialog() {
this.addDeviceDialogVisible = true this.addDeviceDialogVisible = true
}, },
+27 -1
View File
@@ -10,7 +10,7 @@
gap: 10px; gap: 10px;
"> ">
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px; height: 45px" /> <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> </div>
</el-header> </el-header>
<div class="login-person"> <div class="login-person">
@@ -156,6 +156,7 @@ import VersionFooter from "@/components/VersionFooter.vue";
import i18n, { changeLanguage } from "@/i18n"; import i18n, { changeLanguage } from "@/i18n";
import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from "@/utils"; import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from "@/utils";
import { mapState } from "vuex"; import { mapState } from "vuex";
import featureManager from "@/utils/featureManager";
export default { export default {
name: "login", name: "login",
@@ -191,6 +192,24 @@ export default {
return this.$t("language.zhCN"); 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() { data() {
return { return {
@@ -214,6 +233,13 @@ export default {
this.$store.dispatch("fetchPubConfig").then(() => { this.$store.dispatch("fetchPubConfig").then(() => {
// 根据配置决定默认登录方式 // 根据配置决定默认登录方式
this.isMobileLogin = this.enableMobileRegister; this.isMobileLogin = this.enableMobileRegister;
// pub-config接口调用完成后,重新初始化featureManager以确保使用最新的配置
featureManager.waitForInitialization().then(() => {
console.log('featureManager重新初始化完成,使用pub-config配置');
}).catch(error => {
console.warn('featureManager重新初始化失败:', error);
});
}); });
}, },
methods: { methods: {
+25 -2
View File
@@ -5,7 +5,7 @@
<el-header> <el-header>
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;"> <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-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> </div>
</el-header> </el-header>
<div class="login-person"> <div class="login-person">
@@ -108,7 +108,7 @@
<div style="font-size: 14px;color: #979db1;"> <div style="font-size: 14px;color: #979db1;">
{{ $t('register.agreeTo') }} {{ $t('register.agreeTo') }}
<div style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.userAgreement') }}</div> <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 style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.privacyPolicy') }}</div>
</div> </div>
</div> </div>
@@ -127,6 +127,7 @@ import Api from '@/apis/api';
import VersionFooter from '@/components/VersionFooter.vue'; import VersionFooter from '@/components/VersionFooter.vue';
import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from '@/utils'; import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from '@/utils';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import i18n from '@/i18n';
// 导入语言切换功能 // 导入语言切换功能
@@ -142,6 +143,28 @@ export default {
mobileAreaList: state => state.pubConfig.mobileAreaList, mobileAreaList: state => state.pubConfig.mobileAreaList,
sm2PublicKey: state => state.pubConfig.sm2PublicKey, 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() { canSendMobileCaptcha() {
return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode); return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode);
} }
@@ -5,7 +5,7 @@
<el-header> <el-header>
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;"> <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-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> </div>
</el-header> </el-header>
<div class="login-person"> <div class="login-person">
@@ -83,7 +83,7 @@
<div style="font-size: 14px;color: #979db1;"> <div style="font-size: 14px;color: #979db1;">
{{ $t('retrievePassword.agreeTo') }} {{ $t('retrievePassword.agreeTo') }}
<div style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.userAgreement') }}</div> <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 style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.privacyPolicy') }}</div>
</div> </div>
</div> </div>
@@ -103,6 +103,7 @@ import Api from '@/apis/api';
import VersionFooter from '@/components/VersionFooter.vue'; import VersionFooter from '@/components/VersionFooter.vue';
import { getUUID, goToPage, showDanger, showSuccess, validateMobile, sm2Encrypt } from '@/utils'; import { getUUID, goToPage, showDanger, showSuccess, validateMobile, sm2Encrypt } from '@/utils';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import i18n from '@/i18n';
// 导入语言切换功能 // 导入语言切换功能
import { changeLanguage } from '@/i18n'; import { changeLanguage } from '@/i18n';
@@ -118,6 +119,28 @@ export default {
mobileAreaList: state => state.pubConfig.mobileAreaList, mobileAreaList: state => state.pubConfig.mobileAreaList,
sm2PublicKey: state => state.pubConfig.sm2PublicKey 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() { canSendMobileCaptcha() {
return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode); return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode);
} }
+101 -6
View File
@@ -55,10 +55,24 @@
</div> </div>
</div> </div>
</el-form-item> </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-form-item :label="$t('roleConfig.roleIntroduction') + ''">
<el-input <el-input
type="textarea" type="textarea"
rows="9" rows="8"
resize="none" resize="none"
:placeholder="$t('roleConfig.pleaseEnterContent')" :placeholder="$t('roleConfig.pleaseEnterContent')"
v-model="form.systemPrompt" v-model="form.systemPrompt"
@@ -71,7 +85,7 @@
<el-form-item :label="$t('roleConfig.memoryHis') + ''"> <el-form-item :label="$t('roleConfig.memoryHis') + ''">
<el-input <el-input
type="textarea" type="textarea"
rows="6" rows="4"
resize="none" resize="none"
v-model="form.summaryMemory" v-model="form.summaryMemory"
maxlength="2000" maxlength="2000"
@@ -107,7 +121,11 @@
</div> </div>
<div class="form-column"> <div class="form-column">
<div class="model-row"> <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"> <div class="model-select-wrapper">
<el-select <el-select
v-model="form.model.vadModelId" v-model="form.model.vadModelId"
@@ -125,7 +143,11 @@
</el-select> </el-select>
</div> </div>
</el-form-item> </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"> <div class="model-select-wrapper">
<el-select <el-select
v-model="form.model.asrModelId" v-model="form.model.asrModelId"
@@ -267,6 +289,11 @@
@update-functions="handleUpdateFunctions" @update-functions="handleUpdateFunctions"
@dialog-closed="handleDialogClosed" @dialog-closed="handleDialogClosed"
/> />
<context-provider-dialog
:visible.sync="showContextProviderDialog"
:providers="currentContextProviders"
@confirm="handleUpdateContext"
/>
</div> </div>
</template> </template>
@@ -275,14 +302,17 @@ import Api from "@/apis/api";
import { getServiceUrl } from "@/apis/api"; import { getServiceUrl } from "@/apis/api";
import RequestService from "@/apis/httpRequest"; import RequestService from "@/apis/httpRequest";
import FunctionDialog from "@/components/FunctionDialog.vue"; import FunctionDialog from "@/components/FunctionDialog.vue";
import ContextProviderDialog from "@/components/ContextProviderDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import i18n from "@/i18n"; import i18n from "@/i18n";
import featureManager from "@/utils/featureManager";
export default { export default {
name: "RoleConfigPage", name: "RoleConfigPage",
components: { HeaderBar, FunctionDialog }, components: { HeaderBar, FunctionDialog, ContextProviderDialog },
data() { data() {
return { return {
showContextProviderDialog: false,
form: { form: {
agentCode: "", agentCode: "",
agentName: "", agentName: "",
@@ -320,12 +350,18 @@ export default {
voiceDetails: {}, // 保存完整的音色信息 voiceDetails: {}, // 保存完整的音色信息
showFunctionDialog: false, showFunctionDialog: false,
currentFunctions: [], currentFunctions: [],
currentContextProviders: [],
allFunctions: [], allFunctions: [],
originalFunctions: [], originalFunctions: [],
playingVoice: false, playingVoice: false,
isPaused: false, isPaused: false,
currentAudio: null, currentAudio: null,
currentPlayingVoiceId: null, currentPlayingVoiceId: null,
// 功能状态
featureStatus: {
vad: false, // 语言检测活动功能状态
asr: false, // 语音识别功能状态
},
}; };
}, },
methods: { methods: {
@@ -356,6 +392,7 @@ export default {
paramInfo: item.params, paramInfo: item.params,
}; };
}), }),
contextProviders: this.currentContextProviders,
}; };
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => { Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
@@ -472,6 +509,9 @@ export default {
}; };
// 后端只给了最小映射:[{ id, agentId, pluginId }, ...] // 后端只给了最小映射:[{ id, agentId, pluginId }, ...]
const savedMappings = data.data.functions || []; const savedMappings = data.data.functions || [];
// 加载上下文配置
this.currentContextProviders = data.data.contextProviders || [];
// 先保证 allFunctions 已经加载(如果没有,则先 fetchAllFunctions // 先保证 allFunctions 已经加载(如果没有,则先 fetchAllFunctions
const ensureFuncs = this.allFunctions.length const ensureFuncs = this.allFunctions.length
@@ -646,6 +686,12 @@ export default {
this.showFunctionDialog = true; this.showFunctionDialog = true;
} }
}, },
openContextProviderDialog() {
this.showContextProviderDialog = true;
},
handleUpdateContext(providers) {
this.currentContextProviders = providers;
},
handleUpdateFunctions(selected) { handleUpdateFunctions(selected) {
this.currentFunctions = selected; this.currentFunctions = selected;
}, },
@@ -980,6 +1026,19 @@ export default {
this.form.chatHistoryConf = 0; 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: { watch: {
"form.model.ttsModelId": { "form.model.ttsModelId": {
@@ -1002,7 +1061,7 @@ export default {
immediate: true, immediate: true,
}, },
}, },
mounted() { async mounted() {
const agentId = this.$route.query.agentId; const agentId = this.$route.query.agentId;
if (agentId) { if (agentId) {
this.fetchAgentConfig(agentId); this.fetchAgentConfig(agentId);
@@ -1010,6 +1069,8 @@ export default {
} }
this.fetchModelOptions(); this.fetchModelOptions();
this.fetchTemplates(); this.fetchTemplates();
// 加载功能状态,确保featureManager已初始化
await this.loadFeatureStatus();
}, },
}; };
</script> </script>
@@ -1298,6 +1359,26 @@ export default {
justify-content: flex-end; 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 { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1345,4 +1426,18 @@ export default {
height: 32px; height: 32px;
margin-left: 8px; 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> </style>
@@ -74,6 +74,7 @@
- **今天农历:** {{lunar_date}} - **今天农历:** {{lunar_date}}
- **用户所在城市:** {{local_address}} - **用户所在城市:** {{local_address}}
- **当地未来7天天气:** {{weather_info}} - **当地未来7天天气:** {{weather_info}}
{{ dynamic_context }}
</context> </context>
<memory> <memory>
+8
View File
@@ -9,6 +9,7 @@ from core.utils.util import get_local_ip, validate_mcp_endpoint
from core.http_server import SimpleHttpServer from core.http_server import SimpleHttpServer
from core.websocket_server import WebSocketServer from core.websocket_server import WebSocketServer
from core.utils.util import check_ffmpeg_installed from core.utils.util import check_ffmpeg_installed
from core.utils.gc_manager import get_gc_manager
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -63,6 +64,10 @@ async def main():
# 添加 stdin 监控任务 # 添加 stdin 监控任务
stdin_task = asyncio.create_task(monitor_stdin()) stdin_task = asyncio.create_task(monitor_stdin())
# 启动全局GC管理器(5分钟清理一次)
gc_manager = get_gc_manager(interval_seconds=300)
await gc_manager.start()
# 启动 WebSocket 服务器 # 启动 WebSocket 服务器
ws_server = WebSocketServer(config) ws_server = WebSocketServer(config)
ws_task = asyncio.create_task(ws_server.start()) ws_task = asyncio.create_task(ws_server.start())
@@ -122,6 +127,9 @@ async def main():
except asyncio.CancelledError: except asyncio.CancelledError:
print("任务被取消,清理资源中...") print("任务被取消,清理资源中...")
finally: finally:
# 停止全局GC管理器
await gc_manager.stop()
# 取消所有任务(关键修复点) # 取消所有任务(关键修复点)
stdin_task.cancel() stdin_task.cancel()
ws_task.cancel() ws_task.cancel()
+14 -1
View File
@@ -69,6 +69,9 @@ enable_greeting: true
enable_stop_tts_notify: false enable_stop_tts_notify: false
# 说完话是否开启提示音,音效地址 # 说完话是否开启提示音,音效地址
stop_tts_notify_voice: "config/assets/tts_notify.mp3" stop_tts_notify_voice: "config/assets/tts_notify.mp3"
# 是否启用WebSocket心跳保活机制
enable_websocket_ping: false
# TTS音频发送延迟配置 # TTS音频发送延迟配置
# tts_audio_send_delay: 控制音频包发送间隔 # tts_audio_send_delay: 控制音频包发送间隔
@@ -113,6 +116,15 @@ wakeup_words:
# MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token # MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
# 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md # 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
mcp_endpoint: 你的接入点 websocket地址 mcp_endpoint: 你的接入点 websocket地址
# 上下文源配置
# 用于在系统提示词中注入动态数据,如健康数据、股票信息等
# 可以添加多个上下文源
context_providers:
- url: ""
headers:
Authorization: ""
# 插件的基础配置 # 插件的基础配置
plugins: plugins:
# 获取天气插件的配置,这里填写你的api_key # 获取天气插件的配置,这里填写你的api_key
@@ -334,6 +346,8 @@ ASR:
# 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738 # 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738
boosting_table_name: (选填)你的热词文件名称 boosting_table_name: (选填)你的热词文件名称
correct_table_name: (选填)你的替换词文件名称 correct_table_name: (选填)你的替换词文件名称
# 静音判定时长(ms),默认200ms
end_window_size: 200
output_dir: tmp/ output_dir: tmp/
TencentASR: TencentASR:
# token申请地址:https://console.cloud.tencent.com/cam/capi # token申请地址:https://console.cloud.tencent.com/cam/capi
@@ -462,7 +476,6 @@ ASR:
domain: slm # 识别领域,iat:日常用语,medical:医疗,finance:金融等 domain: slm # 识别领域,iat:日常用语,medical:医疗,finance:金融等
language: zh_cn # 语言,zh_cn:中文,en_us:英文 language: zh_cn # 语言,zh_cn:中文,en_us:英文
accent: mandarin # 方言,mandarin:普通话 accent: mandarin # 方言,mandarin:普通话
dwa: wpgs # 动态修正,wpgs:实时返回中间结果
# 调整音频处理参数以提高长语音识别质量 # 调整音频处理参数以提高长语音识别质量
output_dir: tmp/ output_dir: tmp/
+17 -6
View File
@@ -32,7 +32,16 @@ def load_config():
custom_config = read_config(custom_config_path) custom_config = read_config(custom_config_path)
if custom_config.get("manager-api", {}).get("url"): if custom_config.get("manager-api", {}).get("url"):
config = get_config_from_api(custom_config) import asyncio
try:
loop = asyncio.get_running_loop()
# 如果已经在事件循环中,使用异步版本
config = asyncio.run_coroutine_threadsafe(
get_config_from_api_async(custom_config), loop
).result()
except RuntimeError:
# 如果不在事件循环中(启动时),创建新的事件循环
config = asyncio.run(get_config_from_api_async(custom_config))
else: else:
# 合并配置 # 合并配置
config = merge_configs(default_config, custom_config) config = merge_configs(default_config, custom_config)
@@ -44,13 +53,13 @@ def load_config():
return config return config
def get_config_from_api(config): async def get_config_from_api_async(config):
"""从Java API获取配置""" """从Java API获取配置(异步版本)"""
# 初始化API客户端 # 初始化API客户端
init_service(config) init_service(config)
# 获取服务器配置 # 获取服务器配置
config_data = get_server_config() config_data = await get_server_config()
if config_data is None: if config_data is None:
raise Exception("Failed to fetch server config from API") raise Exception("Failed to fetch server config from API")
@@ -59,6 +68,7 @@ def get_config_from_api(config):
"url": config["manager-api"].get("url", ""), "url": config["manager-api"].get("url", ""),
"secret": config["manager-api"].get("secret", ""), "secret": config["manager-api"].get("secret", ""),
} }
auth_enabled = config_data.get("server", {}).get("auth", {}).get("enabled", False)
# server的配置以本地为准 # server的配置以本地为准
if config.get("server"): if config.get("server"):
config_data["server"] = { config_data["server"] = {
@@ -68,15 +78,16 @@ def get_config_from_api(config):
"vision_explain": config["server"].get("vision_explain", ""), "vision_explain": config["server"].get("vision_explain", ""),
"auth_key": config["server"].get("auth_key", ""), "auth_key": config["server"].get("auth_key", ""),
} }
config_data["server"]["auth"] = {"enabled": auth_enabled}
# 如果服务器没有prompt_template,则从本地配置读取 # 如果服务器没有prompt_template,则从本地配置读取
if not config_data.get("prompt_template"): if not config_data.get("prompt_template"):
config_data["prompt_template"] = config.get("prompt_template") config_data["prompt_template"] = config.get("prompt_template")
return config_data return config_data
def get_private_config_from_api(config, device_id, client_id): async def get_private_config_from_api(config, device_id, client_id):
"""从Java API获取私有配置""" """从Java API获取私有配置"""
return get_agent_models(device_id, client_id, config["selected_module"]) return await get_agent_models(device_id, client_id, config["selected_module"])
def ensure_directories(config): def ensure_directories(config):
+1 -1
View File
@@ -5,7 +5,7 @@ from config.config_loader import load_config
from config.settings import check_config_file from config.settings import check_config_file
from datetime import datetime from datetime import datetime
SERVER_VERSION = "0.8.8" SERVER_VERSION = "0.8.10"
_logger_initialized = False _logger_initialized = False
+67 -42
View File
@@ -1,5 +1,4 @@
import os import os
import time
import base64 import base64
from typing import Optional, Dict from typing import Optional, Dict
@@ -20,7 +19,7 @@ class DeviceBindException(Exception):
class ManageApiClient: class ManageApiClient:
_instance = None _instance = None
_client = None _async_clients = {} # 为每个事件循环存储独立的客户端
_secret = None _secret = None
def __new__(cls, config): def __new__(cls, config):
@@ -32,7 +31,7 @@ class ManageApiClient:
@classmethod @classmethod
def _init_client(cls, config): def _init_client(cls, config):
"""初始化持久化连接池""" """初始化配置(延迟创建客户端)"""
cls.config = config.get("manager-api") cls.config = config.get("manager-api")
if not cls.config: if not cls.config:
@@ -47,23 +46,41 @@ class ManageApiClient:
cls._secret = cls.config.get("secret") cls._secret = cls.config.get("secret")
cls.max_retries = cls.config.get("max_retries", 6) # 最大重试次数 cls.max_retries = cls.config.get("max_retries", 6) # 最大重试次数
cls.retry_delay = cls.config.get("retry_delay", 10) # 初始重试延迟(秒) cls.retry_delay = cls.config.get("retry_delay", 10) # 初始重试延迟(秒)
# NOTE(goody): 2025/4/16 http相关资源统一管理,后续可以增加线程池或者超时 # 不在这里创建 AsyncClient,延迟到实际使用时创建
# 后续也可以统一配置apiToken之类的走通用的Auth cls._async_clients = {}
cls._client = httpx.Client(
base_url=cls.config.get("url"),
headers={
"User-Agent": f"PythonClient/2.0 (PID:{os.getpid()})",
"Accept": "application/json",
"Authorization": "Bearer " + cls._secret,
},
timeout=cls.config.get("timeout", 30), # 默认超时时间30秒
)
@classmethod @classmethod
def _request(cls, method: str, endpoint: str, **kwargs) -> Dict: async def _ensure_async_client(cls):
"""发送单次HTTP请求并处理响应""" """确保异步客户端已创建(为每个事件循环创建独立的客户端)"""
import asyncio
try:
loop = asyncio.get_running_loop()
loop_id = id(loop)
# 为每个事件循环创建独立的客户端
if loop_id not in cls._async_clients:
cls._async_clients[loop_id] = httpx.AsyncClient(
base_url=cls.config.get("url"),
headers={
"User-Agent": f"PythonClient/2.0 (PID:{os.getpid()})",
"Accept": "application/json",
"Authorization": "Bearer " + cls._secret,
},
timeout=cls.config.get("timeout", 30),
)
return cls._async_clients[loop_id]
except RuntimeError:
# 如果没有运行中的事件循环,创建一个临时的
raise Exception("必须在异步上下文中调用")
@classmethod
async def _async_request(cls, method: str, endpoint: str, **kwargs) -> Dict:
"""发送单次异步HTTP请求并处理响应"""
# 确保客户端已创建
client = await cls._ensure_async_client()
endpoint = endpoint.lstrip("/") endpoint = endpoint.lstrip("/")
response = cls._client.request(method, endpoint, **kwargs) response = await client.request(method, endpoint, **kwargs)
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
@@ -96,22 +113,24 @@ class ManageApiClient:
return False return False
@classmethod @classmethod
def _execute_request(cls, method: str, endpoint: str, **kwargs) -> Dict: async def _execute_async_request(cls, method: str, endpoint: str, **kwargs) -> Dict:
"""带重试机制的请求执行器""" """带重试机制的异步请求执行器"""
import asyncio
retry_count = 0 retry_count = 0
while retry_count <= cls.max_retries: while retry_count <= cls.max_retries:
try: try:
# 执行请求 # 执行异步请求
return cls._request(method, endpoint, **kwargs) return await cls._async_request(method, endpoint, **kwargs)
except Exception as e: except Exception as e:
# 判断是否应该重试 # 判断是否应该重试
if retry_count < cls.max_retries and cls._should_retry(e): if retry_count < cls.max_retries and cls._should_retry(e):
retry_count += 1 retry_count += 1
print( print(
f"{method} {endpoint} 请求失败,将在 {cls.retry_delay:.1f} 秒后进行第 {retry_count} 次重试" f"{method} {endpoint} 异步请求失败,将在 {cls.retry_delay:.1f} 秒后进行第 {retry_count} 次重试"
) )
time.sleep(cls.retry_delay) await asyncio.sleep(cls.retry_delay)
continue continue
else: else:
# 不重试,直接抛出异常 # 不重试,直接抛出异常
@@ -119,22 +138,30 @@ class ManageApiClient:
@classmethod @classmethod
def safe_close(cls): def safe_close(cls):
"""安全关闭连接池""" """安全关闭所有异步连接池"""
if cls._client: import asyncio
cls._client.close()
cls._instance = None for client in list(cls._async_clients.values()):
try:
asyncio.run(client.aclose())
except Exception:
pass
cls._async_clients.clear()
cls._instance = None
def get_server_config() -> Optional[Dict]: async def get_server_config() -> Optional[Dict]:
"""获取服务器基础配置""" """获取服务器基础配置"""
return ManageApiClient._instance._execute_request("POST", "/config/server-base") return await ManageApiClient._instance._execute_async_request(
"POST", "/config/server-base"
)
def get_agent_models( async def get_agent_models(
mac_address: str, client_id: str, selected_module: Dict mac_address: str, client_id: str, selected_module: Dict
) -> Optional[Dict]: ) -> Optional[Dict]:
"""获取代理模型配置""" """获取代理模型配置"""
return ManageApiClient._instance._execute_request( return await ManageApiClient._instance._execute_async_request(
"POST", "POST",
"/config/agent-models", "/config/agent-models",
json={ json={
@@ -145,28 +172,26 @@ def get_agent_models(
) )
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: try:
return ManageApiClient._instance._execute_request( return await ManageApiClient._instance._execute_async_request(
"PUT", "POST",
f"/agent/saveMemory/" + mac_address, f"/agent/chat-summary/{session_id}/save",
json={
"summaryMemory": short_momery,
},
) )
except Exception as e: except Exception as e:
print(f"存储短期记忆到服务器失败: {e}") print(f"生成并保存聊天记录总结失败: {e}")
return None return None
def report( async def report(
mac_address: str, session_id: str, chat_type: int, content: str, audio, report_time mac_address: str, session_id: str, chat_type: int, content: str, audio, report_time
) -> Optional[Dict]: ) -> Optional[Dict]:
"""带熔断的业务方法示例""" """异步聊天记录上报"""
if not content or not ManageApiClient._instance: if not content or not ManageApiClient._instance:
return None return None
try: try:
return ManageApiClient._instance._execute_request( return await ManageApiClient._instance._execute_async_request(
"POST", "POST",
f"/agent/chat-history/report", f"/agent/chat-history/report",
json={ json={
+9 -1
View File
@@ -10,7 +10,15 @@ class BaseHandler:
def _add_cors_headers(self, response): def _add_cors_headers(self, response):
"""添加CORS头信息""" """添加CORS头信息"""
response.headers["Access-Control-Allow-Headers"] = ( 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-Credentials"] = "true"
response.headers["Access-Control-Allow-Origin"] = "*" 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
+231 -15
View File
@@ -3,15 +3,46 @@ import time
import base64 import base64
import hashlib import hashlib
import hmac import hmac
import os
import re
import glob
from typing import Dict, List, Tuple
from aiohttp import web from aiohttp import web
from core.auth import AuthManager 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 from core.api.base_handler import BaseHandler
TAG = __name__ 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): class OTAHandler(BaseHandler):
def __init__(self, config: dict): def __init__(self, config: dict):
super().__init__(config) super().__init__(config)
@@ -23,6 +54,54 @@ class OTAHandler(BaseHandler):
expire_seconds = auth_config.get("expire_seconds") expire_seconds = auth_config.get("expire_seconds")
self.auth = AuthManager(secret_key=secret_key, expire_seconds=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: def generate_password_signature(self, content: str, secret_key: str) -> str:
"""生成MQTT密码签名 """生成MQTT密码签名
@@ -62,7 +141,14 @@ class OTAHandler(BaseHandler):
return f"ws://{local_ip}:{port}/xiaozhi/v1/" return f"ws://{local_ip}:{port}/xiaozhi/v1/"
async def handle_post(self, request): 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: try:
data = await request.text() data = await request.text()
self.logger.bind(tag=TAG).debug(f"OTA请求方法: {request.method}") self.logger.bind(tag=TAG).debug(f"OTA请求方法: {request.method}")
@@ -81,33 +167,76 @@ class OTAHandler(BaseHandler):
else: else:
raise Exception("OTA请求ClientID为空") 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"] 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() 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 = { return_json = {
"server_time": { "server_time": {
"timestamp": int(round(time.time() * 1000)), "timestamp": int(round(time.time() * 1000)),
"timezone_offset": server_config.get("timezone_offset", 8) * 60, "timezone_offset": server_config.get("timezone_offset", 8) * 60,
}, },
"firmware": { "firmware": {
"version": data_json["application"].get("version", "1.0.0"), "version": device_version,
"url": "", "url": "",
}, },
} }
# existing mqtt/websocket logic (unchanged)
mqtt_gateway_endpoint = server_config.get("mqtt_gateway") mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
if mqtt_gateway_endpoint: # 如果配置了非空字符串 if mqtt_gateway_endpoint: # 如果配置了非空字符串
# 尝试从请求数据中获取设备型号 # 尝试从请求数据中获取设备型号(已解析 above
device_model = "default"
try: 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(" ", "_") group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_")
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"获取设备型号失败: {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) token = self.auth.generate_token(client_id, device_id)
else: else:
token = self.auth.generate_token(client_id, device_id) token = self.auth.generate_token(client_id, device_id)
# NOTE: use websocket_port here
return_json["websocket"] = { return_json["websocket"] = {
"url": self._get_websocket_url(local_ip, port), "url": self._get_websocket_url(local_ip, websocket_port),
"token": token, "token": token,
} }
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).info(
f"未配置MQTT网关,为设备 {device_id} 下发WebSocket配置" 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( response = web.Response(
text=json.dumps(return_json, separators=(",", ":")), text=json.dumps(return_json, separators=(",", ":")),
content_type="application/json", content_type="application/json",
) )
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"OTA POST处理异常: {e}")
return_json = {"success": False, "message": "request error."} return_json = {"success": False, "message": "request error."}
response = web.Response( response = web.Response(
text=json.dumps(return_json, separators=(",", ":")), text=json.dumps(return_json, separators=(",", ":")),
@@ -187,8 +357,9 @@ class OTAHandler(BaseHandler):
try: try:
server_config = self.config["server"] server_config = self.config["server"]
local_ip = get_local_ip() local_ip = get_local_ip()
port = int(server_config.get("port", 8000)) # use websocket port for websocket URL
websocket_url = self._get_websocket_url(local_ip, port) websocket_port = int(server_config.get("port", 8000))
websocket_url = self._get_websocket_url(local_ip, websocket_port)
message = f"OTA接口运行正常,向设备发送的websocket地址是:{websocket_url}" message = f"OTA接口运行正常,向设备发送的websocket地址是:{websocket_url}"
response = web.Response(text=message, content_type="text/plain") response = web.Response(text=message, content_type="text/plain")
except Exception as e: except Exception as e:
@@ -197,3 +368,48 @@ class OTAHandler(BaseHandler):
finally: finally:
self._add_cors_headers(response) self._add_cors_headers(response)
return 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
+4 -12
View File
@@ -2,6 +2,7 @@ import json
import copy import copy
from aiohttp import web from aiohttp import web
from config.logger import setup_logging 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.util import get_vision_url, is_valid_image_file
from core.utils.vllm import create_instance from core.utils.vllm import create_instance
from config.config_loader import get_private_config_from_api from config.config_loader import get_private_config_from_api
@@ -16,10 +17,9 @@ TAG = __name__
MAX_FILE_SIZE = 5 * 1024 * 1024 MAX_FILE_SIZE = 5 * 1024 * 1024
class VisionHandler: class VisionHandler(BaseHandler):
def __init__(self, config: dict): def __init__(self, config: dict):
self.config = config super().__init__(config)
self.logger = setup_logging()
# 初始化认证工具 # 初始化认证工具
self.auth = AuthToken(config["server"]["auth_key"]) self.auth = AuthToken(config["server"]["auth_key"])
@@ -96,7 +96,7 @@ class VisionHandler:
current_config = copy.deepcopy(self.config) current_config = copy.deepcopy(self.config)
read_config_from_api = current_config.get("read_config_from_api", False) read_config_from_api = current_config.get("read_config_from_api", False)
if read_config_from_api: if read_config_from_api:
current_config = get_private_config_from_api( current_config = await get_private_config_from_api(
current_config, current_config,
device_id, device_id,
client_id, client_id,
@@ -172,11 +172,3 @@ class VisionHandler:
finally: finally:
self._add_cors_headers(response) self._add_cors_headers(response)
return 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"] = "*"
+141 -49
View File
@@ -68,8 +68,12 @@ class ConnectionHandler:
self.logger = setup_logging() self.logger = setup_logging()
self.server = server # 保存server实例的引用 self.server = server # 保存server实例的引用
self.need_bind = False self.need_bind = False # 是否需要绑定设备
self.bind_code = None self.bind_completed_event = asyncio.Event()
self.bind_code = None # 绑定设备的验证码
self.last_bind_prompt_time = 0 # 上次播放绑定提示的时间戳(秒)
self.bind_prompt_interval = 60 # 绑定提示播放间隔(秒)
self.read_config_from_api = self.config.get("read_config_from_api", False) self.read_config_from_api = self.config.get("read_config_from_api", False)
self.websocket = None self.websocket = None
@@ -88,7 +92,7 @@ class ConnectionHandler:
self.client_listen_mode = "auto" self.client_listen_mode = "auto"
# 线程任务相关 # 线程任务相关
self.loop = asyncio.get_event_loop() self.loop = None # 在 handle_connection 中获取运行中的事件循环
self.stop_event = threading.Event() self.stop_event = threading.Event()
self.executor = ThreadPoolExecutor(max_workers=5) self.executor = ThreadPoolExecutor(max_workers=5)
@@ -116,6 +120,7 @@ class ConnectionHandler:
self.client_audio_buffer = bytearray() self.client_audio_buffer = bytearray()
self.client_have_voice = False self.client_have_voice = False
self.client_voice_window = deque(maxlen=5) self.client_voice_window = deque(maxlen=5)
self.first_activity_time = 0.0 # 记录首次活动的时间(毫秒)
self.last_activity_time = 0.0 # 统一的活动时间戳(毫秒) self.last_activity_time = 0.0 # 统一的活动时间戳(毫秒)
self.client_voice_stop = False self.client_voice_stop = False
self.last_is_voice = False self.last_is_voice = False
@@ -158,10 +163,13 @@ class ConnectionHandler:
self.conn_from_mqtt_gateway = False self.conn_from_mqtt_gateway = False
# 初始化提示词管理器 # 初始化提示词管理器
self.prompt_manager = PromptManager(config, self.logger) self.prompt_manager = PromptManager(self.config, self.logger)
async def handle_connection(self, ws): async def handle_connection(self, ws):
try: try:
# 获取运行中的事件循环(必须在异步上下文中)
self.loop = asyncio.get_running_loop()
# 获取并验证headers # 获取并验证headers
self.headers = dict(ws.request.headers) self.headers = dict(ws.request.headers)
real_ip = self.headers.get("x-real-ip") or self.headers.get( real_ip = self.headers.get("x-real-ip") or self.headers.get(
@@ -187,6 +195,7 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).info("连接来自:MQTT网关") self.logger.bind(tag=TAG).info("连接来自:MQTT网关")
# 初始化活动时间戳 # 初始化活动时间戳
self.first_activity_time = time.time() * 1000
self.last_activity_time = time.time() * 1000 self.last_activity_time = time.time() * 1000
# 启动超时检查任务 # 启动超时检查任务
@@ -195,10 +204,8 @@ class ConnectionHandler:
self.welcome_msg = self.config["xiaozhi"] self.welcome_msg = self.config["xiaozhi"]
self.welcome_msg["session_id"] = self.session_id self.welcome_msg["session_id"] = self.session_id
# 获取差异化配置 # 在后台初始化配置和组件(完全不阻塞主循环)
self._initialize_private_config() asyncio.create_task(self._background_initialize())
# 异步初始化
self.executor.submit(self._initialize_components)
try: try:
async for message in self.websocket: async for message in self.websocket:
@@ -237,7 +244,9 @@ class ConnectionHandler:
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
loop.run_until_complete( 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: except Exception as e:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}") self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
@@ -260,8 +269,37 @@ class ConnectionHandler:
f"保存记忆后关闭连接失败: {close_error}" 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): 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): if isinstance(message, str):
await handleTextMessage(self, message) await handleTextMessage(self, message)
elif isinstance(message, bytes): elif isinstance(message, bytes):
@@ -391,6 +429,15 @@ class ConnectionHandler:
def _initialize_components(self): def _initialize_components(self):
try: 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.selected_module_str = build_module_string(
self.config.get("selected_module", {}) self.config.get("selected_module", {})
) )
@@ -414,17 +461,10 @@ class ConnectionHandler:
# 初始化声纹识别 # 初始化声纹识别
self._initialize_voiceprint() self._initialize_voiceprint()
# 打开语音识别通道 # 打开语音识别通道
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
self.asr.open_audio_channels(self), self.loop 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() self._initialize_memory()
@@ -439,6 +479,7 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}") self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}")
def _init_prompt_enhancement(self): def _init_prompt_enhancement(self):
# 更新上下文信息 # 更新上下文信息
self.prompt_manager.update_context_info(self, self.client_ip) self.prompt_manager.update_context_info(self, self.client_ip)
enhanced_prompt = self.prompt_manager.build_enhanced_prompt( enhanced_prompt = self.prompt_manager.build_enhanced_prompt(
@@ -474,7 +515,11 @@ class ConnectionHandler:
def _initialize_asr(self): def _initialize_asr(self):
"""初始化ASR""" """初始化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,可以被多个连接共享 # 因为本地一个实例ASR,可以被多个连接共享
asr = self._asr asr = self._asr
@@ -501,22 +546,35 @@ class ConnectionHandler:
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).warning(f"声纹识别初始化失败: {str(e)}") self.logger.bind(tag=TAG).warning(f"声纹识别初始化失败: {str(e)}")
def _initialize_private_config(self): async def _background_initialize(self):
"""如果是从配置文件获取,则进行二次实例化""" """在后台初始化配置和组件(完全不阻塞主循环)"""
try:
# 异步获取差异化配置
await self._initialize_private_config_async()
# 在线程池中初始化组件
self.executor.submit(self._initialize_components)
except Exception as e:
self.logger.bind(tag=TAG).error(f"后台初始化失败: {e}")
async def _initialize_private_config_async(self):
"""从接口异步获取差异化配置(异步版本,不阻塞主循环)"""
if not self.read_config_from_api: if not self.read_config_from_api:
self.need_bind = False
self.bind_completed_event.set()
return return
"""从接口获取差异化的配置进行二次实例化,非全量重新实例化"""
try: try:
begin_time = time.time() begin_time = time.time()
private_config = get_private_config_from_api( private_config = await get_private_config_from_api(
self.config, self.config,
self.headers.get("device-id"), self.headers.get("device-id"),
self.headers.get("client-id", self.headers.get("device-id")), self.headers.get("client-id", self.headers.get("device-id")),
) )
private_config["delete_audio"] = bool(self.config.get("delete_audio", True)) private_config["delete_audio"] = bool(self.config.get("delete_audio", True))
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).info(
f"{time.time() - begin_time} 秒,获取差异化配置成功: {json.dumps(filter_sensitive_info(private_config), ensure_ascii=False)}" 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: except DeviceNotFoundException as e:
self.need_bind = True self.need_bind = True
private_config = {} private_config = {}
@@ -526,7 +584,7 @@ class ConnectionHandler:
private_config = {} private_config = {}
except Exception as e: except Exception as e:
self.need_bind = True self.need_bind = True
self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}") self.logger.bind(tag=TAG).error(f"异步获取差异化配置失败: {e}")
private_config = {} private_config = {}
init_llm, init_tts, init_memory, init_intent = ( init_llm, init_tts, init_memory, init_intent = (
@@ -599,8 +657,14 @@ class ConnectionHandler:
self.chat_history_conf = int(private_config["chat_history_conf"]) self.chat_history_conf = int(private_config["chat_history_conf"])
if private_config.get("mcp_endpoint", None) is not None: if private_config.get("mcp_endpoint", None) is not None:
self.config["mcp_endpoint"] = private_config["mcp_endpoint"] 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: try:
modules = initialize_modules( modules = await self.loop.run_in_executor(
None, # 使用默认线程池
initialize_modules,
self.logger, self.logger,
private_config, private_config,
init_vad, init_vad,
@@ -744,18 +808,26 @@ class ConnectionHandler:
force_final_answer = False # 标记是否强制最终回答 force_final_answer = False # 标记是否强制最终回答
if depth >= MAX_DEPTH: if depth >= MAX_DEPTH:
self.logger.bind(tag=TAG).debug(f"已达到最大工具调用深度 {MAX_DEPTH},将强制基于现有信息回答") self.logger.bind(tag=TAG).debug(
f"已达到最大工具调用深度 {MAX_DEPTH},将强制基于现有信息回答"
)
force_final_answer = True force_final_answer = True
# 添加系统指令,要求 LLM 基于现有信息回答 # 添加系统指令,要求 LLM 基于现有信息回答
self.dialogue.put(Message( self.dialogue.put(
role="user", Message(
content="[系统提示] 已达到最大工具调用次数限制,请你基于目前已经获取的所有信息,直接给出最终答案。不要再尝试调用任何工具。" role="user",
)) content="[系统提示] 已达到最大工具调用次数限制,请你基于目前已经获取的所有信息,直接给出最终答案。不要再尝试调用任何工具。",
)
)
# Define intent functions # Define intent functions
functions = None functions = None
# 达到最大深度时,禁用工具调用,强制 LLM 直接回答 # 达到最大深度时,禁用工具调用,强制 LLM 直接回答
if self.intent_type == "function_call" and hasattr(self, "func_handler") and not force_final_answer: if (
self.intent_type == "function_call"
and hasattr(self, "func_handler")
and not force_final_answer
):
functions = self.func_handler.get_functions() functions = self.func_handler.get_functions()
response_message = [] response_message = []
@@ -844,11 +916,16 @@ class ConnectionHandler:
if a is not None: if a is not None:
try: try:
content_arguments_json = json.loads(a) content_arguments_json = json.loads(a)
tool_calls_list.append({ tool_calls_list.append(
"id": str(uuid.uuid4().hex), {
"name": content_arguments_json["name"], "id": str(uuid.uuid4().hex),
"arguments": json.dumps(content_arguments_json["arguments"], ensure_ascii=False) "name": content_arguments_json["name"],
}) "arguments": json.dumps(
content_arguments_json["arguments"],
ensure_ascii=False,
),
}
)
except Exception as e: except Exception as e:
bHasError = True bHasError = True
response_message.append(a) response_message.append(a)
@@ -880,7 +957,9 @@ class ConnectionHandler:
) )
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
self.func_handler.handle_llm_function_call(self, tool_call_data), self.func_handler.handle_llm_function_call(
self, tool_call_data
),
self.loop, self.loop,
) )
futures_with_data.append((future, tool_call_data)) futures_with_data.append((future, tool_call_data))
@@ -888,7 +967,7 @@ class ConnectionHandler:
# 等待协程结束(实际等待时长为最慢的那个) # 等待协程结束(实际等待时长为最慢的那个)
tool_results = [] tool_results = []
for future, tool_call_data in futures_with_data: for future, tool_call_data in futures_with_data:
result = future.result() result = future.result()
tool_results.append((result, tool_call_data)) tool_results.append((result, tool_call_data))
# 统一处理所有工具调用结果 # 统一处理所有工具调用结果
@@ -922,7 +1001,11 @@ class ConnectionHandler:
need_llm_tools = [] need_llm_tools = []
for result, tool_call_data in tool_results: for result, tool_call_data in tool_results:
if result.action in [Action.RESPONSE, Action.NOTFOUND, Action.ERROR]: # 直接回复前端 if result.action in [
Action.RESPONSE,
Action.NOTFOUND,
Action.ERROR,
]: # 直接回复前端
text = result.response if result.response else result.result text = result.response if result.response else result.result
self.tts.tts_one_sentence(self, ContentType.TEXT, content_detail=text) self.tts.tts_one_sentence(self, ContentType.TEXT, content_detail=text)
self.dialogue.put(Message(role="assistant", content=text)) self.dialogue.put(Message(role="assistant", content=text))
@@ -957,7 +1040,11 @@ class ConnectionHandler:
self.dialogue.put( self.dialogue.put(
Message( Message(
role="tool", role="tool",
tool_call_id=str(uuid.uuid4()) if tool_call_data["id"] is None else tool_call_data["id"], tool_call_id=(
str(uuid.uuid4())
if tool_call_data["id"] is None
else tool_call_data["id"]
),
content=text, content=text,
) )
) )
@@ -990,8 +1077,8 @@ class ConnectionHandler:
def _process_report(self, type, text, audio_data, report_time): def _process_report(self, type, text, audio_data, report_time):
"""处理上报任务""" """处理上报任务"""
try: try:
# 执行上报(传入二进制数据 # 执行异步上报(在事件循环中运行
report(self, type, text, audio_data, report_time) asyncio.run(report(self, type, text, audio_data, report_time))
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"上报处理异常: {e}") self.logger.bind(tag=TAG).error(f"上报处理异常: {e}")
finally: finally:
@@ -1082,7 +1169,6 @@ class ConnectionHandler:
f"关闭线程池时出错: {executor_error}" f"关闭线程池时出错: {executor_error}"
) )
self.executor = None self.executor = None
self.logger.bind(tag=TAG).info("连接资源已释放") self.logger.bind(tag=TAG).info("连接资源已释放")
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"关闭连接时出错: {e}") self.logger.bind(tag=TAG).error(f"关闭连接时出错: {e}")
@@ -1112,6 +1198,11 @@ class ConnectionHandler:
except queue.Empty: except queue.Empty:
break 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( self.logger.bind(tag=TAG).debug(
f"清理结束: TTS队列大小={self.tts.tts_text_queue.qsize()}, 音频队列大小={self.tts.tts_audio_queue.qsize()}" f"清理结束: TTS队列大小={self.tts.tts_text_queue.qsize()}, 音频队列大小={self.tts.tts_audio_queue.qsize()}"
) )
@@ -1137,13 +1228,14 @@ class ConnectionHandler:
"""检查连接超时""" """检查连接超时"""
try: try:
while not self.stop_event.is_set(): while not self.stop_event.is_set():
last_activity_time = self.last_activity_time
if self.need_bind:
last_activity_time = self.first_activity_time
# 检查是否超时(只有在时间戳已初始化的情况下) # 检查是否超时(只有在时间戳已初始化的情况下)
if self.last_activity_time > 0.0: if last_activity_time > 0.0:
current_time = time.time() * 1000 current_time = time.time() * 1000
if ( if current_time - last_activity_time > self.timeout_seconds * 1000:
current_time - self.last_activity_time
> self.timeout_seconds * 1000
):
if not self.stop_event.is_set(): if not self.stop_event.is_set():
self.logger.bind(tag=TAG).info("连接超时,准备关闭") self.logger.bind(tag=TAG).info("连接超时,准备关闭")
# 设置停止事件,防止重复处理 # 设置停止事件,防止重复处理
@@ -1171,7 +1263,7 @@ class ConnectionHandler:
tools_call: 新的工具调用 tools_call: 新的工具调用
""" """
for tool_call in tools_call: for tool_call in tools_call:
tool_index = getattr(tool_call, 'index', None) tool_index = getattr(tool_call, "index", None)
if tool_index is None: if tool_index is None:
if tool_call.function.name: if tool_call.function.name:
# 有 function_name,说明是新的工具调用 # 有 function_name,说明是新的工具调用
@@ -1189,4 +1281,4 @@ class ConnectionHandler:
if tool_call.function.name: if tool_call.function.name:
tool_calls_list[tool_index]["name"] = tool_call.function.name tool_calls_list[tool_index]["name"] = tool_call.function.name
if tool_call.function.arguments: if tool_call.function.arguments:
tool_calls_list[tool_index]["arguments"] += tool_call.function.arguments tool_calls_list[tool_index]["arguments"] += tool_call.function.arguments
@@ -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 conn.client_abort = False
@@ -123,7 +123,7 @@ async def max_out_size(conn):
text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!" text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!"
await send_stt_message(conn, text) await send_stt_message(conn, text)
file_path = "config/assets/max_output_size.wav" 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.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text))
conn.close_after_chat = True conn.close_after_chat = True
@@ -142,7 +142,7 @@ async def check_bind_device(conn):
# 播放提示音 # 播放提示音
music_path = "config/assets/bind_code.wav" 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)) conn.tts.tts_audio_queue.put((SentenceType.FIRST, opus_packets, text))
# 逐个播放数字 # 逐个播放数字
@@ -150,7 +150,7 @@ async def check_bind_device(conn):
try: try:
digit = conn.bind_code[i] digit = conn.bind_code[i]
num_path = f"config/assets/bind_code/{digit}.wav" 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)) conn.tts.tts_audio_queue.put((SentenceType.MIDDLE, num_packets, None))
except Exception as e: except Exception as e:
conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}") conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
@@ -162,5 +162,5 @@ async def check_bind_device(conn):
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。" text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
await send_stt_message(conn, text) await send_stt_message(conn, text)
music_path = "config/assets/bind_not_found.wav" 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)) conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text))
+41 -34
View File
@@ -10,7 +10,6 @@ TTS上报功能已集成到ConnectionHandler类中。
""" """
import time import time
import opuslib_next import opuslib_next
from config.manage_api_client import report as manage_report from config.manage_api_client import report as manage_report
@@ -18,7 +17,7 @@ from config.manage_api_client import report as manage_report
TAG = __name__ TAG = __name__
def report(conn, type, text, opus_data, report_time): async def report(conn, type, text, opus_data, report_time):
"""执行聊天记录上报操作 """执行聊天记录上报操作
Args: Args:
@@ -33,8 +32,8 @@ def report(conn, type, text, opus_data, report_time):
audio_data = opus_to_wav(conn, opus_data) audio_data = opus_to_wav(conn, opus_data)
else: else:
audio_data = None audio_data = None
# 执行上报 # 执行异步上报
manage_report( await manage_report(
mac_address=conn.device_id, mac_address=conn.device_id,
session_id=conn.session_id, session_id=conn.session_id,
chat_type=type, chat_type=type,
@@ -56,41 +55,49 @@ def opus_to_wav(conn, opus_data):
Returns: Returns:
bytes: WAV格式的音频数据 bytes: WAV格式的音频数据
""" """
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道 decoder = None
pcm_data = [] try:
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data: for opus_packet in opus_data:
try: try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame) pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e: except opuslib_next.OpusError as e:
conn.logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True) conn.logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
if not pcm_data: if not pcm_data:
raise ValueError("没有有效的PCM数据") raise ValueError("没有有效的PCM数据")
# 创建WAV文件头 # 创建WAV文件头
pcm_data_bytes = b"".join(pcm_data) pcm_data_bytes = b"".join(pcm_data)
num_samples = len(pcm_data_bytes) // 2 # 16-bit samples num_samples = len(pcm_data_bytes) // 2 # 16-bit samples
# WAV文件头 # WAV文件头
wav_header = bytearray() wav_header = bytearray()
wav_header.extend(b"RIFF") # ChunkID wav_header.extend(b"RIFF") # ChunkID
wav_header.extend((36 + len(pcm_data_bytes)).to_bytes(4, "little")) # ChunkSize wav_header.extend((36 + len(pcm_data_bytes)).to_bytes(4, "little")) # ChunkSize
wav_header.extend(b"WAVE") # Format wav_header.extend(b"WAVE") # Format
wav_header.extend(b"fmt ") # Subchunk1ID wav_header.extend(b"fmt ") # Subchunk1ID
wav_header.extend((16).to_bytes(4, "little")) # Subchunk1Size wav_header.extend((16).to_bytes(4, "little")) # Subchunk1Size
wav_header.extend((1).to_bytes(2, "little")) # AudioFormat (PCM) wav_header.extend((1).to_bytes(2, "little")) # AudioFormat (PCM)
wav_header.extend((1).to_bytes(2, "little")) # NumChannels wav_header.extend((1).to_bytes(2, "little")) # NumChannels
wav_header.extend((16000).to_bytes(4, "little")) # SampleRate wav_header.extend((16000).to_bytes(4, "little")) # SampleRate
wav_header.extend((32000).to_bytes(4, "little")) # ByteRate wav_header.extend((32000).to_bytes(4, "little")) # ByteRate
wav_header.extend((2).to_bytes(2, "little")) # BlockAlign wav_header.extend((2).to_bytes(2, "little")) # BlockAlign
wav_header.extend((16).to_bytes(2, "little")) # BitsPerSample wav_header.extend((16).to_bytes(2, "little")) # BitsPerSample
wav_header.extend(b"data") # Subchunk2ID wav_header.extend(b"data") # Subchunk2ID
wav_header.extend(len(pcm_data_bytes).to_bytes(4, "little")) # Subchunk2Size wav_header.extend(len(pcm_data_bytes).to_bytes(4, "little")) # Subchunk2Size
# 返回完整的WAV数据 # 返回完整的WAV数据
return bytes(wav_header) + pcm_data_bytes return bytes(wav_header) + pcm_data_bytes
finally:
if decoder is not None:
try:
del decoder
except Exception as e:
conn.logger.bind(tag=TAG).debug(f"释放decoder资源时出错: {e}")
def enqueue_tts_report(conn, text, opus_data): def enqueue_tts_report(conn, text, opus_data):
+159 -126
View File
@@ -4,8 +4,13 @@ import asyncio
from core.utils import textUtils from core.utils import textUtils
from core.utils.util import audio_to_data from core.utils.util import audio_to_data
from core.providers.tts.dto.dto import SentenceType from core.providers.tts.dto.dto import SentenceType
from core.utils.audioRateController import AudioRateController
TAG = __name__ TAG = __name__
# 音频帧时长(毫秒)
AUDIO_FRAME_DURATION = 60
# 预缓冲包数量,直接发送以减少延迟
PRE_BUFFER_COUNT = 5
async def sendAudioMessage(conn, sentenceType, audios, text): async def sendAudioMessage(conn, sentenceType, audios, text):
@@ -15,7 +20,19 @@ async def sendAudioMessage(conn, sentenceType, audios, text):
await send_tts_message(conn, "start", None) await send_tts_message(conn, "start", None)
if sentenceType == SentenceType.FIRST: 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) await sendAudio(conn, audios)
# 发送句子开始消息 # 发送句子开始消息
@@ -30,29 +47,27 @@ async def sendAudioMessage(conn, sentenceType, audios, text):
await conn.close() await conn.close()
def calculate_timestamp_and_sequence(conn, start_time, packet_index, frame_duration=60): async def _wait_for_audio_completion(conn):
""" """
计算音频数据包的时间戳和序列号 等待音频队列清空并等待预缓冲包播放完成
Args: Args:
conn: 连接对象 conn: 连接对象
start_time: 起始时间性能计数器值
packet_index: 数据包索引
frame_duration: 帧时长毫秒匹配 Opus 编码
Returns:
tuple: (timestamp, sequence)
""" """
# 计算时间戳(使用播放位置计算) if hasattr(conn, "audio_rate_controller") and conn.audio_rate_controller:
timestamp = int((start_time + packet_index * frame_duration / 1000) * 1000) % ( rate_controller = conn.audio_rate_controller
2**32 conn.logger.bind(tag=TAG).debug(
) f"等待音频发送完成,队列中还有 {len(rate_controller.queue)} 个包"
)
await rate_controller.queue_empty_event.wait()
# 计算序列号 # 等待预缓冲包播放完成
if hasattr(conn, "audio_flow_control"): # 前N个包直接发送,增加2个网络抖动包,需要额外等待它们在客户端播放完成
sequence = conn.audio_flow_control["sequence"] frame_duration_ms = rate_controller.frame_duration
else: pre_buffer_playback_time = (PRE_BUFFER_COUNT + 2) * frame_duration_ms / 1000.0
sequence = packet_index # 如果没有流控状态,直接使用索引 await asyncio.sleep(pre_buffer_playback_time)
return timestamp, sequence conn.logger.bind(tag=TAG).debug("音频发送完成")
async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence): async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence):
@@ -77,135 +92,151 @@ async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence):
await conn.websocket.send(complete_packet) await conn.websocket.send(complete_packet)
# 播放音频 async def sendAudio(conn, audios, frame_duration=AUDIO_FRAME_DURATION):
async def sendAudio(conn, audios, frame_duration=60):
""" """
发送单个opus包支持流控 发送音频包使用 AudioRateController 进行精确的流量控制
Args: Args:
conn: 连接对象 conn: 连接对象
opus_packet: 单个opus数据 audios: 单个opus包(bytes) opus包列表
pre_buffer: 快速发送音频 frame_duration: 帧时长毫秒默认使用全局常量AUDIO_FRAME_DURATION
frame_duration: 帧时长毫秒匹配 Opus 编码
""" """
if audios is None or len(audios) == 0: if audios is None or len(audios) == 0:
return return
# 获取发送延迟配置
send_delay = conn.config.get("tts_audio_send_delay", -1) / 1000.0 send_delay = conn.config.get("tts_audio_send_delay", -1) / 1000.0
is_single_packet = isinstance(audios, bytes)
if isinstance(audios, bytes): # 初始化或获取 RateController
# 重置流控状态,第一次读取和会话发生转变时 rate_controller, flow_control = _get_or_create_rate_controller(
if not hasattr(conn, "audio_flow_control") or conn.audio_flow_control.get("sentence_id") != conn.sentence_id: conn, frame_duration, is_single_packet
conn.audio_flow_control = { )
"last_send_time": 0,
"packet_count": 0,
"start_time": time.perf_counter(),
"sequence": 0, # 添加序列号
"sentence_id": conn.sentence_id,
}
# 统一转换为列表处理
audio_list = [audios] if is_single_packet else audios
# 发送音频包
await _send_audio_with_rate_control(
conn, audio_list, rate_controller, flow_control, send_delay
)
def _get_or_create_rate_controller(conn, frame_duration, is_single_packet):
"""
获取或创建 RateController flow_control
Args:
conn: 连接对象
frame_duration: 帧时长
is_single_packet: 是否单包模式True: TTS流式单包, False: 批量包
Returns:
(rate_controller, flow_control)
"""
# 判断是否需要重置:单包模式且 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,
}
# 启动后台发送循环
_start_background_sender(
conn, conn.audio_rate_controller, conn.audio_flow_control
)
return conn.audio_rate_controller, conn.audio_flow_control
def _start_background_sender(conn, rate_controller, flow_control):
"""
启动后台发送循环任务
Args:
conn: 连接对象
rate_controller: 速率控制器
flow_control: 流控状态
"""
async def send_callback(packet):
# 检查是否应该中止
if conn.client_abort:
raise asyncio.CancelledError("客户端已中止")
conn.last_activity_time = time.time() * 1000
await _do_send_audio(conn, packet, flow_control)
conn.client_is_speaking = True
# 使用 start_sending 启动后台循环
rate_controller.start_sending(send_callback)
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: if conn.client_abort:
return return
conn.last_activity_time = time.time() * 1000 conn.last_activity_time = time.time() * 1000
# 预缓冲:前5个包直接发送,不做延迟 # 预缓冲:前N个包直接发送
pre_buffer_count = 5 if flow_control["packet_count"] < PRE_BUFFER_COUNT:
flow_control = conn.audio_flow_control await _do_send_audio(conn, packet, flow_control)
current_time = time.perf_counter() conn.client_is_speaking = True
if flow_control["packet_count"] < pre_buffer_count:
# 预缓冲阶段,直接发送不延迟
pass
elif send_delay > 0: elif send_delay > 0:
# 使用固定延迟 # 固定延迟模式
await asyncio.sleep(send_delay) await asyncio.sleep(send_delay)
await _do_send_audio(conn, packet, flow_control)
conn.client_is_speaking = True
else: else:
effective_packet = flow_control["packet_count"] - pre_buffer_count # 动态流控模式:仅添加到队列,由后台循环负责发送
expected_time = flow_control["start_time"] + ( rate_controller.add_audio(packet)
effective_packet * frame_duration / 1000
)
delay = expected_time - current_time
if delay > 0:
await asyncio.sleep(delay)
else:
# 纠正误差
flow_control["start_time"] += abs(delay)
if conn.conn_from_mqtt_gateway:
# 计算时间戳和序列号
timestamp, sequence = calculate_timestamp_and_sequence(
conn,
flow_control["start_time"],
flow_control["packet_count"],
frame_duration,
)
# 调用通用函数发送带头部的数据包
await _send_to_mqtt_gateway(conn, audios, timestamp, sequence)
else:
# 直接发送opus数据包,不添加头部
await conn.websocket.send(audios)
conn.client_is_speaking = True
# 更新流控状态 async def _do_send_audio(conn, opus_packet, flow_control):
flow_control["packet_count"] += 1 """
flow_control["sequence"] += 1 执行实际的音频发送
flow_control["last_send_time"] = time.perf_counter() """
packet_index = flow_control.get("packet_count", 0)
sequence = flow_control.get("sequence", 0)
if conn.conn_from_mqtt_gateway:
# 计算时间戳(基于播放位置)
start_time = time.time()
timestamp = int(start_time * 1000) % (2**32)
await _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence)
else: else:
# 文件型音频走普通播放 # 直接发送opus数据包
start_time = time.perf_counter() await conn.websocket.send(opus_packet)
play_position = 0
# 执行预缓冲 # 更新流控状态
pre_buffer_frames = min(5, len(audios)) flow_control["packet_count"] = packet_index + 1
for i in range(pre_buffer_frames): flow_control["sequence"] = sequence + 1
if conn.conn_from_mqtt_gateway:
# 计算时间戳和序列号
timestamp, sequence = calculate_timestamp_and_sequence(
conn, start_time, i, frame_duration
)
# 调用通用函数发送带头部的数据包
await _send_to_mqtt_gateway(conn, audios[i], timestamp, sequence)
else:
# 直接发送预缓冲包,不添加头部
await conn.websocket.send(audios[i])
conn.client_is_speaking = True
remaining_audios = audios[pre_buffer_frames:]
# 播放剩余音频帧
for i, opus_packet in enumerate(remaining_audios):
if conn.client_abort:
break
# 重置没有声音的状态
conn.last_activity_time = time.time() * 1000
if send_delay > 0:
# 固定延迟模式
await asyncio.sleep(send_delay)
else:
# 计算预期发送时间
expected_time = start_time + (play_position / 1000)
current_time = time.perf_counter()
delay = expected_time - current_time
if delay > 0:
await asyncio.sleep(delay)
if conn.conn_from_mqtt_gateway:
# 计算时间戳和序列号(使用当前的数据包索引确保连续性)
packet_index = pre_buffer_frames + i
timestamp, sequence = calculate_timestamp_and_sequence(
conn, start_time, packet_index, frame_duration
)
# 调用通用函数发送带头部的数据包
await _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence)
else:
# 直接发送opus数据包,不添加头部
await conn.websocket.send(opus_packet)
conn.client_is_speaking = True
play_position += frame_duration
async def send_tts_message(conn, state, text=None): async def send_tts_message(conn, state, text=None):
@@ -224,8 +255,10 @@ async def send_tts_message(conn, state, text=None):
stop_tts_notify_voice = conn.config.get( stop_tts_notify_voice = conn.config.get(
"stop_tts_notify_voice", "config/assets/tts_notify.mp3" "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 sendAudio(conn, audios)
# 等待所有音频包发送完成
await _wait_for_audio_completion(conn)
# 清除服务端讲话状态 # 清除服务端讲话状态
conn.clearSpeakStatus() conn.clearSpeakStatus()
@@ -1,12 +1,14 @@
import time import time
import asyncio
from typing import Dict, Any from typing import Dict, Any
from core.handle.receiveAudioHandle import handleAudioMessage, startToChat from core.handle.receiveAudioHandle import startToChat
from core.handle.reportHandle import enqueue_asr_report from core.handle.reportHandle import enqueue_asr_report
from core.handle.sendAudioHandle import send_stt_message, send_tts_message from core.handle.sendAudioHandle import send_stt_message, send_tts_message
from core.handle.textMessageHandler import TextMessageHandler from core.handle.textMessageHandler import TextMessageHandler
from core.handle.textMessageType import TextMessageType from core.handle.textMessageType import TextMessageType
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length
from core.providers.asr.dto.dto import InterfaceType
TAG = __name__ TAG = __name__
@@ -29,8 +31,18 @@ class ListenTextMessageHandler(TextMessageHandler):
elif msg_json["state"] == "stop": elif msg_json["state"] == "stop":
conn.client_have_voice = True conn.client_have_voice = True
conn.client_voice_stop = True conn.client_voice_stop = True
if len(conn.asr_audio) > 0: if conn.asr.interface_type == InterfaceType.STREAM:
await handleAudioMessage(conn, b"") # 流式模式下,发送结束请求
asyncio.create_task(conn.asr._send_stop_request())
else:
# 非流式模式:直接触发ASR识别
if len(conn.asr_audio) > 0:
asr_audio_task = conn.asr_audio.copy()
conn.asr_audio.clear()
conn.reset_vad_states()
if len(asr_audio_task) > 0:
await conn.asr.handle_voice_stop(conn, asr_audio_task)
elif msg_json["state"] == "detect": elif msg_json["state"] == "detect":
conn.client_have_voice = False conn.client_have_voice = False
conn.asr_audio.clear() conn.asr_audio.clear()
@@ -57,6 +69,7 @@ class ListenTextMessageHandler(TextMessageHandler):
enqueue_asr_report(conn, "嘿,你好呀", []) enqueue_asr_report(conn, "嘿,你好呀", [])
await startToChat(conn, "嘿,你好呀") await startToChat(conn, "嘿,你好呀")
else: else:
conn.just_woken_up = True
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据) # 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_asr_report(conn, original_text, []) enqueue_asr_report(conn, original_text, [])
# 否则需要LLM对文字内容进行答复 # 否则需要LLM对文字内容进行答复
@@ -0,0 +1,45 @@
import json
import time
from typing import Dict, Any
from core.handle.textMessageHandler import TextMessageHandler
from core.handle.textMessageType import TextMessageType
TAG = __name__
class PingMessageHandler(TextMessageHandler):
"""Ping消息处理器,用于保持WebSocket连接"""
@property
def message_type(self) -> TextMessageType:
return TextMessageType.PING
async def handle(self, conn, msg_json: Dict[str, Any]) -> None:
"""
处理PING消息发送PONG响应
消息格式{"type": "ping"}
Args:
conn: WebSocket连接对象
msg_json: PING消息的JSON数据
"""
# 检查是否启用了WebSocket心跳功能
enable_websocket_ping = conn.config.get("enable_websocket_ping", False)
if not enable_websocket_ping:
conn.logger.debug(f"WebSocket心跳功能未启用,忽略PING消息")
return
try:
conn.logger.debug(f"收到PING消息,发送PONG响应")
conn.last_activity_time = time.time() * 1000
# 构造PONG响应消息
pong_message = {
"type": "pong",
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
}
# 发送PONG响应
await conn.websocket.send(json.dumps(pong_message))
except Exception as e:
conn.logger.error(f"处理PING消息时发生错误: {e}")
@@ -7,6 +7,7 @@ from core.handle.textHandler.listenMessageHandler import ListenTextMessageHandle
from core.handle.textHandler.mcpMessageHandler import McpTextMessageHandler from core.handle.textHandler.mcpMessageHandler import McpTextMessageHandler
from core.handle.textMessageHandler import TextMessageHandler from core.handle.textMessageHandler import TextMessageHandler
from core.handle.textHandler.serverMessageHandler import ServerTextMessageHandler from core.handle.textHandler.serverMessageHandler import ServerTextMessageHandler
from core.handle.textHandler.pingMessageHandler import PingMessageHandler
TAG = __name__ TAG = __name__
@@ -27,6 +28,7 @@ class TextMessageHandlerRegistry:
IotTextMessageHandler(), IotTextMessageHandler(),
McpTextMessageHandler(), McpTextMessageHandler(),
ServerTextMessageHandler(), ServerTextMessageHandler(),
PingMessageHandler(),
] ]
for handler in handlers: for handler in handlers:
@@ -9,3 +9,4 @@ class TextMessageType(Enum):
IOT = "iot" IOT = "iot"
MCP = "mcp" MCP = "mcp"
SERVER = "server" SERVER = "server"
PING = "ping"
+49 -27
View File
@@ -33,38 +33,60 @@ class SimpleHttpServer:
return f"ws://{local_ip}:{port}/xiaozhi/v1/" return f"ws://{local_ip}:{port}/xiaozhi/v1/"
async def start(self): async def start(self):
server_config = self.config["server"] try:
read_config_from_api = self.config.get("read_config_from_api", False) server_config = self.config["server"]
host = server_config.get("ip", "0.0.0.0") read_config_from_api = self.config.get("read_config_from_api", False)
port = int(server_config.get("http_port", 8003)) host = server_config.get("ip", "0.0.0.0")
port = int(server_config.get("http_port", 8003))
if port: if port:
app = web.Application() app = web.Application()
if not read_config_from_api: if not read_config_from_api:
# 如果没有开启智控台,只是单模块运行,就需要再添加简单OTA接口,用于下发websocket接口 # 如果没有开启智控台,只是单模块运行,就需要再添加简单OTA接口,用于下发websocket接口
app.add_routes(
[
web.get("/xiaozhi/ota/", self.ota_handler.handle_get),
web.post("/xiaozhi/ota/", self.ota_handler.handle_post),
web.options(
"/xiaozhi/ota/", self.ota_handler.handle_options
),
# 下载接口,仅提供 data/bin/*.bin 下载
web.get(
"/xiaozhi/ota/download/{filename}",
self.ota_handler.handle_download,
),
web.options(
"/xiaozhi/ota/download/{filename}",
self.ota_handler.handle_options,
),
]
)
# 添加路由
app.add_routes( app.add_routes(
[ [
web.get("/xiaozhi/ota/", self.ota_handler.handle_get), web.get("/mcp/vision/explain", self.vision_handler.handle_get),
web.post("/xiaozhi/ota/", self.ota_handler.handle_post), web.post(
web.options("/xiaozhi/ota/", self.ota_handler.handle_post), "/mcp/vision/explain", self.vision_handler.handle_post
),
web.options(
"/mcp/vision/explain", self.vision_handler.handle_options
),
] ]
) )
# 添加路由
app.add_routes(
[
web.get("/mcp/vision/explain", self.vision_handler.handle_get),
web.post("/mcp/vision/explain", self.vision_handler.handle_post),
web.options("/mcp/vision/explain", self.vision_handler.handle_post),
]
)
# 运行服务 # 运行服务
runner = web.AppRunner(app) runner = web.AppRunner(app)
await runner.setup() await runner.setup()
site = web.TCPSite(runner, host, port) site = web.TCPSite(runner, host, port)
await site.start() await site.start()
# 保持服务运行 # 保持服务运行
while True: while True:
await asyncio.sleep(3600) # 每隔 1 小时检查一次 await asyncio.sleep(3600) # 每隔 1 小时检查一次
except Exception as e:
self.logger.bind(tag=TAG).error(f"HTTP服务器启动失败: {e}")
import traceback
self.logger.bind(tag=TAG).error(f"错误堆栈: {traceback.format_exc()}")
raise
@@ -8,8 +8,6 @@ import asyncio
import requests import requests
import websockets import websockets
import opuslib_next import opuslib_next
import random
from typing import Optional, Tuple, List
from urllib import parse from urllib import parse
from datetime import datetime from datetime import datetime
from config.logger import setup_logging from config.logger import setup_logging
@@ -139,13 +137,13 @@ class ASRProvider(ASRProviderBase):
conn.asr_audio.append(audio) conn.asr_audio.append(audio)
conn.asr_audio = conn.asr_audio[-10:] conn.asr_audio = conn.asr_audio[-10:]
# 只在有声音且没有连接时建立连接 # 只在有声音且没有连接时建立连接(排除正在停止的情况)
if audio_have_voice and not self.is_processing: if audio_have_voice and not self.is_processing and not self.asr_ws:
try: try:
await self._start_recognition(conn) await self._start_recognition(conn)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"开始识别失败: {str(e)}") logger.bind(tag=TAG).error(f"开始识别失败: {str(e)}")
await self._cleanup(conn) await self._cleanup()
return return
if self.asr_ws and self.is_processing and self.server_ready: if self.asr_ws and self.is_processing and self.server_ready:
@@ -185,10 +183,8 @@ class ASRProvider(ASRProviderBase):
"header": { "header": {
"namespace": "SpeechTranscriber", "namespace": "SpeechTranscriber",
"name": "StartTranscription", "name": "StartTranscription",
"status": 20000000,
"message_id": uuid.uuid4().hex, "message_id": uuid.uuid4().hex,
"task_id": self.task_id, "task_id": self.task_id,
"status_text": "Gateway:SUCCESS:Success.",
"appkey": self.appkey "appkey": self.appkey
}, },
"payload": { "payload": {
@@ -207,18 +203,21 @@ class ASRProvider(ASRProviderBase):
async def _forward_results(self, conn): async def _forward_results(self, conn):
"""转发识别结果""" """转发识别结果"""
try: try:
while self.asr_ws and not conn.stop_event.is_set(): while not conn.stop_event.is_set():
try: try:
response = await asyncio.wait_for(self.asr_ws.recv(), timeout=1.0) response = await asyncio.wait_for(self.asr_ws.recv(), timeout=1.0)
result = json.loads(response) result = json.loads(response)
header = result.get("header", {}) header = result.get("header", {})
payload = result.get("payload", {}) payload = result.get("payload", {})
message_name = header.get("name", "") message_name = header.get("name", "")
status = header.get("status", 0) status = header.get("status", 0)
if status != 20000000: if status != 20000000:
if status in [40000004, 40010004]: # 连接超时或客户端断开 if status == 40010004:
logger.bind(tag=TAG).warning(f"请在服务端响应完成后再关闭链接,状态码: {status}")
break
if status in [40000004, 40010003]: # 连接超时或客户端断开
logger.bind(tag=TAG).warning(f"连接问题,状态码: {status}") logger.bind(tag=TAG).warning(f"连接问题,状态码: {status}")
break break
elif status in [40270002, 40270003]: # 音频问题 elif status in [40270002, 40270003]: # 音频问题
@@ -227,12 +226,12 @@ class ASRProvider(ASRProviderBase):
else: else:
logger.bind(tag=TAG).error(f"识别错误,状态码: {status}, 消息: {header.get('status_text', '')}") logger.bind(tag=TAG).error(f"识别错误,状态码: {status}, 消息: {header.get('status_text', '')}")
continue continue
# 收到TranscriptionStarted表示服务器准备好接收音频数据 # 收到TranscriptionStarted表示服务器准备好接收音频数据
if message_name == "TranscriptionStarted": if message_name == "TranscriptionStarted":
self.server_ready = True self.server_ready = True
logger.bind(tag=TAG).debug("服务器已准备,开始发送缓存音频...") logger.bind(tag=TAG).debug("服务器已准备,开始发送缓存音频...")
# 发送缓存音频 # 发送缓存音频
if conn.asr_audio: if conn.asr_audio:
for cached_audio in conn.asr_audio[-10:]: for cached_audio in conn.asr_audio[-10:]:
@@ -243,89 +242,89 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}") logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}")
break break
continue continue
if message_name == "TranscriptionResultChanged":
# 中间结果
text = payload.get("result", "")
if text:
self.text = text
elif message_name == "SentenceEnd": elif message_name == "SentenceEnd":
# 最终结果 # 句子结束(每个句子都会触发)
text = payload.get("result", "") text = payload.get("result", "")
if text: if text:
self.text = text logger.bind(tag=TAG).info(f"识别到文本: {text}")
conn.reset_vad_states()
# 传递缓存的音频数据 # 手动模式下累积识别结果
audio_data = getattr(conn, 'asr_audio_for_voiceprint', []) if conn.client_listen_mode == "manual":
await self.handle_voice_stop(conn, audio_data) if self.text:
# 清空缓存 self.text += text
conn.asr_audio_for_voiceprint = [] else:
break self.text = text
elif message_name == "TranscriptionCompleted":
# 识别完成 # 手动模式下,只有在收到stop信号后才触发处理(仅处理一次)
self.is_processing = False if conn.client_voice_stop:
break audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
if len(audio_data) > 0:
logger.bind(tag=TAG).debug("收到最终识别结果,触发处理")
await self.handle_voice_stop(conn, audio_data)
# 清理音频缓存
conn.asr_audio.clear()
conn.reset_vad_states()
break
else:
# 自动模式下直接覆盖
self.text = text
conn.reset_vad_states()
audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
await self.handle_voice_stop(conn, audio_data)
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue logger.bind(tag=TAG).error("接收结果超时")
except websockets.exceptions.ConnectionClosed: break
except websockets.ConnectionClosed:
logger.bind(tag=TAG).info("ASR服务连接已关闭")
self.is_processing = False
break break
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"处理结果失败: {str(e)}") logger.bind(tag=TAG).error(f"处理结果失败: {str(e)}")
break break
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"结果转发失败: {str(e)}") logger.bind(tag=TAG).error(f"结果转发失败: {str(e)}")
finally: finally:
await self._cleanup(conn) # 清理连接的音频缓存
await self._cleanup()
if conn:
if hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'):
conn.asr_audio = []
async def _cleanup(self, conn): async def _send_stop_request(self):
"""清理资源""" """发送停止识别请求(不关闭连接)"""
logger.bind(tag=TAG).debug(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}") if self.asr_ws:
# 清理连接的音频缓存
if conn and hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
# 判断是否需要发送终止请求
should_stop = self.is_processing or self.server_ready
# 发送停止识别请求
if self.asr_ws and should_stop:
try: try:
# 先停止音频发送
self.is_processing = False
stop_msg = { stop_msg = {
"header": { "header": {
"namespace": "SpeechTranscriber", "namespace": "SpeechTranscriber",
"name": "StopTranscription", "name": "StopTranscription",
"status": 20000000,
"message_id": uuid.uuid4().hex, "message_id": uuid.uuid4().hex,
"task_id": self.task_id, "task_id": self.task_id,
"status_text": "Client:Stop",
"appkey": self.appkey "appkey": self.appkey
} }
} }
logger.bind(tag=TAG).debug("正在发送ASR终止请求") logger.bind(tag=TAG).debug("停止识别请求已发送")
await self.asr_ws.send(json.dumps(stop_msg, ensure_ascii=False)) await self.asr_ws.send(json.dumps(stop_msg, ensure_ascii=False))
await asyncio.sleep(0.1)
logger.bind(tag=TAG).debug("ASR终止请求已发送")
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"ASR终止请求发送失败: {e}") logger.bind(tag=TAG).error(f"发送停止识别请求失败: {e}")
# 状态重置(在终止请求发送后) async def _cleanup(self):
"""清理资源(关闭连接)"""
logger.bind(tag=TAG).debug(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}")
# 状态重置
self.is_processing = False self.is_processing = False
self.server_ready = False self.server_ready = False
logger.bind(tag=TAG).debug("ASR状态已重置") logger.bind(tag=TAG).debug("ASR状态已重置")
# 清理任务
if self.forward_task and not self.forward_task.done():
self.forward_task.cancel()
try:
await asyncio.wait_for(self.forward_task, timeout=1.0)
except Exception as e:
logger.bind(tag=TAG).debug(f"forward_task取消异常: {e}")
finally:
self.forward_task = None
# 关闭连接 # 关闭连接
if self.asr_ws: if self.asr_ws:
try: try:
@@ -336,7 +335,10 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).error(f"关闭WebSocket连接失败: {e}") logger.bind(tag=TAG).error(f"关闭WebSocket连接失败: {e}")
finally: finally:
self.asr_ws = None self.asr_ws = None
# 清理任务引用
self.forward_task = None
logger.bind(tag=TAG).debug("ASR会话清理完成") logger.bind(tag=TAG).debug("ASR会话清理完成")
async def speech_to_text(self, opus_data, session_id, audio_format): async def speech_to_text(self, opus_data, session_id, audio_format):
@@ -347,4 +349,11 @@ class ASRProvider(ASRProviderBase):
async def close(self): async def close(self):
"""关闭资源""" """关闭资源"""
await self._cleanup() await self._cleanup(None)
if hasattr(self, 'decoder') and self.decoder is not None:
try:
del self.decoder
self.decoder = None
logger.bind(tag=TAG).debug("Aliyun decoder resources released")
except Exception as e:
logger.bind(tag=TAG).debug(f"释放Aliyun decoder资源时出错: {e}")
+57 -83
View File
@@ -9,7 +9,6 @@ import asyncio
import traceback import traceback
import threading import threading
import opuslib_next import opuslib_next
import concurrent.futures
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from config.logger import setup_logging from config.logger import setup_logging
from typing import Optional, Tuple, List from typing import Optional, Tuple, List
@@ -53,121 +52,89 @@ class ASRProviderBase(ABC):
# 接收音频 # 接收音频
async def receive_audio(self, conn, audio, audio_have_voice): async def receive_audio(self, conn, audio, audio_have_voice):
if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime": if conn.client_listen_mode == "manual":
have_voice = audio_have_voice # 手动模式:缓存音频用于ASR识别
conn.asr_audio.append(audio)
else: else:
have_voice = conn.client_have_voice # 自动/实时模式:使用VAD检测
have_voice = audio_have_voice
conn.asr_audio.append(audio)
if not have_voice and not conn.client_have_voice:
conn.asr_audio = conn.asr_audio[-10:]
return
if conn.client_voice_stop: conn.asr_audio.append(audio)
asr_audio_task = conn.asr_audio.copy() if not have_voice and not conn.client_have_voice:
conn.asr_audio.clear() conn.asr_audio = conn.asr_audio[-10:]
conn.reset_vad_states() return
if len(asr_audio_task) > 15: # 自动模式下通过VAD检测到语音停止时触发识别
await self.handle_voice_stop(conn, asr_audio_task) if conn.client_voice_stop:
asr_audio_task = conn.asr_audio.copy()
conn.asr_audio.clear()
conn.reset_vad_states()
if len(asr_audio_task) > 15:
await self.handle_voice_stop(conn, asr_audio_task)
# 处理语音停止 # 处理语音停止
async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]): async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]):
"""并行处理ASR和声纹识别""" """并行处理ASR和声纹识别"""
try: try:
total_start_time = time.monotonic() total_start_time = time.monotonic()
# 准备音频数据 # 准备音频数据
if conn.audio_format == "pcm": if conn.audio_format == "pcm":
pcm_data = asr_audio_task pcm_data = asr_audio_task
else: else:
pcm_data = self.decode_opus(asr_audio_task) pcm_data = self.decode_opus(asr_audio_task)
combined_pcm_data = b"".join(pcm_data) combined_pcm_data = b"".join(pcm_data)
# 预先准备WAV数据 # 预先准备WAV数据
wav_data = None wav_data = None
if conn.voiceprint_provider and combined_pcm_data: if conn.voiceprint_provider and combined_pcm_data:
wav_data = self._pcm_to_wav(combined_pcm_data) wav_data = self._pcm_to_wav(combined_pcm_data)
# 定义ASR任务 # 定义ASR任务
def run_asr(): asr_task = self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format)
start_time = time.monotonic()
try: if conn.voiceprint_provider and wav_data:
loop = asyncio.new_event_loop() voiceprint_task = conn.voiceprint_provider.identify_speaker(wav_data, conn.session_id)
asyncio.set_event_loop(loop) # 并发等待两个结果
try: asr_result, voiceprint_result = await asyncio.gather(
result = loop.run_until_complete( asr_task, voiceprint_task, return_exceptions=True
self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format) )
) else:
end_time = time.monotonic() asr_result = await asr_task
logger.bind(tag=TAG).debug(f"ASR耗时: {end_time - start_time:.3f}s") voiceprint_result = None
return result
finally: # 记录识别结果 - 检查是否为异常
loop.close() if isinstance(asr_result, Exception):
except Exception as e: logger.bind(tag=TAG).error(f"ASR识别失败: {asr_result}")
end_time = time.monotonic() raw_text = ""
logger.bind(tag=TAG).error(f"ASR失败: {e}") else:
return ("", None) raw_text, _ = asr_result
# 定义声纹识别任务 if isinstance(voiceprint_result, Exception):
def run_voiceprint(): logger.bind(tag=TAG).error(f"声纹识别失败: {voiceprint_result}")
if not wav_data: speaker_name = ""
return None else:
try: speaker_name = voiceprint_result
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# 使用连接的声纹识别提供者
result = loop.run_until_complete(
conn.voiceprint_provider.identify_speaker(wav_data, conn.session_id)
)
return result
finally:
loop.close()
except Exception as e:
logger.bind(tag=TAG).error(f"声纹识别失败: {e}")
return None
# 使用线程池执行器并行运行
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as thread_executor:
asr_future = thread_executor.submit(run_asr)
if conn.voiceprint_provider and wav_data:
voiceprint_future = thread_executor.submit(run_voiceprint)
# 等待两个线程都完成
asr_result = asr_future.result(timeout=15)
voiceprint_result = voiceprint_future.result(timeout=15)
results = {"asr": asr_result, "voiceprint": voiceprint_result}
else:
asr_result = asr_future.result(timeout=15)
results = {"asr": asr_result, "voiceprint": None}
# 处理结果
raw_text, _ = results.get("asr", ("", None))
speaker_name = results.get("voiceprint", None)
# 记录识别结果
if raw_text: if raw_text:
logger.bind(tag=TAG).info(f"识别文本: {raw_text}") logger.bind(tag=TAG).info(f"识别文本: {raw_text}")
if speaker_name: if speaker_name:
logger.bind(tag=TAG).info(f"识别说话人: {speaker_name}") logger.bind(tag=TAG).info(f"识别说话人: {speaker_name}")
# 性能监控 # 性能监控
total_time = time.monotonic() - total_start_time total_time = time.monotonic() - total_start_time
logger.bind(tag=TAG).debug(f"总处理耗时: {total_time:.3f}s") logger.bind(tag=TAG).debug(f"总处理耗时: {total_time:.3f}s")
# 检查文本长度 # 检查文本长度
text_len, _ = remove_punctuation_and_length(raw_text) text_len, _ = remove_punctuation_and_length(raw_text)
self.stop_ws_connection() self.stop_ws_connection()
if text_len > 0: if text_len > 0:
# 构建包含说话人信息的JSON字符串 # 构建包含说话人信息的JSON字符串
enhanced_text = self._build_enhanced_text(raw_text, speaker_name) enhanced_text = self._build_enhanced_text(raw_text, speaker_name)
# 使用自定义模块进行上报 # 使用自定义模块进行上报
await startToChat(conn, enhanced_text) await startToChat(conn, enhanced_text)
enqueue_asr_report(conn, enhanced_text, asr_audio_task) enqueue_asr_report(conn, enhanced_text, asr_audio_task)
@@ -241,6 +208,7 @@ class ASRProviderBase(ABC):
@staticmethod @staticmethod
def decode_opus(opus_data: List[bytes]) -> List[bytes]: def decode_opus(opus_data: List[bytes]) -> List[bytes]:
"""将Opus音频数据解码为PCM数据""" """将Opus音频数据解码为PCM数据"""
decoder = None
try: try:
decoder = opuslib_next.Decoder(16000, 1) decoder = opuslib_next.Decoder(16000, 1)
pcm_data = [] pcm_data = []
@@ -265,3 +233,9 @@ class ASRProviderBase(ABC):
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"音频解码过程发生错误: {e}") logger.bind(tag=TAG).error(f"音频解码过程发生错误: {e}")
return [] return []
finally:
if decoder is not None:
try:
del decoder
except Exception as e:
logger.bind(tag=TAG).debug(f"释放decoder资源时出错: {e}")
@@ -18,8 +18,6 @@ class ASRProvider(ASRProviderBase):
self.interface_type = InterfaceType.STREAM self.interface_type = InterfaceType.STREAM
self.config = config self.config = config
self.text = "" self.text = ""
self.max_retries = 3
self.retry_delay = 2
self.decoder = opuslib_next.Decoder(16000, 1) self.decoder = opuslib_next.Decoder(16000, 1)
self.asr_ws = None self.asr_ws = None
self.forward_task = None self.forward_task = None
@@ -49,6 +47,8 @@ class ASRProvider(ASRProviderBase):
self.channel = config.get("channel", 1) self.channel = config.get("channel", 1)
self.auth_method = config.get("auth_method", "token") self.auth_method = config.get("auth_method", "token")
self.secret = config.get("secret", "access_secret") self.secret = config.get("secret", "access_secret")
end_window_size = config.get("end_window_size")
self.end_window_size = int(end_window_size) if end_window_size else 200
async def open_audio_channels(self, conn): async def open_audio_channels(self, conn):
await super().open_audio_channels(conn) await super().open_audio_channels(conn)
@@ -56,14 +56,13 @@ class ASRProvider(ASRProviderBase):
async def receive_audio(self, conn, audio, audio_have_voice): async def receive_audio(self, conn, audio, audio_have_voice):
conn.asr_audio.append(audio) conn.asr_audio.append(audio)
conn.asr_audio = conn.asr_audio[-10:] conn.asr_audio = conn.asr_audio[-10:]
# 存储音频数据 # 存储音频数据
if not hasattr(conn, 'asr_audio_for_voiceprint'): if not hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = [] conn.asr_audio_for_voiceprint = []
conn.asr_audio_for_voiceprint.append(audio) conn.asr_audio_for_voiceprint.append(audio)
# 当没有音频数据时处理完整语音片段 # 当没有音频数据时处理完整语音片段
if not audio and len(conn.asr_audio_for_voiceprint) > 0: if conn.client_listen_mode != "manual" and not audio and len(conn.asr_audio_for_voiceprint) > 0:
await self.handle_voice_stop(conn, conn.asr_audio_for_voiceprint) await self.handle_voice_stop(conn, conn.asr_audio_for_voiceprint)
conn.asr_audio_for_voiceprint = [] conn.asr_audio_for_voiceprint = []
@@ -179,6 +178,7 @@ class ASRProvider(ASRProviderBase):
payload.get("audio_info", {}).get("duration", 0) > 2000 payload.get("audio_info", {}).get("duration", 0) > 2000
and not utterances and not utterances
and not payload["result"].get("text") and not payload["result"].get("text")
and conn.client_listen_mode != "manual"
): ):
logger.bind(tag=TAG).error(f"识别文本:空") logger.bind(tag=TAG).error(f"识别文本:空")
self.text = "" self.text = ""
@@ -187,15 +187,44 @@ class ASRProvider(ASRProviderBase):
await self.handle_voice_stop(conn, audio_data) await self.handle_voice_stop(conn, audio_data)
break break
# 专门处理没有文本的识别结果(手动模式下可能已经识别完成但是没松按键)
elif not payload["result"].get("text") and not utterances:
if conn.client_listen_mode == "manual" and conn.client_voice_stop and len(audio_data) > 0:
logger.bind(tag=TAG).debug("消息结束收到停止信号,触发处理")
await self.handle_voice_stop(conn, audio_data)
# 清理音频缓存
conn.asr_audio.clear()
conn.reset_vad_states()
break
for utterance in utterances: for utterance in utterances:
if utterance.get("definite", False): if utterance.get("definite", False):
self.text = utterance["text"] current_text = utterance["text"]
logger.bind(tag=TAG).info( logger.bind(tag=TAG).info(
f"识别到文本: {self.text}" f"识别到文本: {current_text}"
) )
conn.reset_vad_states()
if len(audio_data) > 15: # 确保有足够音频数据 # 手动模式下累积识别结果
await self.handle_voice_stop(conn, audio_data) if conn.client_listen_mode == "manual":
if self.text:
self.text += current_text
else:
self.text = current_text
# 在接收消息中途时收到停止信号
if conn.client_voice_stop and len(audio_data) > 0:
logger.bind(tag=TAG).debug("消息中途收到停止信号,触发处理")
await self.handle_voice_stop(conn, audio_data)
# 清理音频缓存
conn.asr_audio.clear()
conn.reset_vad_states()
break
else:
# 自动模式下直接覆盖
self.text = current_text
conn.reset_vad_states()
if len(audio_data) > 15: # 确保有足够音频数据
await self.handle_voice_stop(conn, audio_data)
break break
elif "error" in payload: elif "error" in payload:
error_msg = payload.get("error", "未知错误") error_msg = payload.get("error", "未知错误")
@@ -227,8 +256,6 @@ class ASRProvider(ASRProviderBase):
conn.asr_audio_for_voiceprint = [] conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'): if hasattr(conn, 'asr_audio'):
conn.asr_audio = [] conn.asr_audio = []
if hasattr(conn, 'has_valid_voice'):
conn.has_valid_voice = False
def stop_ws_connection(self): def stop_ws_connection(self):
if self.asr_ws: if self.asr_ws:
@@ -236,6 +263,20 @@ class ASRProvider(ASRProviderBase):
self.asr_ws = None self.asr_ws = None
self.is_processing = False self.is_processing = False
async def _send_stop_request(self):
"""发送最后一个音频帧以通知服务器结束"""
if self.asr_ws:
try:
# 发送结束标记的音频帧(gzip压缩的空数据)
empty_payload = gzip.compress(b"")
last_audio_request = bytearray(self.generate_last_audio_default_header())
last_audio_request.extend(len(empty_payload).to_bytes(4, "big"))
last_audio_request.extend(empty_payload)
await self.asr_ws.send(last_audio_request)
logger.bind(tag=TAG).debug("已发送结束音频帧")
except Exception as e:
logger.bind(tag=TAG).debug(f"发送结束音频帧时出错: {e}")
def construct_request(self, reqid): def construct_request(self, reqid):
req = { req = {
"app": { "app": {
@@ -252,7 +293,7 @@ class ASRProvider(ASRProviderBase):
"sequence": 1, "sequence": 1,
"boosting_table_name": self.boosting_table_name, "boosting_table_name": self.boosting_table_name,
"correct_table_name": self.correct_table_name, "correct_table_name": self.correct_table_name,
"end_window_size": 200, "end_window_size": self.end_window_size,
}, },
"audio": { "audio": {
"format": self.format, "format": self.format,
@@ -370,6 +411,16 @@ class ASRProvider(ASRProviderBase):
pass pass
self.forward_task = None self.forward_task = None
self.is_processing = False self.is_processing = False
# 显式释放decoder资源
if hasattr(self, 'decoder') and self.decoder is not None:
try:
del self.decoder
self.decoder = None
logger.bind(tag=TAG).debug("Doubao decoder resources released")
except Exception as e:
logger.bind(tag=TAG).debug(f"释放Doubao decoder资源时出错: {e}")
# 清理所有连接的音频缓冲区 # 清理所有连接的音频缓冲区
if hasattr(self, '_connections'): if hasattr(self, '_connections'):
for conn in self._connections.values(): for conn in self._connections.values():
@@ -377,5 +428,3 @@ class ASRProvider(ASRProviderBase):
conn.asr_audio_for_voiceprint = [] conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'): if hasattr(conn, 'asr_audio'):
conn.asr_audio = [] conn.asr_audio = []
if hasattr(conn, 'has_valid_voice'):
conn.has_valid_voice = False
@@ -1,14 +1,16 @@
import time
import os import os
import sys
import io import io
import sys
import time
import shutil
import psutil import psutil
import asyncio
from config.logger import setup_logging from config.logger import setup_logging
from typing import Optional, Tuple, List from typing import Optional, Tuple, List
from core.providers.asr.base import ASRProviderBase
from funasr import AutoModel from funasr import AutoModel
from funasr.utils.postprocess_utils import rich_transcription_postprocess from funasr.utils.postprocess_utils import rich_transcription_postprocess
import shutil from core.providers.asr.base import ASRProviderBase
from core.providers.asr.dto.dto import InterfaceType from core.providers.asr.dto.dto import InterfaceType
TAG = __name__ TAG = __name__
@@ -90,16 +92,17 @@ class ASRProvider(ASRProviderBase):
else: else:
file_path = self.save_audio_to_file(pcm_data, session_id) file_path = self.save_audio_to_file(pcm_data, session_id)
# 语音识别 # 语音识别 - 使用线程池避免阻塞事件循环
start_time = time.time() start_time = time.time()
result = self.model.generate( result = await asyncio.to_thread(
self.model.generate,
input=combined_pcm_data, input=combined_pcm_data,
cache={}, cache={},
language="auto", language="auto",
use_itn=True, use_itn=True,
batch_size_s=60, batch_size_s=60,
) )
text = rich_transcription_postprocess(result[0]["text"]) text = await asyncio.to_thread(rich_transcription_postprocess, result[0]["text"])
logger.bind(tag=TAG).debug( logger.bind(tag=TAG).debug(
f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}" f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}"
) )

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