Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bf619e2dd | ||
|
|
1120be2f60 | ||
|
|
9880ce998f |
No files matched your search
@@ -20,58 +20,24 @@ jobs:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'main/xiaozhi-server/requirements.txt'
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
# opuslib_next 是 ctypes 绑定,pip 装上的是纯 Python 包,真正的编解码
|
||||
# 靠系统里的 libopus.so。runner 上默认没有,import 会失败,音频相关的
|
||||
# 用例直接报错。正式镜像 Dockerfile-server-base 里也是装 libopus0 的
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libopus0
|
||||
- name: Install dependencies
|
||||
working-directory: main/xiaozhi-server
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-asyncio freezegun
|
||||
- name: Prepare test config
|
||||
- name: Run smoke test
|
||||
working-directory: main/xiaozhi-server
|
||||
run: |
|
||||
# config/settings.py 在 import 期就要这个文件,缺了整套用例连收集都过不去。
|
||||
# data/ 在 .gitignore 里,只能由这一步现造
|
||||
mkdir -p data
|
||||
printf '{}\n' > data/.config.yaml
|
||||
- name: Run tests
|
||||
working-directory: main/xiaozhi-server
|
||||
run: |
|
||||
# Edge TTS 那条要连微软公网,runner 的出口 IP 会被风控挡掉,先排除。
|
||||
# 根治办法是给它打 live 标记,让 mock 模式下自动不收(另开一条改)
|
||||
pytest tests/ -v -k "not test_tts_text_to_speak_writes_file"
|
||||
if [ -f tests/test_smoke.py ]; then
|
||||
pytest tests/test_smoke.py -x -q
|
||||
else
|
||||
echo "::warning::Slice 2 not done yet — tests/test_smoke.py missing"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
java:
|
||||
name: Java (manager-api)
|
||||
runs-on: ubuntu-latest
|
||||
# 三个 @SpringBootTest 要真连 MySQL 和 Redis,空库即可,Liquibase 会自己建表
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: 123456
|
||||
MYSQL_DATABASE: xiaozhi_esp32_server
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -p123456"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
redis:
|
||||
image: redis:8.0
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd="redis-cli ping"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
@@ -81,12 +47,7 @@ jobs:
|
||||
cache: 'maven'
|
||||
- name: Run tests
|
||||
working-directory: main/manager-api
|
||||
run: mvn -B test -DskipTests=false -DfailIfNoTests=false
|
||||
- name: Check the integration tests really ran
|
||||
working-directory: main/manager-api
|
||||
# 守卫在依赖不齐时会把整类集成测试跳过,这里盯一下它们有没有真跑,
|
||||
# 免得门禁被悄悄掏空
|
||||
run: bash scripts/check-integration-tests.sh
|
||||
run: mvn -B -q test -DfailIfNoTests=false
|
||||
|
||||
web:
|
||||
name: Vue (manager-web)
|
||||
@@ -102,6 +63,4 @@ jobs:
|
||||
working-directory: main/manager-web
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
npm run test:contract
|
||||
npm run test:unit
|
||||
npm run build
|
||||
@@ -22,7 +22,6 @@
|
||||
<a href="./docs/readme/README_vi.md"><img alt="Tiếng Việt" src="https://img.shields.io/badge/Tiếng Việt-DFE0E5"></a>
|
||||
<a href="./docs/readme/README_de.md"><img alt="Deutsch" src="https://img.shields.io/badge/Deutsch-DFE0E5"></a>
|
||||
<a href="./docs/readme/README_pt_BR.md"><img alt="Português (Brasil)" src="https://img.shields.io/badge/Português (Brasil)-DFE0E5"></a>
|
||||
<a href="./docs/readme/README_es.md"><img alt="Español" src="https://img.shields.io/badge/Español-DFE0E5"></a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
|
||||
<img alt="GitHub Contributors" src="https://img.shields.io/github/v/release/xinnan-tech/xiaozhi-esp32-server?logo=docker" />
|
||||
</a>
|
||||
@@ -170,12 +169,12 @@ Spearheaded by Professor Siyuan Liu's Team (South China University of Technology
|
||||
#### 🚀 部署方式选择
|
||||
| 部署方式 | 特点 | 适用场景 | 部署文档 | 配置要求 | 视频教程 |
|
||||
|---------|------|---------|---------|---------|---------|
|
||||
| **最简化安装** | 智能对话、单智能体管理 | 低配置环境,数据存储在配置文件,无需数据库 | [①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` 较耗内存,全部走 API 时资源占用低 | - |
|
||||
| **全模块安装** | 智能对话、多用户管理、多智能体管理、智控台界面操作 | 完整功能体验,数据存储在数据库 |[①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` 较耗内存,另需数据库资源,全部走 API 时资源占用低| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
| **最简化安装** | 智能对话、单智能体管理 | 低配置环境,数据存储在配置文件,无需数据库 | [①Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②源码部署](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - |
|
||||
| **全模块安装** | 智能对话、多用户管理、多智能体管理、智控台界面操作 | 完整功能体验,数据存储在数据库 |[①Docker版](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②源码部署](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
|
||||
常见问题及相关教程,可参考[这个链接](./docs/FAQ.md)
|
||||
|
||||
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据。
|
||||
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据,
|
||||
|
||||
```
|
||||
智控台地址: https://2662r3426b.vicp.fun
|
||||
@@ -203,7 +202,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
| VLLM(视觉大模型) | glm-4v-flash(智谱) | 👍qwen3.5-flash(阿里百炼) |
|
||||
| TTS(语音合成) | EdgeTTS(微软) | 👍HuoshanDoubleStreamTTS(火山流式) |
|
||||
| Intent(意图识别) | function_call(函数调用) | function_call(函数调用) |
|
||||
| Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) |
|
||||
| Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) |
|
||||
|
||||
如果您关心各组件的耗时,请查阅[小智各组件性能测试报告](https://github.com/xinnan-tech/xiaozhi-performance-research),可按报告中的测试方法在您的环境中实际测试。
|
||||
|
||||
@@ -212,8 +211,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 工具名称 | 位置 | 使用方法 | 功能说明 |
|
||||
|:---:|:---|:---:|:---:|
|
||||
| 音频交互测试工具 | main》digital-human》index.html | 在 `main/digital-human` 按其 README 装好依赖后执行 `python start.py`,然后访问 `http://127.0.0.1:8006/index.html` | 测试音频播放和接收功能,验证Python端音频处理是否正常 |
|
||||
| 模型响应测试工具 | main》xiaozhi-server》performance_tester.py | 执行 `python performance_tester.py` | 测试ASR(语音识别)、LLM(大模型)、VLLM(视觉模型)、TTS(语音合成)等核心模块的响应速度 |
|
||||
| 音频交互测试工具 | main》digital-human》index.html | 在 `main/digital-human` 执行 `python start.py` 后访问 `http://127.0.0.1:8006/index.html` | 测试音频播放和接收功能,验证Python端音频处理是否正常 |
|
||||
| 模型响应测试工具 | main》xiaozhi-server》performance_tester.py | 执行 `python performance_tester.py` | 测试ASR(语音识别)、LLM(大模型)、VLLM(视觉模型)、TTS(语音合成)三个核心模块的响应速度 |
|
||||
|
||||
> 💡 提示:测试模型速度时,只会测试配置了密钥的模型。
|
||||
|
||||
@@ -231,12 +230,12 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
| 意图识别 | 支持外挂的大模型意图识别、大模型自主函数调用,提供插件化意图处理机制 |
|
||||
| 记忆系统 | 支持本地短期记忆、mem0ai接口记忆、PowerMem智能记忆,具备记忆总结功能 |
|
||||
| 知识库 | 支持RAGFlow知识库,让大模型判断需要调度知识库后再回答 |
|
||||
| 工具调用 | 支持客户端IOT协议、客户端MCP协议、服务端MCP协议、MCP接入点协议、自定义工具函数 |
|
||||
| 工具调用 | 支持客户端IOT协议、客户MCP协议、服务端MCP协议、MCP接入点协议、自定义工具函数 |
|
||||
| 指令下发 | 依托MQTT协议,支持从智控台将MCP指令下发到ESP32设备 |
|
||||
| 管理后台 | 提供Web管理界面,支持用户管理、系统配置和设备管理;界面支持中文简体、中文繁体、英文、德语、越南语、巴西葡萄牙语显示 |
|
||||
| 测试工具 | 提供模型响应性能测试工具和音频交互测试工具 |
|
||||
| 管理后台 | 提供Web管理界面,支持用户管理、系统配置和设备管理;界面支持中文简体、中文繁体、英文显示 |
|
||||
| 测试工具 | 提供性能测试工具、视觉模型测试工具和音频交互测试工具 |
|
||||
| 部署支持 | 支持Docker部署和本地部署,提供完整的配置文件管理 |
|
||||
| 插件系统 | 支持功能插件扩展、自定义插件开发;新增插件文件在下次设备连接时自动加载,改动已加载插件的代码需重启服务 |
|
||||
| 插件系统 | 支持功能插件扩展、自定义插件开发和插件热加载 |
|
||||
|
||||
### 正在开发 🚧
|
||||
|
||||
@@ -256,7 +255,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| OpenAI 接口调用 | 阿里百炼、火山引擎、DeepSeek、智谱、Gemini、科大讯飞、LM Studio | 智谱、Gemini |
|
||||
| openai 接口调用 | 阿里百炼、火山引擎、DeepSeek、智谱、Gemini、科大讯飞 | 智谱、Gemini |
|
||||
| ollama 接口调用 | Ollama | - |
|
||||
| dify 接口调用 | Dify | - |
|
||||
| fastgpt 接口调用 | Fastgpt | - |
|
||||
@@ -264,7 +263,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
| xinference 接口调用 | Xinference | - |
|
||||
| homeassistant 接口调用 | HomeAssistant | - |
|
||||
|
||||
实际上,任何支持 OpenAI 接口调用的 LLM 均可接入使用。
|
||||
实际上,任何支持 openai 接口调用的 LLM 均可接入使用。
|
||||
|
||||
---
|
||||
|
||||
@@ -272,9 +271,9 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| OpenAI 接口调用 | 阿里百炼、智谱ChatGLMVLLM | 智谱ChatGLMVLLM |
|
||||
| openai 接口调用 | 阿里百炼、智谱ChatGLMVLLM | 智谱ChatGLMVLLM |
|
||||
|
||||
实际上,任何支持 OpenAI 接口调用的 VLLM 均可接入使用。
|
||||
实际上,任何支持 openai 接口调用的 VLLM 均可接入使用。
|
||||
|
||||
---
|
||||
|
||||
@@ -282,8 +281,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| 接口调用 | EdgeTTS、科大讯飞、火山引擎、腾讯云、阿里云及百炼、CosyVoiceSiliconflow、TTS302AI、CozeCnTTS、CustomTTS、OpenAITTS、MinimaxTTS | EdgeTTS、CosyVoiceSiliconflow(部分) |
|
||||
| 本地服务 | FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、IndexStreamTTS、PaddleSpeechTTS | IndexStreamTTS、PaddleSpeechTTS、FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3 |
|
||||
| 接口调用 | EdgeTTS、科大讯飞、火山引擎、腾讯云、阿里云及百炼、CosyVoiceSiliconflow、TTS302AI、CozeCnTTS、GizwitsTTS、ACGNTTS、OpenAITTS、灵犀流式TTS、MinimaxTTS | 灵犀流式TTS、EdgeTTS、CosyVoiceSiliconflow(部分) |
|
||||
| 本地服务 | FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、Index-TTS、PaddleSpeech | Index-TTS、PaddleSpeech、FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3 |
|
||||
|
||||
---
|
||||
|
||||
@@ -299,8 +298,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| 本地使用 | FunASR、SherpaASR、SherpaParaformerASR、VoskASR | FunASR、SherpaASR、SherpaParaformerASR、VoskASR |
|
||||
| 接口调用 | FunASRServer、火山引擎、科大讯飞、腾讯云、阿里云及百炼、百度云、OpenAI ASR、GroqASR、Qwen3ASRFlash | FunASRServer |
|
||||
| 本地使用 | FunASR、SherpaASR | FunASR、SherpaASR |
|
||||
| 接口调用 | FunASRServer、火山引擎、科大讯飞、腾讯云、阿里云、百度云、OpenAI ASR | FunASRServer |
|
||||
|
||||
---
|
||||
|
||||
@@ -308,9 +307,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| 接口调用 | 声纹识别服务(voiceprint-api) | - |
|
||||
|
||||
声纹识别需要单独部署 [voiceprint-api](https://github.com/xinnan-tech/voiceprint-api) 服务(基于 3D-Speaker),并依赖 MySQL,详见[声纹识别启用指南](./docs/voiceprint-integration.md)
|
||||
| 本地使用 | 3D-Speaker | 3D-Speaker |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+17
-15
@@ -2,7 +2,7 @@
|
||||

|
||||
# 方式一:Docker只运行Server
|
||||
|
||||
本项目发行的docker镜像同时提供`amd64`和`arm64`两种架构,可以直接拉取使用;如需自定义镜像,可按照[这个教程](docker-build.md)在本机编译。
|
||||
`0.8.2`版本开始,本项目发行的docker镜像只支持`x86架构`,如果需要在`arm64架构`的CPU上部署,可按照[这个教程](docker-build.md)在本机编译`arm64的镜像`。
|
||||
|
||||
## 1. 安装docker
|
||||
|
||||
@@ -36,9 +36,9 @@ xiaozhi-server
|
||||
|
||||
#### 1.1.3 下载配置文件
|
||||
|
||||
你需要下载两个配置文件:`docker-compose.yml` 和 `config.yaml`。需要从项目仓库下载这两个文件。
|
||||
你需要下载两个配置文件:`docker-compose.yaml` 和 `config.yaml`。需要从项目仓库下载这两个文件。
|
||||
|
||||
##### 1.1.3.1 下载 docker-compose.yml
|
||||
##### 1.1.3.1 下载 docker-compose.yaml
|
||||
|
||||
用浏览器打开[这个链接](../main/xiaozhi-server/docker-compose.yml)。
|
||||
|
||||
@@ -91,22 +91,25 @@ docker logs -f xiaozhi-esp32-server
|
||||
|
||||
这时,你就要留意日志信息,可以根据这个教程,判断是否成功了。[跳转到运行状态确认](#运行状态确认)
|
||||
|
||||
## 4. 版本升级操作
|
||||
## 5. 版本升级操作
|
||||
|
||||
如果后期想升级版本,可以这么操作
|
||||
|
||||
4.1、备份好`data`文件夹中的`.config.yaml`文件,一些关键的配置到时复制到新的`.config.yaml`文件里。
|
||||
5.1、备份好`data`文件夹中的`.config.yaml`文件,一些关键的配置到时复制到新的`.config.yaml`文件里。
|
||||
请注意是对关键密钥逐个复制,不要直接覆盖。因为新的`.config.yaml`文件可能有一些新的配置项,旧的`.config.yaml`文件不一定有。
|
||||
|
||||
4.2、执行以下命令
|
||||
5.2、执行以下命令
|
||||
|
||||
```
|
||||
docker stop xiaozhi-esp32-server
|
||||
docker rm xiaozhi-esp32-server
|
||||
docker stop xiaozhi-esp32-server-web
|
||||
docker rm xiaozhi-esp32-server-web
|
||||
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
|
||||
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
|
||||
```
|
||||
|
||||
4.3、重新按docker方式部署
|
||||
5.3、重新按docker方式部署
|
||||
|
||||
# 方式二:本地源码只运行Server
|
||||
|
||||
@@ -210,14 +213,14 @@ prompt: |
|
||||
请你像一个人一样说话,请勿返回配置xml及其他特殊字符。
|
||||
|
||||
selected_module:
|
||||
LLM: ChatGLMLLM
|
||||
LLM: DoubaoLLM
|
||||
|
||||
LLM:
|
||||
ChatGLMLLM:
|
||||
api_key: xxxxxxxxxxxxxxx.xxxxxx
|
||||
```
|
||||
|
||||
建议先将最简单的配置运行起来,然后再去[`../main/xiaozhi-server/config.yaml`](../main/xiaozhi-server/config.yaml)阅读配置的使用说明。
|
||||
建议先将最简单的配置运行起来,然后再去`xiaozhi/config.yaml`阅读配置的使用说明。
|
||||
比如你要换更换模型,修改`selected_module`下的配置就行。
|
||||
|
||||
## 模型文件
|
||||
@@ -235,12 +238,11 @@ LLM:
|
||||
如果你能看到,类似以下日志,则是本项目服务启动成功的标志。
|
||||
|
||||
```
|
||||
250427 13:04:20[0.9.8_00000000000000][__main__]-INFO-OTA接口是 http://192.168.4.123:8003/xiaozhi/ota/
|
||||
250427 13:04:20[0.9.8_00000000000000][__main__]-INFO-视觉分析接口是 http://192.168.4.123:8003/mcp/vision/explain
|
||||
250427 13:04:20[0.9.8_00000000000000][__main__]-INFO-Websocket地址是 ws://192.168.4.123:8000/xiaozhi/v1/
|
||||
250427 13:04:20[0.9.8_00000000000000][__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问=======
|
||||
250427 13:04:20[0.9.8_00000000000000][__main__]-INFO-如想测试websocket请启动digital-human模块,打开浏览器交互测试
|
||||
250427 13:04:20[0.9.8_00000000000000][__main__]-INFO-=============================================================
|
||||
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-OTA接口是 http://192.168.4.123:8003/xiaozhi/ota/
|
||||
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-Websocket地址是 ws://192.168.4.123:8000/xiaozhi/v1/
|
||||
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问=======
|
||||
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-如想测试websocket请启动digital-human模块,打开浏览器交互测试
|
||||
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-=======================================================
|
||||
```
|
||||
|
||||
正常来说,如果您是通过源码运行本项目,日志会有你的接口地址信息。
|
||||
|
||||
@@ -144,7 +144,5 @@
|
||||
### Q: 我叫他呼叫"张山",但是他老是识别成"张三",怎么办?
|
||||
- 可以查阅你使用的asr服务的文档,确认是否支持热词识别。
|
||||
- 如果你用的是`FunASRServer`,可以在容器里的`热词文件`里添加"张山",然后重启容器。
|
||||
- 如果你用的是`火山引擎`的服务,可以在`火山引擎的控制台`里添加`热词文件`,然后回到智控台的`模型配置`页面,在`语音识别`里找到模型名称为`豆包语音识别(流式)`的那一条,点`修改`,弹窗里的`供应器`是`火山引擎语音识别(流式)`,在弹窗的`调用信息`区域把`热词文件名称`填到对应的输入框,点`确认保存`。
|
||||
- `豆包语音识别2.0(流式)`(供应器是`火山引擎语音识别(流式)`)和`豆包语音识别`(供应器是`火山引擎语音识别`)这两条也都有`热词文件名称`这个字段,用法一样,配置时注意别选错行。
|
||||
- 改完要让设备重新连接才会生效:设备连上时才会把最新的私有配置拉下来,一直连着的设备不会自动变。
|
||||
- 如果你用的是`火山引擎`的服务,可以在`火山引擎的控制台`里添加`热词文件`,然后回到智控台的`模型配置页面`,把`热词文件名称`配置在`火山引擎的tts`上去。
|
||||
|
||||
@@ -50,11 +50,10 @@ paddlespeech tts --input "你好,这是一次测试"
|
||||
|
||||
### 7.修改tts_online_application.yaml配置
|
||||
参考目录 ```"PaddleSpeech\demos\streaming_tts_server\conf\tts_online_application.yaml"```
|
||||
Linux / WSL 2 用正斜杠写法:```"PaddleSpeech/demos/streaming_tts_server/conf/tts_online_application.yaml"```
|
||||
选择```tts_online_application.yaml```文件用编辑器打开,设置```protocol```为```websocket```
|
||||
|
||||
### 8.启动服务
|
||||
```bash
|
||||
```yaml
|
||||
paddlespeech_server start --config_file ./demos/streaming_tts_server/conf/tts_online_application.yaml
|
||||
#官方默认启动命令:
|
||||
paddlespeech_server start --config_file ./conf/tts_online_application.yaml
|
||||
@@ -69,19 +68,10 @@ INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:8092 (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
## 三、配置小智服务
|
||||
### 1. 无需修改代码(provider 已内置于 main/xiaozhi-server/core/providers/tts/paddle_speech.py)
|
||||
|
||||
### 2.新建 `main/xiaozhi-server/data/.config.yaml`
|
||||
|
||||
`main/xiaozhi-server/data/` 目录不在仓库里,是被 `.gitignore` 忽略掉的,所以第一次用要先把它建出来,再在里面新建 `.config.yaml` 文件(在仓库根目录执行,上面的 `cd PaddleSpeech` 之后当前目录还停在 PaddleSpeech 源码目录里):
|
||||
|
||||
```bash
|
||||
mkdir -p main/xiaozhi-server/data
|
||||
```
|
||||
|
||||
这个文件是必需的,少了它小智服务起不来,会直接报错退出。
|
||||
## 三、修改小智的配置文件
|
||||
### 1.```main/xiaozhi-server/core/providers/tts/paddle_speech.py```
|
||||
|
||||
### 2.```main/xiaozhi-server/data/.config.yaml```
|
||||
使用单模块部署
|
||||
```yaml
|
||||
selected_module:
|
||||
@@ -89,41 +79,19 @@ selected_module:
|
||||
TTS:
|
||||
PaddleSpeechTTS:
|
||||
type: paddle_speech
|
||||
protocol: websocket # 当前仅支持 websocket
|
||||
protocol: websocket
|
||||
url: ws://127.0.0.1:8092/paddlespeech/tts/streaming # TTS 服务的 URL 地址,指向本地服务器 [websocket默认ws://127.0.0.1:8092/paddlespeech/tts/streaming]
|
||||
spk_id: 0 # 发音人 ID,0 通常表示默认的发音人
|
||||
sample_rate: 24000 # 采样率 [websocket默认24000,http默认0 自动选择]
|
||||
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
||||
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
||||
save_path: # 保存路径
|
||||
```
|
||||
采样率固定 24000,provider 内部写死,配置里不用写这一项。
|
||||
|
||||
### 3.启动xiaozhi服务
|
||||
|
||||
小智服务用**另一个** Python 环境,不要用前面给 PaddleSpeech 建的 `paddle_env` —— 那个环境里装的是 PaddleSpeech 自己那套依赖,版本由它决定,跟本项目锁定的版本不一定一致(`paddlespeech` 对 numpy 没写版本上限,装进去的 numpy 未必是本项目要的那个)。本项目推荐的 Python 版本和依赖版本写在 `main/xiaozhi-server/requirements.txt` 里(文件开头写着推荐 python3.10,另外锁了 torch 2.2.2、numpy 1.26.4)。
|
||||
|
||||
先建环境、装依赖(在仓库根目录执行):
|
||||
```bash
|
||||
conda create -n xiaozhi-esp32-server python=3.10 -y
|
||||
conda activate xiaozhi-esp32-server
|
||||
cd main/xiaozhi-server
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
然后在 `main/xiaozhi-server` 目录下启动(目录不能换:provider 是按相对路径 `core/providers/tts/` 找的,见 `core/utils/tts.py:35`):
|
||||
```bash
|
||||
# 上一段已经 cd 到 main/xiaozhi-server 了,这段不用再 cd
|
||||
# 如果是另开的新终端,先 cd main/xiaozhi-server 再执行
|
||||
```py
|
||||
python app.py
|
||||
```
|
||||
|
||||
要验证语音合成的实际输出,另开一个终端,在仓库根目录下跑数字人页面(这套代码另有一份依赖清单,在 `main/digital-human/wakeword_runtime/requirements.txt` 里,缺了会报缺包;唤醒词那部分还要 numpy —— `core/detector.py:7` 和 `core/microphone.py:4` 在模块顶层就 import numpy,用上面建的 `xiaozhi-esp32-server` 跑的话 numpy 已经在里面,不会有这个问题):
|
||||
```bash
|
||||
cd main/digital-human
|
||||
python start.py
|
||||
```
|
||||
|
||||
打开`http://127.0.0.1:8006/index.html`,测试连接和发送消息时paddlespeech端是否有输出日志
|
||||
启动`main/digital-human`下的`python start.py`后,打开`http://127.0.0.1:8006/index.html`,测试连接和发送消息时paddlespeech端是否有输出日志
|
||||
|
||||
输出日志参考:
|
||||
```
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
# 语音识别、流式语音识别、大语言模型、非流式语音合成、流式语音合成、视觉模型的性能测试工具使用指南
|
||||
|
||||
0.先装依赖(在仓库根目录执行)
|
||||
```
|
||||
cd main/xiaozhi-server
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
测试脚本要 import `yaml`、`aiohttp`、`tabulate`,少一个都起不来。`tabulate` 不在 requirements.txt 里,是 `edge-tts` 间接带进来的。
|
||||
# 语音识别、大语言模型、非流式语音合成、流式语音合成、视觉模型的性能测试工具使用指南
|
||||
|
||||
1.在main/xiaozhi-server目录下创建data目录
|
||||
2.在data目录下创建.config.yaml文件
|
||||
3.在data/.config.yaml中,写入你的语音识别、流式语音识别、大语言模型、非流式语音合成、流式语音合成、视觉模型的参数
|
||||
3.在.data/config.yaml中,写入你的语音识别、大语言模型、流式语音合成、视觉模型的参数
|
||||
例如:
|
||||
```
|
||||
LLM:
|
||||
@@ -23,50 +16,12 @@ LLM:
|
||||
api_key: 你的chat-glm web key
|
||||
|
||||
TTS:
|
||||
EdgeTTS:
|
||||
# 定义TTS API类型
|
||||
type: edge
|
||||
voice: zh-CN-XiaoxiaoNeural
|
||||
|
||||
VLLM:
|
||||
ChatGLMVLLM:
|
||||
# 定义视觉模型API类型
|
||||
type: openai
|
||||
# glm-4v-flash 是智谱的视觉模型,需要在智谱平台申请api_key
|
||||
model_name: glm-4v-flash
|
||||
url: https://open.bigmodel.cn/api/paas/v4/
|
||||
api_key: 你的api_key
|
||||
|
||||
ASR:
|
||||
FunASR:
|
||||
type: fun_local
|
||||
model_dir: models/SenseVoiceSmall
|
||||
language: auto
|
||||
```
|
||||
4.在main/xiaozhi-server目录下运行performance_tester.py:
|
||||
```
|
||||
python performance_tester.py
|
||||
```
|
||||
|
||||
## 几点说明
|
||||
|
||||
- 脚本第一行提示的 `docs/performance_tester.md` 就是本文档,它在仓库根目录的 `docs/` 下;脚本本身在 `main/xiaozhi-server/` 目录里跑,两处不是一个目录。
|
||||
- LLM、VLLM、非流式语音合成这三项会把 `config.yaml` 和 `data/.config.yaml` 两份配置合并起来再遍历,所以实际会测**所有配好 api_key 的供应器**,不只是你在 `.config.yaml` 里写的那一个;api_key 还是占位符的会被跳过。
|
||||
- 非流式的语音识别这一项读配置的方式和别的不一样:它自己扫 `data/` 目录下的 `*.config.yaml`,只取其中的 `ASR` 段,**不会**用 `config.yaml` 里的默认值。想测语音识别,就得把供应器写进 `data/.config.yaml`。
|
||||
- 流式语音识别读的是合并后的配置,固定测 `DoubaoStreamASR`、`Qwen3ASRFlash`、`XunfeiStreamASR` 这三家。`config.yaml` 里这三项带的 key 是占位符,要在 `data/.config.yaml` 的 `ASR` 段里填上自己的 key 才会测出延迟。
|
||||
- 视觉模型的配置写在 `data/.config.yaml` 的 `VLLM:` 段里,这段**不能只写一个空键**:写空的话合并后整段是空的,这项测试会直接跳过、什么都不测。
|
||||
- 流式语音合成一次会跑这些供应器:
|
||||
|
||||
| 供应器 | 说明 |
|
||||
|---|---|
|
||||
| `AliyunStreamTTS` | 阿里云流式 TTS,无条件调用 |
|
||||
| `HuoshanDoubleStreamTTS` | 火山引擎流式 TTS,无条件调用 |
|
||||
| `PaddleSpeechTTS` | PaddleSpeech 流式 TTS,无条件调用 |
|
||||
| `IndexStreamTTS` | IndexStream 流式 TTS,无条件调用 |
|
||||
| `LinkeraiTTS` | `config.yaml` 的 TTS 段里没有这个供应器,默认不测、整项跳过;要测得自己在 `.config.yaml` 里补上 |
|
||||
| `AliBLTTS` | 阿里百炼流式 TTS,默认就会测:判断读的是合并后的配置,`config.yaml` 里本来就带着这一项 |
|
||||
| `XunFeiTTS` | 讯飞流式 TTS,同上,默认就会测 |
|
||||
|
||||
`AliBLTTS` 和 `XunFeiTTS` 这两项默认带的 key 是占位符,不在 `.config.yaml` 里换成自己的 key 就会报失败;想直接跳过,把这两项在 `.config.yaml` 里写成空的(键名下面不填内容)就行。
|
||||
|
||||
标了「无条件调用」的这几个(`AliyunStreamTTS`、`HuoshanDoubleStreamTTS`、`PaddleSpeechTTS`、`IndexStreamTTS`),供应器缺失或被清空时,那一项会报错,错误被测试方法内部的异常处理吃掉,这一项被记成失败 —— 结果表里会多出一行失败记录,但不影响其它项。带守卫的那几家(`AliBLTTS`、`XunFeiTTS`、`LinkeraiTTS`)是整项跳过:没配就不报错、结果表里也不会有这一行。
|
||||
```
|
||||
+37
-39
@@ -6,7 +6,7 @@
|
||||
Dieses Projekt basiert auf der Theorie und Technologie der Mensch-Maschine-symbiotischen Intelligenz zur Entwicklung intelligenter Terminal-Hardware- und Software-Systeme<br/>und bietet Backend-Dienste für das Open-Source-Hardware-Projekt
|
||||
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a><br/>
|
||||
Implementiert mit Python, Java und Vue gemäß dem <a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">Xiaozhi-Kommunikationsprotokoll</a><br/>
|
||||
Unterstützt MQTT+UDP-Protokoll, Websocket-Protokoll, MCP-Zugriffspunkte, Stimmabdruckerkennung und Wissensdatenbank
|
||||
Unterstützt MQTT+UDP-Protokoll, Websocket-Protokoll, MCP-Endpunkte und Stimmabdruckerkennung
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -22,7 +22,6 @@ Unterstützt MQTT+UDP-Protokoll, Websocket-Protokoll, MCP-Zugriffspunkte, Stimma
|
||||
<a href="./README_vi.md"><img alt="Tiếng Việt" src="https://img.shields.io/badge/Tiếng Việt-DFE0E5"></a>
|
||||
<a href="./README_de.md"><img alt="Deutsch" src="https://img.shields.io/badge/Deutsch-DBEDFA"></a>
|
||||
<a href="./README_pt_BR.md"><img alt="Português (Brasil)" src="https://img.shields.io/badge/Português (Brasil)-DFE0E5"></a>
|
||||
<a href="./README_es.md"><img alt="Español" src="https://img.shields.io/badge/Español-DFE0E5"></a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
|
||||
<img alt="GitHub Contributors" src="https://img.shields.io/github/v/release/xinnan-tech/xiaozhi-esp32-server?logo=docker" />
|
||||
</a>
|
||||
@@ -55,31 +54,31 @@ Möchten Sie die Nutzungseffekte sehen? Klicken Sie auf die Videos unten 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1FMFyejExX" target="_blank">
|
||||
<picture>
|
||||
<img alt="Das Gefühl der Reaktionsgeschwindigkeit" src="../images/demo9.png" /></picture>
|
||||
<img alt="响应速度感受" src="docs/images/demo9.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||
<picture>
|
||||
<img alt="Das Geheimnis der Geschwindigkeitsoptimierung" src="../images/demo6.png" /></picture>
|
||||
<img alt="速度优化秘诀" src="docs/images/demo6.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1WEcxzFEAT" target="_blank">
|
||||
<picture>
|
||||
<img alt="Xiaozhi-Digitalmensch mit Sprachaktivierung" src="../images/demo8.png" /></picture>
|
||||
<img alt="小智数字人 支持语音唤醒" src="docs/images/demo8.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CKVz6UEuB" target="_blank">
|
||||
<picture>
|
||||
<img alt="Anruf von Gerät zu Gerät" src="../images/demo0.png" /></picture>
|
||||
<img alt="设备呼叫设备,打电话" src="docs/images/demo0.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||
<picture>
|
||||
<img alt="Komplexes medizinisches Szenario" src="../images/demo1.png" /></picture>
|
||||
<img alt="复杂医疗场景" src="docs/images/demo1.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -87,31 +86,31 @@ Möchten Sie die Nutzungseffekte sehen? Klicken Sie auf die Videos unten 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1VC96Y5EMH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Musik abspielen, Wetter abfragen und Nachrichten vorlesen" src="../images/demo7.png" /></picture>
|
||||
<img alt="播放音乐查询天气播报新闻" src="docs/images/demo7.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12J7WzBEaH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Unterbrechung in Echtzeit" src="../images/demo10.png" /></picture>
|
||||
<img alt="实时打断" src="docs/images/demo10.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Co76z7EvK" target="_blank">
|
||||
<picture>
|
||||
<img alt="Objekte per Foto erkennen" src="../images/demo12.png" /></picture>
|
||||
<img alt="拍照识物品" src="docs/images/demo12.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
<img alt="Haushaltsgeräte ein- und ausschalten" src="../images/demo5.png" /></picture>
|
||||
<img alt="控制家电开关" src="docs/images/demo5.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Aufgaben mit mehreren Befehlen" src="../images/demo11.png" /></picture>
|
||||
<img alt="多指令任务" src="docs/images/demo11.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -119,31 +118,31 @@ Möchten Sie die Nutzungseffekte sehen? Klicken Sie auf die Videos unten 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
|
||||
<picture>
|
||||
<img alt="MCP-Zugriffspunkt" src="../images/demo13.png" /></picture>
|
||||
<img alt="MCP接入点" src="docs/images/demo13.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
|
||||
<picture>
|
||||
<img alt="MQTT-Befehlsübermittlung" src="../images/demo4.png" /></picture>
|
||||
<img alt="MQTT指令下发" src="docs/images/demo4.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<picture>
|
||||
<img alt="Stimmabdruckerkennung" src="../images/demo14.png" /></picture>
|
||||
<img alt="声纹识别" src="docs/images/demo14.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Eigene Stimmfarbe" src="../images/demo2.png" /></picture>
|
||||
<img alt="自定义音色" src="docs/images/demo2.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="Gespräch auf Kantonesisch" src="../images/demo3.png" /></picture>
|
||||
<img alt="使用粤语交流" src="docs/images/demo3.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -168,8 +167,8 @@ Dieses Projekt bietet zwei Bereitstellungsmethoden. Bitte wählen Sie basierend
|
||||
#### 🚀 Auswahl der Bereitstellungsmethode
|
||||
| Bereitstellungsmethode | Funktionen | Anwendungsszenarien | Deployment-Dokumente | Konfigurationsanforderungen | Video-Tutorials |
|
||||
|---------|------|---------|---------|---------|---------|
|
||||
| **Vereinfachte Installation** | Intelligenter Dialog, Einzel-Agenten-Verwaltung | Umgebungen mit geringer Konfiguration, Daten in Konfigurationsdateien gespeichert, keine Datenbank erforderlich | [①Docker-Version](../Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Quellcode-Deployment](../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)| Lokales `FunASR` benötigt mehr Arbeitsspeicher; bei durchgängiger API-Nutzung ist der Ressourcenverbrauch gering | - |
|
||||
| **Vollständige Modulinstallation** | Intelligenter Dialog, Mehrbenutzerverwaltung, Mehr-Agenten-Verwaltung, Intelligente Steuerkonsole-Bedienung | Vollständige Funktionserfahrung, Daten in Datenbank gespeichert |[①Docker-Version](../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](../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](../dev-ops-integration.md) | Lokales `FunASR` benötigt mehr Arbeitsspeicher, zusätzlich werden Datenbankressourcen benötigt; bei durchgängiger API-Nutzung ist der Ressourcenverbrauch gering| [Video-Tutorial für lokalen Quellcode-Start](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
| **Vereinfachte Installation** | Intelligenter Dialog, Einzel-Agenten-Verwaltung | Umgebungen mit geringer Konfiguration, Daten in Konfigurationsdateien gespeichert, keine Datenbank erforderlich | [①Docker-Version](../Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Quellcode-Deployment](../Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 Kerne 4GB bei Verwendung von `FunASR`, 2 Kerne 2GB bei allen APIs | - |
|
||||
| **Vollständige Modulinstallation** | Intelligenter Dialog, Mehrbenutzerverwaltung, Mehr-Agenten-Verwaltung, Intelligente Steuerkonsole-Bedienung | Vollständige Funktionserfahrung, Daten in Datenbank gespeichert |[①Docker-Version](../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](../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](../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](../FAQ.md)
|
||||
|
||||
@@ -210,15 +209,15 @@ Dieses Projekt bietet die folgenden Testwerkzeuge, um Ihnen bei der Überprüfun
|
||||
|
||||
| Werkzeugname | Standort | Verwendungsmethode | Funktionsbeschreibung |
|
||||
|:---:|:---|:---:|:---:|
|
||||
| Audio-Interaktionstesttool | main》digital-human》index.html | In `main/digital-human` zuerst die Abhängigkeiten wie in der README installieren, dann `python start.py` ausführen und `http://127.0.0.1:8006/index.html` öffnen | Testet Audio-Wiedergabe- und Empfangsfunktionen, überprüft, ob die Python-seitige Audioverarbeitung normal ist |
|
||||
| Modell-Reaktionstesttool | main》xiaozhi-server》performance_tester.py | Ausführen `python performance_tester.py` | Testet die Reaktionsgeschwindigkeit von Kernmodulen wie ASR (Spracherkennung), LLM (großes Modell), VLLM (Vision-Modell), TTS (Sprachsynthese) |
|
||||
| Audio-Interaktionstesttool | main》digital-human》index.html | In `main/digital-human` `python start.py` ausführen und dann `http://127.0.0.1:8006/index.html` öffnen | Testet Audio-Wiedergabe- und Empfangsfunktionen, überprüft, ob die Python-seitige Audioverarbeitung normal ist |
|
||||
| Modell-Reaktionstesttool | main》xiaozhi-server》performance_tester.py | Ausführen `python performance_tester.py` | Testet die Reaktionsgeschwindigkeit von ASR (Spracherkennung), LLM (großes Modell), VLLM (Vision-Modell), TTS (Sprachsynthese) drei Kernmodulen |
|
||||
|
||||
> 💡 Hinweis: Beim Testen der Modellgeschwindigkeit werden nur Modelle mit konfigurierten Schlüsseln getestet.
|
||||
|
||||
---
|
||||
## Funktionsliste ✨
|
||||
### Implementiert ✅
|
||||

|
||||

|
||||
| Funktionsmodul | Beschreibung |
|
||||
|:---:|:---|
|
||||
| Kernarchitektur | Basierend auf [MQTT+UDP-Gateway](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mqtt-gateway-integration.md), WebSocket und HTTP-Servern, bietet vollständiges Konsolenverwaltungs- und Authentifizierungssystem |
|
||||
@@ -226,15 +225,15 @@ Dieses Projekt bietet die folgenden Testwerkzeuge, um Ihnen bei der Überprüfun
|
||||
| Stimmabdruckerkennung | Unterstützt Mehrbenutzer-Stimmabdruckregistrierung, -verwaltung und -erkennung, verarbeitet parallel mit ASR, Echtzeit-Sprecheridentitätserkennung und Weitergabe an LLM für personalisierte Antworten |
|
||||
| Intelligenter Dialog | Unterstützt mehrere LLM (große Sprachmodelle), implementiert intelligenten Dialog |
|
||||
| Visuelle Wahrnehmung | Unterstützt mehrere VLLM (Vision Large Models), implementiert multimodale Interaktion |
|
||||
| Absichtserkennung | Unterstützt externe LLM-Absichtserkennung, autonome Funktionsaufrufe durch große Modelle, bietet plugin-basierten Absichtsverarbeitungsmechanismus |
|
||||
| Absichtserkennung | Unterstützt LLM-Absichtserkennung, Function Call-Funktionsaufruf, bietet plugin-basierten Absichtsverarbeitungsmechanismus |
|
||||
| Gedächtnissystem | Unterstützt lokales Kurzzeitgedächtnis, mem0ai-Schnittstellengedächtnis, PowerMem intelligentes Gedächtnis, mit Gedächtniszusammenfassungsfunktion |
|
||||
| Wissensdatenbank | Unterstützt RAGFlow-Wissensdatenbank, ermöglicht großem Modell die Bewertung, ob Wissensdatenbank benötigt wird, bevor geantwortet wird |
|
||||
| Werkzeugaufruf | Unterstützt Client-IOT-Protokoll, Client-MCP-Protokoll, Server-MCP-Protokoll, MCP-Zugriffspunktprotokoll, benutzerdefinierte Werkzeugfunktionen |
|
||||
| Werkzeugaufruf | Unterstützt Client-IOT-Protokoll, Client-MCP-Protokoll, Server-MCP-Protokoll, MCP-Endpunktprotokoll, benutzerdefinierte Werkzeugfunktionen |
|
||||
| Befehlsübermittlung | Basierend auf MQTT-Protokoll, unterstützt die Übermittlung von MCP-Befehlen von der intelligenten Steuerkonsole an ESP32-Geräte |
|
||||
| Verwaltungs-Backend | Bietet Web-Verwaltungsoberfläche, unterstützt Benutzerverwaltung, Systemkonfiguration und Geräteverwaltung; Oberfläche unterstützt vereinfachtes Chinesisch, traditionelles Chinesisch, Englisch, Deutsch, Vietnamesisch und brasilianisches Portugiesisch an |
|
||||
| Testwerkzeuge | Bietet Leistungstestwerkzeuge für Modellantworten und Audio-Interaktionstestwerkzeuge |
|
||||
| Verwaltungs-Backend | Bietet Web-Verwaltungsoberfläche, unterstützt Benutzerverwaltung, Systemkonfiguration und Geräteverwaltung; Oberfläche unterstützt vereinfachtes Chinesisch, traditionelles Chinesisch und englische Anzeige |
|
||||
| Testwerkzeuge | Bietet Leistungstestwerkzeuge, Vision-Modell-Testwerkzeuge und Audio-Interaktionstestwerkzeuge |
|
||||
| Deployment-Unterstützung | Unterstützt Docker-Deployment und lokales Deployment, bietet vollständige Konfigurationsdateiverwaltung |
|
||||
| Plugin-System | Unterstützt funktionale Plugin-Erweiterungen und benutzerdefinierte Plugin-Entwicklung; neu hinzugefügte Plugin-Dateien werden bei der nächsten Geräteverbindung geladen, Änderungen an bereits geladenen Plugins erfordern einen Neustart des Dienstes |
|
||||
| Plugin-System | Unterstützt funktionale Plugin-Erweiterungen, benutzerdefinierte Plugin-Entwicklung und Plugin-Hot-Loading |
|
||||
|
||||
### In Entwicklung 🚧
|
||||
|
||||
@@ -254,7 +253,7 @@ Xiaozhi ist ein Ökosystem. Wenn Sie dieses Produkt verwenden, können Sie sich
|
||||
|
||||
| Verwendungsmethode | Unterstützte Plattformen | Kostenlose Plattformen |
|
||||
|:---:|:---:|:---:|
|
||||
| OpenAI-Schnittstellenaufrufe | Alibaba Bailian, Volcano Engine Doubao, Volcano Engine AI Gateway, DeepSeek, Zhipu ChatGLM, Gemini, iFLYTEK, LM Studio | Zhipu ChatGLM, Gemini |
|
||||
| OpenAI-Schnittstellenaufrufe | Alibaba Bailian, Volcano Engine Doubao, DeepSeek, Zhipu ChatGLM, Gemini | Zhipu ChatGLM, Gemini |
|
||||
| Ollama-Schnittstellenaufrufe | Ollama | - |
|
||||
| Dify-Schnittstellenaufrufe | Dify | - |
|
||||
| FastGPT-Schnittstellenaufrufe | FastGPT | - |
|
||||
@@ -280,8 +279,8 @@ Tatsächlich kann jedes VLLM, das OpenAI-Schnittstellenaufrufe unterstützt, int
|
||||
|
||||
| Verwendungsmethode | Unterstützte Plattformen | Kostenlose Plattformen |
|
||||
|:---:|:---:|:---:|
|
||||
| Schnittstellenaufrufe | EdgeTTS, iFLYTEK, Volcano Engine Doubao TTS, Volcano Engine AI Gateway TTS, Tencent Cloud, Alibaba Cloud TTS, AliYun Stream TTS, Alibaba Bailian CosyVoice TTS, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, CustomTTS, OpenAITTS, MinimaxTTS, Volcano Dual-Stream TTS | EdgeTTS, CosyVoiceSiliconflow (teilweise) |
|
||||
| Lokale Dienste | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, IndexStreamTTS, PaddleSpeechTTS | IndexStreamTTS, PaddleSpeechTTS, FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3 |
|
||||
| Schnittstellenaufrufe | EdgeTTS, Volcano Engine Doubao TTS, Tencent Cloud, Alibaba Cloud TTS, AliYun Stream TTS, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, GizwitsTTS, ACGNTTS, OpenAITTS, Lingxi Streaming TTS, MinimaxTTS, Volcano Dual-Stream TTS | Lingxi Streaming TTS, EdgeTTS, CosyVoiceSiliconflow (teilweise) |
|
||||
| Lokale Dienste | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, Index-TTS, PaddleSpeech | Index-TTS, PaddleSpeech, FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3 |
|
||||
|
||||
---
|
||||
|
||||
@@ -297,8 +296,8 @@ Tatsächlich kann jedes VLLM, das OpenAI-Schnittstellenaufrufe unterstützt, int
|
||||
|
||||
| Verwendungsmethode | Unterstützte Plattformen | Kostenlose Plattformen |
|
||||
|:---:|:---:|:---:|
|
||||
| Lokale Verwendung | FunASR, SherpaASR, SherpaParaformerASR, VoskASR | FunASR, SherpaASR, SherpaParaformerASR, VoskASR |
|
||||
| Schnittstellenaufrufe | DoubaoASR, Doubao Streaming ASR, Doubao Streaming ASR V2, FunASRServer, iFLYTEK, TencentASR, AliyunASR, Aliyun Streaming ASR, Alibaba Bailian Streaming ASR, Baidu ASR, OpenAI ASR, GroqASR, Qwen3ASRFlash | FunASRServer |
|
||||
| Lokale Verwendung | FunASR, SherpaASR | FunASR, SherpaASR |
|
||||
| Schnittstellenaufrufe | DoubaoASR, Doubao Streaming ASR, FunASRServer, TencentASR, AliyunASR, Aliyun Streaming ASR, Baidu ASR, OpenAI ASR | FunASRServer |
|
||||
|
||||
---
|
||||
|
||||
@@ -306,9 +305,7 @@ Tatsächlich kann jedes VLLM, das OpenAI-Schnittstellenaufrufe unterstützt, int
|
||||
|
||||
| Verwendungsmethode | Unterstützte Plattformen | Kostenlose Plattformen |
|
||||
|:---:|:---:|:---:|
|
||||
| Schnittstellenaufrufe | Voiceprint-Dienst (voiceprint-api) | - |
|
||||
|
||||
Die Stimmabdruckerkennung erfordert einen separat bereitgestellten [voiceprint-api](https://github.com/xinnan-tech/voiceprint-api)-Dienst (auf Basis von 3D-Speaker) sowie MySQL. Siehe [Anleitung zur Stimmabdruckerkennung](../voiceprint-integration.md).
|
||||
| Lokale Verwendung | 3D-Speaker | 3D-Speaker |
|
||||
|
||||
---
|
||||
|
||||
@@ -354,10 +351,11 @@ Die Stimmabdruckerkennung erfordert einen separat bereitgestellten [voiceprint-a
|
||||
| <img src="../images/logo_contributors.png" width="160"> | [Code-Mitwirkende](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | Dank an [alle Code-Mitwirkenden](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors), Ihre Bemühungen haben das Projekt robuster und leistungsfähiger gemacht. |
|
||||
|
||||
|
||||
<a href="https://www.star-history.com/?repos=xinnan-tech%2Fxiaozhi-esp32-server&type=date&legend=top-left">
|
||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&theme=dark&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
+36
-38
@@ -22,7 +22,6 @@ Support for MQTT+UDP protocol, Websocket protocol, MCP access point, voiceprint
|
||||
<a href="./README_vi.md"><img alt="Tiếng Việt" src="https://img.shields.io/badge/Tiếng Việt-DFE0E5"></a>
|
||||
<a href="./README_de.md"><img alt="Deutsch" src="https://img.shields.io/badge/Deutsch-DFE0E5"></a>
|
||||
<a href="./README_pt_BR.md"><img alt="Português (Brasil)" src="https://img.shields.io/badge/Português (Brasil)-DFE0E5"></a>
|
||||
<a href="./README_es.md"><img alt="Español" src="https://img.shields.io/badge/Español-DFE0E5"></a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
|
||||
<img alt="GitHub Contributors" src="https://img.shields.io/github/v/release/xinnan-tech/xiaozhi-esp32-server?logo=docker" />
|
||||
</a>
|
||||
@@ -55,31 +54,31 @@ Want to see the usage effects? Click the videos below 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1FMFyejExX" target="_blank">
|
||||
<picture>
|
||||
<img alt="The feel of the response speed" src="../images/demo9.png" /></picture>
|
||||
<img alt="响应速度感受" src="docs/images/demo9.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||
<picture>
|
||||
<img alt="The secret to speeding things up" src="../images/demo6.png" /></picture>
|
||||
<img alt="速度优化秘诀" src="docs/images/demo6.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1WEcxzFEAT" target="_blank">
|
||||
<picture>
|
||||
<img alt="Xiaozhi digital human with voice wake-up" src="../images/demo8.png" /></picture>
|
||||
<img alt="小智数字人 支持语音唤醒" src="docs/images/demo8.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CKVz6UEuB" target="_blank">
|
||||
<picture>
|
||||
<img alt="Device-to-device calling" src="../images/demo0.png" /></picture>
|
||||
<img alt="设备呼叫设备,打电话" src="docs/images/demo0.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||
<picture>
|
||||
<img alt="A complex medical scenario" src="../images/demo1.png" /></picture>
|
||||
<img alt="复杂医疗场景" src="docs/images/demo1.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -87,31 +86,31 @@ Want to see the usage effects? Click the videos below 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1VC96Y5EMH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Play music, check the weather, and read the news" src="../images/demo7.png" /></picture>
|
||||
<img alt="播放音乐查询天气播报新闻" src="docs/images/demo7.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12J7WzBEaH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Real-time interruption" src="../images/demo10.png" /></picture>
|
||||
<img alt="实时打断" src="docs/images/demo10.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Co76z7EvK" target="_blank">
|
||||
<picture>
|
||||
<img alt="Take a photo to identify objects" src="../images/demo12.png" /></picture>
|
||||
<img alt="拍照识物品" src="docs/images/demo12.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
<img alt="Turn home appliances on and off" src="../images/demo5.png" /></picture>
|
||||
<img alt="控制家电开关" src="docs/images/demo5.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Multi-command tasks" src="../images/demo11.png" /></picture>
|
||||
<img alt="多指令任务" src="docs/images/demo11.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -119,31 +118,31 @@ Want to see the usage effects? Click the videos below 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
|
||||
<picture>
|
||||
<img alt="MCP access point" src="../images/demo13.png" /></picture>
|
||||
<img alt="MCP接入点" src="docs/images/demo13.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
|
||||
<picture>
|
||||
<img alt="MQTT command delivery" src="../images/demo4.png" /></picture>
|
||||
<img alt="MQTT指令下发" src="docs/images/demo4.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<picture>
|
||||
<img alt="Voiceprint recognition" src="../images/demo14.png" /></picture>
|
||||
<img alt="声纹识别" src="docs/images/demo14.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Custom voice timbre" src="../images/demo2.png" /></picture>
|
||||
<img alt="自定义音色" src="docs/images/demo2.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="Talking in Cantonese" src="../images/demo3.png" /></picture>
|
||||
<img alt="使用粤语交流" src="docs/images/demo3.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -168,8 +167,8 @@ This project provides two deployment methods. Please choose based on your specif
|
||||
#### 🚀 Deployment Method Selection
|
||||
| Deployment Method | Features | Applicable Scenarios | Deployment Docs | Configuration Requirements | Video Tutorials |
|
||||
|---------|------|---------|---------|---------|---------|
|
||||
| **Simplified Installation** | Intelligent dialogue, single agent management | Low-configuration environments, data stored in config files, no database required | [①Docker Version](../Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Source Code Deployment](../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)| Local `FunASR` needs more memory; using APIs for everything keeps resource usage low | - |
|
||||
| **Full Module Installation** | Intelligent dialogue, multi-user management, multi-agent management, intelligent console interface operation | Complete functionality experience, data stored in database |[①Docker Version](../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](../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](../dev-ops-integration.md) | Local `FunASR` needs more memory plus database resources; using APIs for everything keeps resource usage low| [Local Source Code Startup Video Tutorial](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
| **Simplified Installation** | Intelligent dialogue, single agent management | Low-configuration environments, data stored in config files, no database required | [①Docker Version](../Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Source Code Deployment](../Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 cores 4GB if using `FunASR`, 2 cores 2GB if all APIs | - |
|
||||
| **Full Module Installation** | Intelligent dialogue, multi-user management, multi-agent management, intelligent console interface operation | Complete functionality experience, data stored in database |[①Docker Version](../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](../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](../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) |
|
||||
|
||||
For frequently asked questions and related tutorials, please refer to [this link](../FAQ.md)
|
||||
|
||||
@@ -210,15 +209,15 @@ This project provides the following testing tools to help you verify the system
|
||||
|
||||
| Tool Name | Location | Usage Method | Function Description |
|
||||
|:---:|:---|:---:|:---:|
|
||||
| Audio Interaction Test Tool | main》digital-human》index.html | In `main/digital-human`, install dependencies as its README says, then run `python start.py` and open `http://127.0.0.1:8006/index.html` | Tests audio playback and reception functions, verifies if Python-side audio processing is normal |
|
||||
| Model Response Test Tool | main》xiaozhi-server》performance_tester.py | Execute `python performance_tester.py` | Tests response speed of core modules such as ASR(speech recognition), LLM(large model), VLLM(vision model), TTS(speech synthesis) |
|
||||
| Audio Interaction Test Tool | main》digital-human》index.html | Run `python start.py` in `main/digital-human`, then open `http://127.0.0.1:8006/index.html` | Tests audio playback and reception functions, verifies if Python-side audio processing is normal |
|
||||
| Model Response Test Tool | main》xiaozhi-server》performance_tester.py | Execute `python performance_tester.py` | Tests response speed of three core modules: ASR(speech recognition), LLM(large model), VLLM(vision model), TTS(speech synthesis) |
|
||||
|
||||
> 💡 Note: When testing model speed, only models with configured keys will be tested.
|
||||
|
||||
---
|
||||
## Feature List ✨
|
||||
### Implemented ✅
|
||||

|
||||

|
||||
| Feature Module | Description |
|
||||
|:---:|:---|
|
||||
| Core Architecture | Based on [MQTT+UDP gateway](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mqtt-gateway-integration.md), WebSocket and HTTP servers, provides complete console management and authentication system |
|
||||
@@ -226,15 +225,15 @@ This project provides the following testing tools to help you verify the system
|
||||
| Voiceprint Recognition | Supports multi-user voiceprint registration, management, and recognition, processes in parallel with ASR, real-time speaker identity recognition and passes to LLM for personalized responses |
|
||||
| Intelligent Dialogue | Supports multiple LLM(large language models), implements intelligent dialogue |
|
||||
| Visual Perception | Supports multiple VLLM(vision large models), implements multimodal interaction |
|
||||
| Intent Recognition | Supports pluggable external LLM intent recognition and autonomous LLM function calling, provides plugin-based intent processing mechanism |
|
||||
| Intent Recognition | Supports LLM intent recognition, Function Call function calling, provides plugin-based intent processing mechanism |
|
||||
| Memory System | Supports local short-term memory, mem0ai interface memory, PowerMem intelligent memory, with memory summarization functionality |
|
||||
| Knowledge Base | Supports RAGFlow knowledge base, enabling LLM to judge whether to schedule the knowledge base after receiving the user's question, and then answer the question |
|
||||
| Tool Calling | Supports client IOT protocol, client MCP protocol, server MCP protocol, MCP access point protocol, custom tool functions |
|
||||
| Tool Calling | Supports client IOT protocol, client MCP protocol, server MCP protocol, MCP endpoint protocol, custom tool functions |
|
||||
| Command Delivery | Supports MCP command delivery to ESP32 devices via MQTT protocol from Smart Console |
|
||||
| Management Backend | Provides Web management interface, supports user management, system configuration and device management; Supports Simplified Chinese, Traditional Chinese, English, German, Vietnamese and Brazilian Portuguese display |
|
||||
| Testing Tools | Provides model response performance testing tools and audio interaction testing tools |
|
||||
| Management Backend | Provides Web management interface, supports user management, system configuration and device management; Supports Simplified Chinese, Traditional Chinese and English display |
|
||||
| Testing Tools | Provides performance testing tools, vision model testing tools, and audio interaction testing tools |
|
||||
| Deployment Support | Supports Docker deployment and local deployment, provides complete configuration file management |
|
||||
| Plugin System | Supports functional plugin extensions and custom plugin development; newly added plugin files are loaded on the next device connection, code changes to already loaded plugins require a service restart |
|
||||
| Plugin System | Supports functional plugin extensions, custom plugin development, and plugin hot-loading |
|
||||
|
||||
### Under Development 🚧
|
||||
|
||||
@@ -254,7 +253,7 @@ Xiaozhi is an ecosystem. When using this product, you can also check out other [
|
||||
|
||||
| Usage Method | Supported Platforms | Free Platforms |
|
||||
|:---:|:---:|:---:|
|
||||
| OpenAI interface calls | Alibaba Bailian, Volcano Engine, DeepSeek, Zhipu, Gemini, iFLYTEK, LM Studio | Zhipu, Gemini |
|
||||
| OpenAI interface calls | Alibaba Bailian, Volcano Engine, DeepSeek, Zhipu, Gemini, iFLYTEK | Zhipu, Gemini |
|
||||
| Ollama interface calls | Ollama | - |
|
||||
| Dify interface calls | Dify | - |
|
||||
| FastGPT interface calls | FastGPT | - |
|
||||
@@ -280,8 +279,8 @@ In fact, any VLLM that supports OpenAI interface calls can be integrated and use
|
||||
|
||||
| Usage Method | Supported Platforms | Free Platforms |
|
||||
|:---:|:---:|:---:|
|
||||
| Interface calls | EdgeTTS, iFLYTEK, Volcano Engine, Tencent Cloud, Alibaba Cloud and Bailian, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, CustomTTS, OpenAITTS, MinimaxTTS | EdgeTTS, CosyVoiceSiliconflow(partial) |
|
||||
| Local services | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, IndexStreamTTS, PaddleSpeechTTS | IndexStreamTTS, PaddleSpeechTTS, FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3 |
|
||||
| Interface calls | EdgeTTS, iFLYTEK, Volcano Engine, Tencent Cloud, Alibaba Cloud and Bailian, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, GizwitsTTS, ACGNTTS, OpenAITTS, Lingxi Streaming TTS, MinimaxTTS | Lingxi Streaming TTS, EdgeTTS, CosyVoiceSiliconflow(partial) |
|
||||
| Local services | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, Index-TTS, PaddleSpeech | Index-TTS, PaddleSpeech, FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3 |
|
||||
|
||||
---
|
||||
|
||||
@@ -297,8 +296,8 @@ In fact, any VLLM that supports OpenAI interface calls can be integrated and use
|
||||
|
||||
| Usage Method | Supported Platforms | Free Platforms |
|
||||
|:---:|:---:|:---:|
|
||||
| Local use | FunASR, SherpaASR, SherpaParaformerASR, VoskASR | FunASR, SherpaASR, SherpaParaformerASR, VoskASR |
|
||||
| Interface calls | FunASRServer, Volcano Engine, iFLYTEK, Tencent Cloud, Alibaba Cloud and Bailian, Baidu Cloud, OpenAI ASR, GroqASR, Qwen3ASRFlash | FunASRServer |
|
||||
| Local use | FunASR, SherpaASR | FunASR, SherpaASR |
|
||||
| Interface calls | FunASRServer, Volcano Engine, iFLYTEK, Tencent Cloud, Alibaba Cloud, Baidu Cloud, OpenAI ASR | FunASRServer |
|
||||
|
||||
---
|
||||
|
||||
@@ -306,9 +305,7 @@ In fact, any VLLM that supports OpenAI interface calls can be integrated and use
|
||||
|
||||
| Usage Method | Supported Platforms | Free Platforms |
|
||||
|:---:|:---:|:---:|
|
||||
| Interface calls | Voiceprint service (voiceprint-api) | - |
|
||||
|
||||
Voiceprint recognition requires a separately deployed [voiceprint-api](https://github.com/xinnan-tech/voiceprint-api) service (based on 3D-Speaker) and MySQL. See the [voiceprint integration guide](../voiceprint-integration.md).
|
||||
| Local use | 3D-Speaker | 3D-Speaker |
|
||||
|
||||
---
|
||||
|
||||
@@ -354,10 +351,11 @@ Voiceprint recognition requires a separately deployed [voiceprint-api](https://g
|
||||
| <img src="../images/logo_contributors.png" width="160"> | [Code Contributors](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | Thanks to [all code contributors](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors), your efforts have made the project more robust and powerful. |
|
||||
|
||||
|
||||
<a href="https://www.star-history.com/?repos=xinnan-tech%2Fxiaozhi-esp32-server&type=date&legend=top-left">
|
||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&theme=dark&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -1,363 +0,0 @@
|
||||
[](https://github.com/xinnan-tech/xiaozhi-esp32-server)
|
||||
|
||||
<h1 align="center">Servicio backend Xiaozhi xiaozhi-esp32-server</h1>
|
||||
|
||||
<p align="center">
|
||||
Este proyecto desarrolla sistemas de hardware y software para terminales inteligentes a partir de la teoría y la tecnología de la inteligencia simbiótica humano-máquina<br/>y ofrece el servicio backend del proyecto de hardware inteligente de código abierto
|
||||
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a><br/>
|
||||
Implementado en Python, Java y Vue según el <a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">protocolo de comunicación Xiaozhi</a><br/>
|
||||
Compatible con el protocolo MQTT+UDP, el protocolo WebSocket, puntos de acceso MCP, reconocimiento de huella de voz y bases de conocimiento
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="../FAQ.md">Preguntas frecuentes</a>
|
||||
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">Informar de problemas</a>
|
||||
· <a href="../../README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">Documentación de despliegue</a>
|
||||
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">Notas de la versión</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="../../README.md"><img alt="简体中文版自述文件" src="https://img.shields.io/badge/简体中文-DFE0E5"></a>
|
||||
<a href="./README_en.md"><img alt="README in English" src="https://img.shields.io/badge/English-DFE0E5"></a>
|
||||
<a href="./README_vi.md"><img alt="Tiếng Việt" src="https://img.shields.io/badge/Tiếng Việt-DFE0E5"></a>
|
||||
<a href="./README_de.md"><img alt="Deutsch" src="https://img.shields.io/badge/Deutsch-DFE0E5"></a>
|
||||
<a href="./README_pt_BR.md"><img alt="Português (Brasil)" src="https://img.shields.io/badge/Português (Brasil)-DFE0E5"></a>
|
||||
<a href="./README_es.md"><img alt="Español" src="https://img.shields.io/badge/Español-DBEDFA"></a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
|
||||
<img alt="GitHub Contributors" src="https://img.shields.io/github/v/release/xinnan-tech/xiaozhi-esp32-server?logo=docker" />
|
||||
</a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/LICENSE">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/badge/license-MIT-white?labelColor=black" />
|
||||
</a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server">
|
||||
<img alt="stars" src="https://img.shields.io/github/stars/xinnan-tech/xiaozhi-esp32-server?color=ffcb47&labelColor=black" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Liderado por el equipo del profesor Siyuan Liu (Universidad Tecnológica del Sur de China)
|
||||
</br>
|
||||
刘思源教授团队主导研发(华南理工大学)
|
||||
</br>
|
||||
<img src="../images/hnlg.jpg" alt="Universidad Tecnológica del Sur de China (华南理工大学)" width="50%">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## A quién va dirigido 👥
|
||||
|
||||
Este proyecto debe usarse junto con hardware ESP32. Si ya ha comprado hardware relacionado con ESP32, se ha conectado con éxito al servicio backend desplegado por Brother Xia (虾哥) y quiere montar por su cuenta su propio servicio backend `xiaozhi-esp32`, este proyecto encaja con lo que busca.
|
||||
|
||||
¿Quiere ver cómo se usa? Pulse los vídeos 🎥
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1FMFyejExX" target="_blank">
|
||||
<picture>
|
||||
<img alt="Sensación de la velocidad de respuesta" src="../images/demo9.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||
<picture>
|
||||
<img alt="El secreto para optimizar la velocidad" src="../images/demo6.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1WEcxzFEAT" target="_blank">
|
||||
<picture>
|
||||
<img alt="Humano digital Xiaozhi con activación por voz" src="../images/demo8.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CKVz6UEuB" target="_blank">
|
||||
<picture>
|
||||
<img alt="Llamadas de dispositivo a dispositivo" src="../images/demo0.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||
<picture>
|
||||
<img alt="Escenario médico complejo" src="../images/demo1.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1VC96Y5EMH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Reproducir música, consultar el tiempo y leer noticias" src="../images/demo7.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12J7WzBEaH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Interrupción en tiempo real" src="../images/demo10.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Co76z7EvK" target="_blank">
|
||||
<picture>
|
||||
<img alt="Reconocer objetos con una foto" src="../images/demo12.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
<img alt="Controlar el encendido de electrodomésticos" src="../images/demo5.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Tareas con varias instrucciones" src="../images/demo11.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
|
||||
<picture>
|
||||
<img alt="Punto de acceso MCP" src="../images/demo13.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
|
||||
<picture>
|
||||
<img alt="Envío de instrucciones MQTT" src="../images/demo4.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<picture>
|
||||
<img alt="Reconocimiento de huella de voz" src="../images/demo14.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Tono de voz personalizado" src="../images/demo2.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="Conversación en cantonés" src="../images/demo3.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Advertencias ⚠️
|
||||
|
||||
1. Este proyecto es software de código abierto. Este software no mantiene relación comercial alguna con los proveedores de API de terceros a los que se conecta (incluidos, entre otros, plataformas de reconocimiento de voz, modelos de lenguaje y síntesis de voz), y no ofrece ninguna garantía sobre la calidad del servicio de dichos proveedores ni sobre la seguridad de sus fondos. Se recomienda elegir de forma preferente proveedores que cuenten con las licencias de actividad correspondientes y leer con atención sus condiciones de servicio y su política de privacidad. Este software no custodia ninguna clave de cuenta, no interviene en el movimiento de fondos y no asume el riesgo de pérdida del saldo recargado.
|
||||
|
||||
2. Las funciones de este proyecto no están completas y no ha superado una evaluación de seguridad de red. No lo utilice en entornos de producción. Si despliega este proyecto en una red pública con fines de aprendizaje, aplique las protecciones necesarias.
|
||||
|
||||
---
|
||||
|
||||
## Documentación de despliegue
|
||||
|
||||

|
||||
|
||||
Este proyecto ofrece dos formas de despliegue. Elija según lo que necesite:
|
||||
|
||||
#### 🚀 Elección del método de despliegue
|
||||
| Método de despliegue | Características | Escenarios | Documentación de despliegue | Requisitos | Tutoriales en vídeo |
|
||||
|---------|------|---------|---------|---------|---------|
|
||||
| **Instalación simplificada** | Diálogo inteligente, gestión de un solo agente | Entornos con pocos recursos; los datos se guardan en archivos de configuración y no hace falta base de datos | [①Versión Docker](../Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Despliegue desde el código fuente](../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)| Ejecutar `FunASR` en local consume bastante memoria; si todo va por API, el consumo de recursos es bajo | - |
|
||||
| **Instalación de todos los módulos** | Diálogo inteligente, gestión multiusuario, gestión de varios agentes, uso de la interfaz de la consola inteligente | Experiencia con todas las funciones; los datos se guardan en una base de datos |[①Versión Docker](../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) / [②Despliegue desde el código fuente](../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) / [③Tutorial de actualización automática desde el código fuente](../dev-ops-integration.md) | Ejecutar `FunASR` en local consume bastante memoria, y además requiere recursos de base de datos; si todo va por API, el consumo de recursos es bajo | [Tutorial en vídeo para arrancar el código fuente en local](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
|
||||
Las preguntas frecuentes y los tutoriales relacionados están en [este enlace](../FAQ.md)
|
||||
|
||||
> 💡 Nota: a continuación hay una plataforma de prueba desplegada con el código más reciente. Si lo necesita, puede grabar el firmware y probarla. La concurrencia es de 6 usuarios y los datos se borran cada día.
|
||||
|
||||
```
|
||||
Dirección de la consola inteligente: https://2662r3426b.vicp.fun
|
||||
Consola inteligente (versión H5): https://2662r3426b.vicp.fun/h5/index.html
|
||||
|
||||
Herramienta de prueba del servicio: https://2662r3426b.vicp.fun/test/
|
||||
Dirección de la interfaz OTA: https://2662r3426b.vicp.fun/xiaozhi/ota/
|
||||
Dirección de la interfaz WebSocket: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
```
|
||||
|
||||
#### 🚩 Descripción y recomendaciones de configuración
|
||||
> [!Note]
|
||||
> Este proyecto ofrece dos esquemas de configuración:
|
||||
>
|
||||
> 1. `Configuración básica y totalmente gratuita`: adecuada para uso personal y doméstico. Todos los componentes usan opciones gratuitas y no hay que pagar nada más.
|
||||
>
|
||||
> 2. `Configuración en streaming`: adecuada para demostraciones, formación y escenarios con más de 2 usuarios concurrentes. Usa procesamiento en streaming, responde antes y se siente mejor.
|
||||
>
|
||||
> Desde la versión `0.5.2`, el proyecto admite la configuración en streaming. Frente a las versiones anteriores, el tiempo de respuesta mejora unos `2,5 segundos` y la experiencia de uso mejora de forma notable.
|
||||
|
||||
| Módulo | Configuración básica y totalmente gratuita | Configuración en streaming |
|
||||
|:---:|:---:|:---:|
|
||||
| ASR (reconocimiento de voz) | FunASR (local) | 👍XunfeiStreamASR (iFLYTEK en streaming) |
|
||||
| LLM (modelo de lenguaje) | glm-4-flash (Zhipu) | 👍qwen-flash (Alibaba Bailian) |
|
||||
| VLLM (modelo de visión) | glm-4v-flash (Zhipu) | 👍qwen3.5-flash (Alibaba Bailian) |
|
||||
| TTS (síntesis de voz) | EdgeTTS (Microsoft) | 👍HuoshanDoubleStreamTTS (Volcano en streaming) |
|
||||
| Intent (reconocimiento de intención) | function_call (llamada a funciones) | function_call (llamada a funciones) |
|
||||
| Memory (memoria) | mem_local_short (memoria local a corto plazo) | mem_local_short (memoria local a corto plazo) |
|
||||
|
||||
Si le importa el tiempo que tarda cada componente, consulte el [informe de rendimiento de los componentes de Xiaozhi](https://github.com/xinnan-tech/xiaozhi-performance-research) y pruebe en su propio entorno con el método que describe el informe.
|
||||
|
||||
#### 🔧 Herramientas de prueba
|
||||
Este proyecto incluye las siguientes herramientas para comprobar el sistema y elegir un modelo adecuado:
|
||||
|
||||
| Herramienta | Ubicación | Uso | Qué hace |
|
||||
|:---:|:---|:---:|:---:|
|
||||
| Prueba de interacción de audio | main》digital-human》index.html | En `main/digital-human`, instale las dependencias como indica su README, después ejecute `python start.py` y abra `http://127.0.0.1:8006/index.html` | Prueba la reproducción y la recepción de audio, y comprueba que el procesamiento de audio en Python funciona |
|
||||
| Prueba de respuesta de los modelos | main》xiaozhi-server》performance_tester.py | Ejecute `python performance_tester.py` | Mide la velocidad de respuesta de los principales módulos: ASR (reconocimiento de voz), LLM (modelo de lenguaje), VLLM (modelo de visión) y TTS (síntesis de voz) |
|
||||
|
||||
> 💡 Nota: al medir la velocidad de los modelos, solo se prueban los modelos que tienen una clave configurada.
|
||||
|
||||
---
|
||||
## Lista de funciones ✨
|
||||
### Implementado ✅
|
||||

|
||||
| Módulo | Descripción |
|
||||
|:---:|:---|
|
||||
| Arquitectura principal | Basada en la [pasarela MQTT+UDP](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mqtt-gateway-integration.md) y en servidores WebSocket y HTTP; ofrece un sistema completo de gestión de consola y de autenticación |
|
||||
| Interacción por voz | Admite ASR en streaming (reconocimiento de voz), TTS en streaming (síntesis de voz) y VAD (detección de actividad de voz), además de reconocimiento multilingüe y procesamiento de voz |
|
||||
| Huella de voz | Admite el registro, la gestión y el reconocimiento de la huella de voz de varios usuarios; se procesa en paralelo con el ASR, identifica en tiempo real quién habla y se lo pasa al LLM para una respuesta personalizada |
|
||||
| Diálogo inteligente | Admite varios LLM (modelos de lenguaje) para mantener un diálogo inteligente |
|
||||
| Percepción visual | Admite varios VLLM (modelos de visión) para una interacción multimodal |
|
||||
| Reconocimiento de intención | Admite el reconocimiento de intención con un modelo de lenguaje (LLM) externo y conectable, y la llamada autónoma a funciones del modelo, y ofrece un mecanismo de procesamiento de intenciones mediante plugins |
|
||||
| Sistema de memoria | Admite memoria local a corto plazo, memoria por la interfaz mem0ai y memoria inteligente PowerMem, con resumen de la memoria |
|
||||
| Base de conocimiento | Admite la base de conocimiento RAGFlow, para que el modelo decida si debe consultarla antes de responder |
|
||||
| Llamada a herramientas | Admite el protocolo IOT del cliente, el protocolo MCP del cliente, el protocolo MCP del servidor, el protocolo de punto de acceso MCP y funciones de herramientas personalizadas |
|
||||
| Envío de instrucciones | Apoyado en el protocolo MQTT, permite enviar instrucciones MCP desde la consola inteligente a los dispositivos ESP32 |
|
||||
| Backend de administración | Ofrece una interfaz web de administración, con gestión de usuarios, configuración del sistema y gestión de dispositivos. La interfaz se muestra en chino simplificado, chino tradicional, inglés, alemán, vietnamita y portugués de Brasil |
|
||||
| Herramientas de prueba | Incluye herramientas de prueba de rendimiento y de interacción de audio |
|
||||
| Soporte de despliegue | Admite el despliegue con Docker y el despliegue local, y ofrece una gestión completa de los archivos de configuración |
|
||||
| Sistema de plugins | Admite extensiones de plugins funcionales y el desarrollo de plugins propios; los nuevos archivos de plugin se cargan automáticamente en la siguiente conexión del dispositivo, y los cambios en el código de un plugin ya cargado requieren reiniciar el servicio |
|
||||
|
||||
### En desarrollo 🚧
|
||||
|
||||
Para ver el avance concreto del plan de desarrollo, [pulse aquí](https://github.com/users/xinnan-tech/projects/3). Las preguntas frecuentes y los tutoriales relacionados están en [este enlace](../FAQ.md)
|
||||
|
||||
Si desarrolla software, aquí tiene una [carta abierta a quienes desarrollan](../contributor_open_letter.md). La invitación está hecha.
|
||||
|
||||
---
|
||||
|
||||
## Ecosistema del producto 👬
|
||||
Xiaozhi es un ecosistema. Al usar este producto, también puede ver otros [proyectos destacados](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) de este ecosistema
|
||||
|
||||
---
|
||||
|
||||
## Plataformas y componentes compatibles 📋
|
||||
### LLM, modelos de lenguaje
|
||||
|
||||
| Forma de uso | Plataformas compatibles | Plataformas gratuitas |
|
||||
|:---:|:---:|:---:|
|
||||
| Llamadas por la interfaz OpenAI | Alibaba Bailian, Volcano Engine, DeepSeek, Zhipu, Gemini, iFLYTEK, LM Studio | Zhipu, Gemini |
|
||||
| Llamadas por la interfaz Ollama | Ollama | - |
|
||||
| Llamadas por la interfaz Dify | Dify | - |
|
||||
| Llamadas por la interfaz FastGPT | FastGPT | - |
|
||||
| Llamadas por la interfaz Coze | Coze | - |
|
||||
| Llamadas por la interfaz Xinference | Xinference | - |
|
||||
| Llamadas por la interfaz HomeAssistant | HomeAssistant | - |
|
||||
|
||||
En la práctica, se puede integrar cualquier LLM que admita llamadas por la interfaz OpenAI.
|
||||
|
||||
---
|
||||
|
||||
### VLLM, modelos de visión
|
||||
|
||||
| Forma de uso | Plataformas compatibles | Plataformas gratuitas |
|
||||
|:---:|:---:|:---:|
|
||||
| Llamadas por la interfaz OpenAI | Alibaba Bailian, Zhipu ChatGLMVLLM | Zhipu ChatGLMVLLM |
|
||||
|
||||
En la práctica, se puede integrar cualquier VLLM que admita llamadas por la interfaz OpenAI.
|
||||
|
||||
---
|
||||
|
||||
### TTS, síntesis de voz
|
||||
|
||||
| Forma de uso | Plataformas compatibles | Plataformas gratuitas |
|
||||
|:---:|:---:|:---:|
|
||||
| Llamadas por interfaz | EdgeTTS, iFLYTEK, Volcano Engine, Tencent Cloud, Alibaba Cloud y Bailian, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, CustomTTS, OpenAITTS, MinimaxTTS | EdgeTTS, CosyVoiceSiliconflow (parcial) |
|
||||
| Servicio local | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, IndexStreamTTS, PaddleSpeechTTS | IndexStreamTTS, PaddleSpeechTTS, FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3 |
|
||||
|
||||
---
|
||||
|
||||
### VAD, detección de actividad de voz
|
||||
|
||||
| Tipo | Plataforma | Forma de uso | Precio | Notas |
|
||||
|:---:|:---------:|:----:|:----:|:--:|
|
||||
| VAD | SileroVAD | Uso local | Gratuito | |
|
||||
|
||||
---
|
||||
|
||||
### ASR, reconocimiento de voz
|
||||
|
||||
| Forma de uso | Plataformas compatibles | Plataformas gratuitas |
|
||||
|:---:|:---:|:---:|
|
||||
| Uso local | FunASR, SherpaASR, SherpaParaformerASR, VoskASR | FunASR, SherpaASR, SherpaParaformerASR, VoskASR |
|
||||
| Llamadas por interfaz | FunASRServer, Volcano Engine, iFLYTEK, Tencent Cloud, Alibaba Cloud y Bailian, Baidu Cloud, OpenAI ASR, GroqASR, Qwen3ASRFlash | FunASRServer |
|
||||
|
||||
---
|
||||
|
||||
### Voiceprint, huella de voz
|
||||
|
||||
| Forma de uso | Plataformas compatibles | Plataformas gratuitas |
|
||||
|:---:|:---:|:---:|
|
||||
| Llamadas por interfaz | Servicio de reconocimiento de huella de voz (voiceprint-api) | - |
|
||||
|
||||
El reconocimiento de huella de voz requiere desplegar por separado el servicio [voiceprint-api](https://github.com/xinnan-tech/voiceprint-api) (basado en 3D-Speaker), que depende de MySQL; consulte la [guía de integración de la huella de voz](../voiceprint-integration.md)
|
||||
|
||||
---
|
||||
|
||||
### Memory, almacenamiento de memoria
|
||||
|
||||
| Tipo | Plataforma | Forma de uso | Precio | Notas |
|
||||
|:------:|:---------------:|:----:|:---------:|:--:|
|
||||
| Memory | mem0ai | Llamadas por interfaz | Cuota de 1000 veces al mes | |
|
||||
| Memory | [powermem](../powermem-integration.md) | Resumen local | Depende del LLM y de la base de datos | OceanBase de código abierto; admite búsqueda inteligente |
|
||||
| Memory | mem_local_short | Resumen local | Gratuito | |
|
||||
| Memory | nomem | Modo sin memoria | Gratuito | |
|
||||
|
||||
---
|
||||
|
||||
### Intent, reconocimiento de intención
|
||||
|
||||
| Tipo | Plataforma | Forma de uso | Precio | Notas |
|
||||
|:------:|:-------------:|:----:|:-------:|:---------------------:|
|
||||
| Intent | intent_llm | Llamadas por interfaz | Según el precio del LLM | Reconoce la intención mediante el modelo de lenguaje (LLM); gran capacidad de generalización |
|
||||
| Intent | function_call | Llamadas por interfaz | Según el precio del LLM | Completa la intención con llamadas a funciones del modelo; rápido y con buen resultado |
|
||||
| Intent | nointent | Modo sin intención | Gratuito | No reconoce la intención y devuelve directamente el resultado del diálogo |
|
||||
|
||||
---
|
||||
|
||||
### Rag, generación aumentada por recuperación
|
||||
|
||||
| Tipo | Plataforma | Forma de uso | Precio | Notas |
|
||||
|:------:|:-------------:|:----:|:-------:|:---------------------:|
|
||||
| Rag | ragflow | Llamadas por interfaz | Cobrado según los tokens consumidos en el troceado y la segmentación | Usa la generación aumentada por recuperación de RagFlow para dar respuestas más precisas |
|
||||
|
||||
---
|
||||
|
||||
## Agradecimientos 🙏
|
||||
|
||||
| Logo | Proyecto o empresa | Descripción |
|
||||
|:---:|:---:|:---|
|
||||
| <img src="../images/logo_bailing.png" width="160"> | [Robot de diálogo por voz Bailing (百聆语音对话机器人)](https://github.com/wwbin2017/bailing) | Este proyecto parte del [robot de diálogo por voz Bailing](https://github.com/wwbin2017/bailing) y se construye sobre esa base |
|
||||
| <img src="../images/logo_tenclass.png" width="160"> | [Tenclass (十方融海)](https://www.tenclass.com/) | Gracias a [Tenclass](https://www.tenclass.com/) por definir el protocolo de comunicación estándar del ecosistema Xiaozhi, las soluciones de compatibilidad con varios dispositivos y los ejemplos prácticos de alta concurrencia, y por el apoyo de documentación técnica de todo el recorrido de este proyecto |
|
||||
| <img src="../images/logo_xuanfeng.png" width="160"> | [Xuanfeng Technology (玄凤科技)](https://github.com/Eric0308) | Gracias a [Xuanfeng Technology](https://github.com/Eric0308) por el código del marco de llamada a funciones, del protocolo de comunicación MCP y del mecanismo de llamadas basado en plugins. Con un sistema estandarizado de despacho de instrucciones y capacidad de ampliación dinámica, mejora de forma clara la eficiencia de interacción y la extensibilidad de los dispositivos de frontend (IoT) |
|
||||
| <img src="../images/logo_junsen.png" width="160"> | [huangjunsen](https://github.com/huangjunsen0406) | Gracias a [huangjunsen](https://github.com/huangjunsen0406) por el módulo `consola inteligente móvil`, que permite controlar con eficacia dispositivos móviles de varias plataformas e interactuar en tiempo real, y mejora mucho la comodidad de uso y la eficiencia de gestión en escenarios móviles |
|
||||
| <img src="../images/logo_huiyuan.png" width="160"> | [Huiyuan Design (汇远设计)](http://ui.kwd988.net/) | Gracias a [Huiyuan Design](http://ui.kwd988.net/) por la solución visual de este proyecto, y por poner al servicio de la experiencia de uso la práctica de diseño acumulada con más de mil empresas |
|
||||
| <img src="../images/logo_qinren.png" width="160"> | [Xi'an Qinren Information Technology (西安勤人信息科技)](https://www.029app.com/) | Gracias a [Xi'an Qinren Information Technology](https://www.029app.com/) por profundizar el sistema visual de este proyecto y mantener un estilo coherente y ampliable en distintos escenarios |
|
||||
| <img src="../images/logo_contributors.png" width="160"> | [Quienes contribuyen código](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | Gracias a [todas las personas que contribuyen código](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors). Su trabajo hace el proyecto más sólido y más capaz. |
|
||||
|
||||
|
||||
<a href="https://www.star-history.com/?repos=xinnan-tech%2Fxiaozhi-esp32-server&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&theme=dark&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
</picture>
|
||||
</a>
|
||||
+37
-39
@@ -22,7 +22,6 @@ Suporte ao protocolo MQTT+UDP, protocolo WebSocket, ponto de acesso MCP, reconhe
|
||||
<a href="./README_vi.md"><img alt="Tiếng Việt" src="https://img.shields.io/badge/Tiếng Việt-DFE0E5"></a>
|
||||
<a href="./README_de.md"><img alt="Deutsch" src="https://img.shields.io/badge/Deutsch-DFE0E5"></a>
|
||||
<a href="./README_pt_BR.md"><img alt="Português (Brasil)" src="https://img.shields.io/badge/Português (Brasil)-DBEDFA"></a>
|
||||
<a href="./README_es.md"><img alt="Español" src="https://img.shields.io/badge/Español-DFE0E5"></a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
|
||||
<img alt="GitHub Contributors" src="https://img.shields.io/github/v/release/xinnan-tech/xiaozhi-esp32-server?logo=docker" />
|
||||
</a>
|
||||
@@ -55,31 +54,31 @@ Quer ver os efeitos de uso? Clique nos vídeos abaixo 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1FMFyejExX" target="_blank">
|
||||
<picture>
|
||||
<img alt="A sensação da velocidade de resposta" src="../images/demo9.png" /></picture>
|
||||
<img alt="响应速度感受" src="docs/images/demo9.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||
<picture>
|
||||
<img alt="O segredo para otimizar a velocidade" src="../images/demo6.png" /></picture>
|
||||
<img alt="速度优化秘诀" src="docs/images/demo6.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1WEcxzFEAT" target="_blank">
|
||||
<picture>
|
||||
<img alt="Humano digital Xiaozhi com ativação por voz" src="../images/demo8.png" /></picture>
|
||||
<img alt="小智数字人 支持语音唤醒" src="docs/images/demo8.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CKVz6UEuB" target="_blank">
|
||||
<picture>
|
||||
<img alt="Chamadas de dispositivo para dispositivo" src="../images/demo0.png" /></picture>
|
||||
<img alt="设备呼叫设备,打电话" src="docs/images/demo0.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||
<picture>
|
||||
<img alt="Cenário médico complexo" src="../images/demo1.png" /></picture>
|
||||
<img alt="复杂医疗场景" src="docs/images/demo1.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -87,31 +86,31 @@ Quer ver os efeitos de uso? Clique nos vídeos abaixo 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1VC96Y5EMH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Reproduzir música, consultar o tempo e ler as notícias" src="../images/demo7.png" /></picture>
|
||||
<img alt="播放音乐查询天气播报新闻" src="docs/images/demo7.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12J7WzBEaH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Interrupção em tempo real" src="../images/demo10.png" /></picture>
|
||||
<img alt="实时打断" src="docs/images/demo10.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Co76z7EvK" target="_blank">
|
||||
<picture>
|
||||
<img alt="Reconhecer objetos com uma foto" src="../images/demo12.png" /></picture>
|
||||
<img alt="拍照识物品" src="docs/images/demo12.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
<img alt="Controlar o acionamento de eletrodomésticos" src="../images/demo5.png" /></picture>
|
||||
<img alt="控制家电开关" src="docs/images/demo5.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Tarefas com vários comandos" src="../images/demo11.png" /></picture>
|
||||
<img alt="多指令任务" src="docs/images/demo11.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -119,31 +118,31 @@ Quer ver os efeitos de uso? Clique nos vídeos abaixo 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
|
||||
<picture>
|
||||
<img alt="Ponto de acesso MCP" src="../images/demo13.png" /></picture>
|
||||
<img alt="MCP接入点" src="docs/images/demo13.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
|
||||
<picture>
|
||||
<img alt="Envio de comandos MQTT" src="../images/demo4.png" /></picture>
|
||||
<img alt="MQTT指令下发" src="docs/images/demo4.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<picture>
|
||||
<img alt="Reconhecimento de impressão vocal" src="../images/demo14.png" /></picture>
|
||||
<img alt="声纹识别" src="docs/images/demo14.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Tom de voz personalizado" src="../images/demo2.png" /></picture>
|
||||
<img alt="自定义音色" src="docs/images/demo2.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="Conversa em cantonês" src="../images/demo3.png" /></picture>
|
||||
<img alt="使用粤语交流" src="docs/images/demo3.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -168,8 +167,8 @@ Este projeto oferece dois métodos de implantação. Por favor, escolha de acord
|
||||
#### 🚀 Seleção do Método de Implantação
|
||||
| Método de Implantação | Funcionalidades | Cenários Aplicáveis | Documentação de Implantação | Requisitos de Configuração | Tutoriais em Vídeo |
|
||||
|---------|------|---------|---------|---------|---------|
|
||||
| **Instalação Simplificada** | Diálogo inteligente, gerenciamento de agente único | Ambientes de baixa configuração, dados armazenados em arquivos de configuração, sem necessidade de banco de dados | [①Versão Docker](../Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Implantação via Código-Fonte](../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` local exige mais memória; usar apenas APIs mantém o consumo de recursos baixo | - |
|
||||
| **Instalação de Módulo Completo** | Diálogo inteligente, gerenciamento multiusuário, gerenciamento de múltiplos agentes, operação de interface do console inteligente | Experiência com funcionalidade completa, dados armazenados em banco de dados |[①Versão Docker](../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) / [②Implantação via Código-Fonte](../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) / [③Tutorial de Atualização Automática via Código-Fonte](../dev-ops-integration.md) | `FunASR` local exige mais memória e recursos de banco de dados; usar apenas APIs mantém o consumo de recursos baixo| [Tutorial em Vídeo de Inicialização via Código-Fonte Local](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
| **Instalação Simplificada** | Diálogo inteligente, gerenciamento de agente único | Ambientes de baixa configuração, dados armazenados em arquivos de configuração, sem necessidade de banco de dados | [①Versão Docker](../Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Implantação via Código-Fonte](../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 núcleos 4GB se usar `FunASR`, 2 núcleos 2GB se todas APIs | - |
|
||||
| **Instalação de Módulo Completo** | Diálogo inteligente, gerenciamento multiusuário, gerenciamento de múltiplos agentes, operação de interface do console inteligente | Experiência com funcionalidade completa, dados armazenados em banco de dados |[①Versão Docker](../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) / [②Implantação via Código-Fonte](../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) / [③Tutorial de Atualização Automática via Código-Fonte](../dev-ops-integration.md) | 4 núcleos 8GB se usar `FunASR`, 2 núcleos 4GB se todas APIs| [Tutorial em Vídeo de Inicialização via Código-Fonte Local](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
|
||||
Perguntas frequentes e tutoriais relacionados podem ser consultados [neste link](../FAQ.md)
|
||||
|
||||
@@ -210,15 +209,15 @@ Este projeto fornece as seguintes ferramentas de teste para ajudá-lo a verifica
|
||||
|
||||
| Nome da Ferramenta | Localização | Método de Uso | Descrição da Função |
|
||||
|:---:|:---|:---:|:---:|
|
||||
| Ferramenta de Teste de Interação por Áudio | main》digital-human》index.html | Em `main/digital-human`, instale as dependências como indica o README, depois execute `python start.py` e abra `http://127.0.0.1:8006/index.html` | Testa as funções de reprodução e recepção de áudio, verifica se o processamento de áudio no lado Python está normal |
|
||||
| Ferramenta de Teste de Resposta de Modelo | main》xiaozhi-server》performance_tester.py | Execute `python performance_tester.py` | Testa a velocidade de resposta de módulos principais como ASR(reconhecimento de fala), LLM(modelo de linguagem), VLLM(modelo de visão), TTS(síntese de voz) |
|
||||
| Ferramenta de Teste de Interação por Áudio | main》digital-human》index.html | Execute `python start.py` em `main/digital-human` e depois abra `http://127.0.0.1:8006/index.html` | Testa as funções de reprodução e recepção de áudio, verifica se o processamento de áudio no lado Python está normal |
|
||||
| Ferramenta de Teste de Resposta de Modelo | main》xiaozhi-server》performance_tester.py | Execute `python performance_tester.py` | Testa a velocidade de resposta dos três módulos principais: ASR(reconhecimento de fala), LLM(modelo de linguagem), VLLM(modelo de visão), TTS(síntese de voz) |
|
||||
|
||||
> 💡 Nota: Ao testar a velocidade dos modelos, apenas os modelos com chaves configuradas serão testados.
|
||||
|
||||
---
|
||||
## Lista de Funcionalidades ✨
|
||||
### Implementado ✅
|
||||

|
||||

|
||||
| Módulo de Funcionalidade | Descrição |
|
||||
|:---:|:---|
|
||||
| Arquitetura Principal | Baseado em [gateway MQTT+UDP](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mqtt-gateway-integration.md), servidores WebSocket e HTTP, fornece sistema completo de gerenciamento de console e autenticação |
|
||||
@@ -226,15 +225,15 @@ Este projeto fornece as seguintes ferramentas de teste para ajudá-lo a verifica
|
||||
| Reconhecimento de Impressão Vocal | Suporta registro, gerenciamento e reconhecimento de impressão vocal de múltiplos usuários, processa em paralelo com o ASR, reconhecimento de identidade do falante em tempo real e repassa ao LLM para respostas personalizadas |
|
||||
| Diálogo Inteligente | Suporta múltiplos LLM (modelos de linguagem de grande porte), implementa diálogo inteligente |
|
||||
| Percepção Visual | Suporta múltiplos VLLM (modelos de visão de grande porte), implementa interação multimodal |
|
||||
| Reconhecimento de Intenção | Suporta reconhecimento de intenção por LLM externo e conectável, chamada de função autônoma do modelo de linguagem, fornece mecanismo de processamento de intenção baseado em plugins |
|
||||
| Reconhecimento de Intenção | Suporta reconhecimento de intenção por LLM, Function Call (chamada de função), fornece mecanismo de processamento de intenção baseado em plugins |
|
||||
| Sistema de Memória | Suporta memória local de curto prazo, memória via interface mem0ai, memória inteligente PowerMem, com funcionalidade de resumo de memória |
|
||||
| Base de Conhecimento | Suporta base de conhecimento RAGFlow, permitindo que o LLM julgue se deve acionar a base de conhecimento após receber a pergunta do usuário, e então responda à pergunta |
|
||||
| Chamada de Ferramentas | Suporta protocolo IOT do cliente, protocolo MCP do cliente, protocolo MCP do servidor, protocolo de ponto de acesso MCP, funções de ferramentas personalizadas |
|
||||
| Chamada de Ferramentas | Suporta protocolo IOT do cliente, protocolo MCP do cliente, protocolo MCP do servidor, protocolo de endpoint MCP, funções de ferramentas personalizadas |
|
||||
| Envio de Comandos | Suporta envio de comandos MCP para dispositivos ESP32 via protocolo MQTT a partir do Console Inteligente |
|
||||
| Backend de Gerenciamento | Fornece interface de gerenciamento Web, suporta gerenciamento de usuários, configuração do sistema e gerenciamento de dispositivos; Suporta exibição em Chinês Simplificado, Chinês Tradicional, Inglês, Alemão, Vietnamita e Português do Brasil |
|
||||
| Ferramentas de Teste | Fornece ferramentas de teste de desempenho de resposta de modelo e ferramentas de teste de interação por áudio |
|
||||
| Backend de Gerenciamento | Fornece interface de gerenciamento Web, suporta gerenciamento de usuários, configuração do sistema e gerenciamento de dispositivos; Suporta exibição em Chinês Simplificado, Chinês Tradicional e Inglês |
|
||||
| Ferramentas de Teste | Fornece ferramentas de teste de desempenho, ferramentas de teste de modelo de visão e ferramentas de teste de interação por áudio |
|
||||
| Suporte à Implantação | Suporta implantação via Docker e implantação local, fornece gerenciamento completo de arquivos de configuração |
|
||||
| Sistema de Plugins | Suporta extensões de plugins funcionais e desenvolvimento de plugins personalizados; novos arquivos de plugin são carregados na próxima conexão do dispositivo e alterações em plugins já carregados exigem reiniciar o serviço |
|
||||
| Sistema de Plugins | Suporta extensões de plugins funcionais, desenvolvimento de plugins personalizados e carregamento dinâmico de plugins |
|
||||
|
||||
### Em Desenvolvimento 🚧
|
||||
|
||||
@@ -254,7 +253,7 @@ Xiaozhi é um ecossistema. Ao utilizar este produto, você também pode conferir
|
||||
|
||||
| Método de Uso | Plataformas Suportadas | Plataformas Gratuitas |
|
||||
|:---:|:---:|:---:|
|
||||
| Chamadas via interface OpenAI | Alibaba Bailian, Volcano Engine, DeepSeek, Zhipu, Gemini, iFLYTEK, LM Studio | Zhipu, Gemini |
|
||||
| Chamadas via interface OpenAI | Alibaba Bailian, Volcano Engine, DeepSeek, Zhipu, Gemini, iFLYTEK | Zhipu, Gemini |
|
||||
| Chamadas via interface Ollama | Ollama | - |
|
||||
| Chamadas via interface Dify | Dify | - |
|
||||
| Chamadas via interface FastGPT | FastGPT | - |
|
||||
@@ -262,7 +261,7 @@ Xiaozhi é um ecossistema. Ao utilizar este produto, você também pode conferir
|
||||
| Chamadas via interface Xinference | Xinference | - |
|
||||
| Chamadas via interface HomeAssistant | HomeAssistant | - |
|
||||
|
||||
Na verdade, qualquer LLM que suporte chamadas via interface OpenAI pode ser integrado e utilizado.
|
||||
Na verdade, qualquer LLM que suporte chamadas via interface openai pode ser integrado e utilizado.
|
||||
|
||||
---
|
||||
|
||||
@@ -280,8 +279,8 @@ Na verdade, qualquer VLLM que suporte chamadas via interface OpenAI pode ser int
|
||||
|
||||
| Método de Uso | Plataformas Suportadas | Plataformas Gratuitas |
|
||||
|:---:|:---:|:---:|
|
||||
| Chamadas via interface | EdgeTTS, iFLYTEK, Volcano Engine, Tencent Cloud, Alibaba Cloud e Bailian, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, CustomTTS, OpenAITTS, MinimaxTTS | EdgeTTS, CosyVoiceSiliconflow(parcial) |
|
||||
| Serviços locais | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, IndexStreamTTS, PaddleSpeechTTS | IndexStreamTTS, PaddleSpeechTTS, FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3 |
|
||||
| Chamadas via interface | EdgeTTS, iFLYTEK, Volcano Engine, Tencent Cloud, Alibaba Cloud e Bailian, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, GizwitsTTS, ACGNTTS, OpenAITTS, Lingxi Streaming TTS, MinimaxTTS | Lingxi Streaming TTS, EdgeTTS, CosyVoiceSiliconflow(parcial) |
|
||||
| Serviços locais | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, Index-TTS, PaddleSpeech | Index-TTS, PaddleSpeech, FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3 |
|
||||
|
||||
---
|
||||
|
||||
@@ -297,8 +296,8 @@ Na verdade, qualquer VLLM que suporte chamadas via interface OpenAI pode ser int
|
||||
|
||||
| Método de Uso | Plataformas Suportadas | Plataformas Gratuitas |
|
||||
|:---:|:---:|:---:|
|
||||
| Uso local | FunASR, SherpaASR, SherpaParaformerASR, VoskASR | FunASR, SherpaASR, SherpaParaformerASR, VoskASR |
|
||||
| Chamadas via interface | FunASRServer, Volcano Engine, iFLYTEK, Tencent Cloud, Alibaba Cloud e Bailian, Baidu Cloud, OpenAI ASR, GroqASR, Qwen3ASRFlash | FunASRServer |
|
||||
| Uso local | FunASR, SherpaASR | FunASR, SherpaASR |
|
||||
| Chamadas via interface | FunASRServer, Volcano Engine, iFLYTEK, Tencent Cloud, Alibaba Cloud, Baidu Cloud, OpenAI ASR | FunASRServer |
|
||||
|
||||
---
|
||||
|
||||
@@ -306,9 +305,7 @@ Na verdade, qualquer VLLM que suporte chamadas via interface OpenAI pode ser int
|
||||
|
||||
| Método de Uso | Plataformas Suportadas | Plataformas Gratuitas |
|
||||
|:---:|:---:|:---:|
|
||||
| Chamadas via interface | Serviço de voiceprint (voiceprint-api) | - |
|
||||
|
||||
O reconhecimento de impressão vocal exige um serviço [voiceprint-api](https://github.com/xinnan-tech/voiceprint-api) implantado separadamente (baseado em 3D-Speaker) e MySQL. Veja o [guia de integração de voiceprint](../voiceprint-integration.md).
|
||||
| Uso local | 3D-Speaker | 3D-Speaker |
|
||||
|
||||
---
|
||||
|
||||
@@ -354,10 +351,11 @@ O reconhecimento de impressão vocal exige um serviço [voiceprint-api](https://
|
||||
| <img src="../images/logo_contributors.png" width="160"> | [Contribuidores de Código](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | Agradecimentos a [todos os contribuidores de código](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors), seus esforços tornaram o projeto mais robusto e poderoso. |
|
||||
|
||||
|
||||
<a href="https://www.star-history.com/?repos=xinnan-tech%2Fxiaozhi-esp32-server&type=date&legend=top-left">
|
||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&theme=dark&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
+41
-43
@@ -6,7 +6,7 @@
|
||||
Dự án này dựa trên lý thuyết và công nghệ trí tuệ cộng sinh người-máy để phát triển hệ thống phần mềm và phần cứng thiết bị đầu cuối thông minh<br/>Cung cấp dịch vụ backend cho dự án phần cứng thông minh mã nguồn mở
|
||||
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a><br/>
|
||||
Được triển khai bằng Python, Java, Vue theo <a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">giao thức truyền thông Xiaozhi</a><br/>
|
||||
Hỗ trợ giao thức MQTT+UDP, giao thức Websocket, điểm truy cập MCP, nhận dạng vân giọng và kho tri thức
|
||||
Hỗ trợ giao thức MQTT+UDP, giao thức Websocket, điểm truy cập MCP, nhận dạng giọng nói và kho tri thức
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -22,7 +22,6 @@ Hỗ trợ giao thức MQTT+UDP, giao thức Websocket, điểm truy cập MCP,
|
||||
<a href="./README_vi.md"><img alt="Tiếng Việt" src="https://img.shields.io/badge/Tiếng Việt-DBEDFA"></a>
|
||||
<a href="./README_de.md"><img alt="Deutsch" src="https://img.shields.io/badge/Deutsch-DFE0E5"></a>
|
||||
<a href="./README_pt_BR.md"><img alt="Português (Brasil)" src="https://img.shields.io/badge/Português (Brasil)-DFE0E5"></a>
|
||||
<a href="./README_es.md"><img alt="Español" src="https://img.shields.io/badge/Español-DFE0E5"></a>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
|
||||
<img alt="GitHub Contributors" src="https://img.shields.io/github/v/release/xinnan-tech/xiaozhi-esp32-server?logo=docker" />
|
||||
</a>
|
||||
@@ -39,7 +38,7 @@ Spearheaded by Professor Siyuan Liu's Team (South China University of Technology
|
||||
</br>
|
||||
Được dẫn dắt bởi nhóm Giáo sư Lưu Tư Nguyên (Đại học Bách khoa Nam Trung Quốc)
|
||||
</br>
|
||||
<img src="../images/hnlg.jpg" alt="Đại học Bách khoa Nam Trung Quốc" width="50%">
|
||||
<img src="../images/hnlg.jpg" alt="华南理工大学" width="50%">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -55,31 +54,31 @@ Muốn xem hiệu quả sử dụng? Hãy xem video 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1FMFyejExX" target="_blank">
|
||||
<picture>
|
||||
<img alt="Cảm giác tốc độ phản hồi" src="../images/demo9.png" /></picture>
|
||||
<img alt="响应速度感受" src="docs/images/demo9.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||
<picture>
|
||||
<img alt="Bí quyết tối ưu tốc độ" src="../images/demo6.png" /></picture>
|
||||
<img alt="速度优化秘诀" src="docs/images/demo6.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1WEcxzFEAT" target="_blank">
|
||||
<picture>
|
||||
<img alt="Người ảo số Xiaozhi có đánh thức bằng giọng nói" src="../images/demo8.png" /></picture>
|
||||
<img alt="小智数字人 支持语音唤醒" src="docs/images/demo8.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CKVz6UEuB" target="_blank">
|
||||
<picture>
|
||||
<img alt="Gọi giữa các thiết bị" src="../images/demo0.png" /></picture>
|
||||
<img alt="设备呼叫设备,打电话" src="docs/images/demo0.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||
<picture>
|
||||
<img alt="Tình huống y tế phức tạp" src="../images/demo1.png" /></picture>
|
||||
<img alt="复杂医疗场景" src="docs/images/demo1.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -87,31 +86,31 @@ Muốn xem hiệu quả sử dụng? Hãy xem video 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1VC96Y5EMH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Phát nhạc, tra cứu thời tiết và đọc tin tức" src="../images/demo7.png" /></picture>
|
||||
<img alt="播放音乐查询天气播报新闻" src="docs/images/demo7.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12J7WzBEaH" target="_blank">
|
||||
<picture>
|
||||
<img alt="Ngắt giữa chừng theo thời gian thực" src="../images/demo10.png" /></picture>
|
||||
<img alt="实时打断" src="docs/images/demo10.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Co76z7EvK" target="_blank">
|
||||
<picture>
|
||||
<img alt="Nhận dạng vật thể qua ảnh chụp" src="../images/demo12.png" /></picture>
|
||||
<img alt="拍照识物品" src="docs/images/demo12.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
<img alt="Bật tắt thiết bị gia dụng" src="../images/demo5.png" /></picture>
|
||||
<img alt="控制家电开关" src="docs/images/demo5.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Nhiệm vụ nhiều lệnh" src="../images/demo11.png" /></picture>
|
||||
<img alt="多指令任务" src="docs/images/demo11.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -119,31 +118,31 @@ Muốn xem hiệu quả sử dụng? Hãy xem video 🎥
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
|
||||
<picture>
|
||||
<img alt="Điểm truy cập MCP" src="../images/demo13.png" /></picture>
|
||||
<img alt="MCP接入点" src="docs/images/demo13.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
|
||||
<picture>
|
||||
<img alt="Gửi lệnh MQTT" src="../images/demo4.png" /></picture>
|
||||
<img alt="MQTT指令下发" src="docs/images/demo4.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<picture>
|
||||
<img alt="Nhận dạng vân giọng" src="../images/demo14.png" /></picture>
|
||||
<img alt="声纹识别" src="docs/images/demo14.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="Giọng nói tùy chỉnh" src="../images/demo2.png" /></picture>
|
||||
<img alt="自定义音色" src="docs/images/demo2.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="Trò chuyện bằng tiếng Quảng Đông" src="../images/demo3.png" /></picture>
|
||||
<img alt="使用粤语交流" src="docs/images/demo3.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -169,12 +168,12 @@ Dự án này cung cấp hai phương pháp triển khai, vui lòng chọn theo
|
||||
#### 🚀 Lựa chọn phương pháp triển khai
|
||||
| Phương pháp triển khai | Đặc điểm | Tình huống áp dụng | Tài liệu triển khai | Yêu cầu cấu hình | Video hướng dẫn |
|
||||
|---------|------|---------|---------|---------|---------|
|
||||
| **Cài đặt tối giản** | Đối thoại thông minh, 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](../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](../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` cục bộ cần nhiều bộ nhớ; dùng toàn API thì mức dùng tài nguyên thấp | - |
|
||||
| **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, thao tác qua 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](../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](../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](../dev-ops-integration.md) | `FunASR` cục bộ cần nhiều bộ nhớ, thêm tài nguyên cơ sở dữ liệu; dùng toàn API thì mức dùng tài nguyên thấp| [Video hướng dẫn khởi động mã nguồn cục bộ](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
|
||||
| **Cài đặt tối giản** | Đối thoại thông minh, quản lý đơn tác nhân | Môi trường cấu hình thấp, dữ liệu lưu trong tệp cấu hình, không cần cơ sở dữ liệu | [①Phiên bản Docker](../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](../Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 nhân 4GB nếu dùng `FunASR`, 2 nhân 2GB nếu toàn API | - |
|
||||
| **Cài đặt toàn bộ module** | Đối thoại thông minh, quản lý đa người dùng, quản lý đa tác nhân, bảng điều khiển thông minh | Trải nghiệm đầy đủ tính năng, dữ liệu lưu trong cơ sở dữ liệu |[①Phiên bản Docker](../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](../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](../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](../FAQ.md)
|
||||
|
||||
> 💡 Gợi ý: Dưới đây là nền tảng thử nghiệm được triển khai theo mã mới nhất, có thể flash để thử nghiệm nếu cần, đồng thời là 6, dữ liệu sẽ được xóa mỗi ngày.
|
||||
> 💡 Gợi ý: Dưới đây là nền tảng thử nghiệm được triển khai theo mã mới nhất, có thể flash để thử nghiệm nếu cần, đồng thời là 6, dữ liệu sẽ được xóa mỗi ngày,
|
||||
|
||||
```
|
||||
Địa chỉ bảng điều khiển thông minh: https://2662r3426b.vicp.fun
|
||||
@@ -193,7 +192,7 @@ Công cụ kiểm tra dịch vụ: https://2662r3426b.vicp.fun/test/
|
||||
>
|
||||
> 2. `Cấu hình streaming`: Phù hợp với demo, đào tạo, hơn 2 đồng thời, v.v., sử dụng công nghệ xử lý streaming, tốc độ phản hồi nhanh hơn, trải nghiệm tốt hơn.
|
||||
>
|
||||
> Từ phiên bản `0.5.2`, dự án hỗ trợ cấu hình streaming, so với các phiên bản trước đó, tốc độ phản hồi cải thiện khoảng `2.5 giây`, cải thiện đáng kể trải nghiệm người dùng.
|
||||
> Từ phiên bản `0.5.2`, dự án hỗ trợ cấu hình streaming, so với phiên bản đầu, tốc độ phản hồi cải thiện khoảng `2.5 giây`, cải thiện đáng kể trải nghiệm người dùng.
|
||||
|
||||
| Tên module | Cài đặt miễn phí cho người mới | Cấu hình streaming |
|
||||
|:---:|:---:|:---:|
|
||||
@@ -211,15 +210,15 @@ Dự án này cung cấp các công cụ kiểm tra sau để giúp bạn xác m
|
||||
|
||||
| Tên công cụ | Vị trí | Phương pháp sử dụng | Mô tả chức năng |
|
||||
|:---:|:---|:---:|:---:|
|
||||
| Công cụ kiểm tra tương tác âm thanh | main》digital-human》index.html | Trong `main/digital-human`, cài đặt các phụ thuộc theo README, rồi chạy `python start.py` và mở `http://127.0.0.1:8006/index.html` | Kiểm tra chức năng phát và nhận âm thanh, xác minh xử lý âm thanh phía Python có bình thường không |
|
||||
| Công cụ kiểm tra phản hồi mô hình | main》xiaozhi-server》performance_tester.py | Thực hiện `python performance_tester.py` | Kiểm tra tốc độ phản hồi của các module cốt lõi như ASR(Nhận dạng giọng nói), LLM(Mô hình lớn), VLLM(Mô hình thị giác), TTS(Tổng hợp giọng nói) |
|
||||
| Công cụ kiểm tra tương tác âm thanh | main》digital-human》index.html | Chạy `python start.py` trong `main/digital-human`, sau đó mở `http://127.0.0.1:8006/index.html` | Kiểm tra chức năng phát và nhận âm thanh, xác minh xử lý âm thanh phía Python có bình thường không |
|
||||
| Công cụ kiểm tra phản hồi mô hình | main》xiaozhi-server》performance_tester.py | Thực hiện `python performance_tester.py` | Kiểm tra tốc độ phản hồi của ba module cốt lõi ASR(Nhận dạng giọng nói), LLM(Mô hình lớn), VLLM(Mô hình thị giác), TTS(Tổng hợp giọng nói) |
|
||||
|
||||
> 💡 Gợi ý: Khi kiểm tra tốc độ mô hình, chỉ kiểm tra các mô hình đã cấu hình khóa.
|
||||
|
||||
---
|
||||
## Danh sách tính năng ✨
|
||||
### Đã thực hiện ✅
|
||||

|
||||

|
||||
| Module chức năng | Mô tả |
|
||||
|:---:|:---|
|
||||
| Kiến trúc cốt lõi | Dựa trên [cổng MQTT+UDP](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mqtt-gateway-integration.md), WebSocket, máy chủ HTTP, cung cấp hệ thống quản lý bảng điều khiển và xác thực hoàn chỉnh |
|
||||
@@ -232,10 +231,10 @@ Dự án này cung cấp các công cụ kiểm tra sau để giúp bạn xác m
|
||||
| Kho tri thức | Hỗ trợ kho tri thức RAGFlow, cho phép mô hình lớn đánh giá cần gọi kho tri thức trước khi trả lời |
|
||||
| Gọi công cụ | Hỗ trợ giao thức IOT phía client, giao thức MCP phía client, giao thức MCP phía server, giao thức điểm truy cập MCP, hàm công cụ tùy chỉnh |
|
||||
| Gửi lệnh | Dựa vào giao thức MQTT, hỗ trợ gửi lệnh MCP từ bảng điều khiển thông minh xuống thiết bị ESP32 |
|
||||
| Backend quản lý | Cung cấp giao diện quản lý Web, hỗ trợ quản lý người dùng, cấu hình hệ thống và quản lý thiết bị; giao diện hỗ trợ hiển thị tiếng Trung giản thể, tiếng Trung phồn thể, tiếng Anh, tiếng Đức, tiếng Việt và tiếng Bồ Đào Nha Brazil |
|
||||
| Công cụ kiểm tra | Cung cấp công cụ kiểm tra hiệu suất phản hồi mô hình và công cụ kiểm tra tương tác âm thanh |
|
||||
| Backend quản lý | Cung cấp giao diện quản lý Web, hỗ trợ quản lý người dùng, cấu hình hệ thống và quản lý thiết bị; giao diện hỗ trợ hiển thị tiếng Trung giản thể, tiếng Trung phồn thể, tiếng Anh |
|
||||
| Công cụ kiểm tra | Cung cấp công cụ kiểm tra hiệu suất, công cụ kiểm tra mô hình thị giác và công cụ kiểm tra tương tác âm thanh |
|
||||
| Hỗ trợ triển khai | Hỗ trợ triển khai Docker và triển khai cục bộ, cung cấp quản lý tệp cấu hình hoàn chỉnh |
|
||||
| Hệ thống plugin | Hỗ trợ mở rộng plugin chức năng và phát triển plugin tùy chỉnh; file plugin mới được tải ở lần kết nối thiết bị tiếp theo, thay đổi mã của plugin đã tải cần khởi động lại dịch vụ |
|
||||
| Hệ thống plugin | Hỗ trợ mở rộng plugin chức năng, phát triển plugin tùy chỉnh và hot loading plugin |
|
||||
|
||||
### Đang phát triển 🚧
|
||||
|
||||
@@ -255,7 +254,7 @@ Xiaozhi là một hệ sinh thái, khi bạn sử dụng sản phẩm này, bạ
|
||||
|
||||
| Phương pháp sử dụng | Nền tảng hỗ trợ | Nền tảng miễn phí |
|
||||
|:---:|:---:|:---:|
|
||||
| Gọi giao diện OpenAI | Alibaba Bailian, Volcano Engine, DeepSeek, Zhipu, Gemini, iFLYTEK, LM Studio | Zhipu, Gemini |
|
||||
| Gọi giao diện openai | Alibaba Bailian, Volcano Engine, DeepSeek, Zhipu, Gemini, iFlytek | Zhipu, Gemini |
|
||||
| Gọi giao diện ollama | Ollama | - |
|
||||
| Gọi giao diện dify | Dify | - |
|
||||
| Gọi giao diện fastgpt | Fastgpt | - |
|
||||
@@ -263,7 +262,7 @@ Xiaozhi là một hệ sinh thái, khi bạn sử dụng sản phẩm này, bạ
|
||||
| Gọi giao diện xinference | Xinference | - |
|
||||
| Gọi giao diện homeassistant | HomeAssistant | - |
|
||||
|
||||
Trên thực tế, bất kỳ LLM nào hỗ trợ gọi giao diện OpenAI đều có thể truy cập sử dụng.
|
||||
Trên thực tế, bất kỳ LLM nào hỗ trợ gọi giao diện openai đều có thể truy cập sử dụng.
|
||||
|
||||
---
|
||||
|
||||
@@ -271,9 +270,9 @@ Trên thực tế, bất kỳ LLM nào hỗ trợ gọi giao diện OpenAI đề
|
||||
|
||||
| Phương pháp sử dụng | Nền tảng hỗ trợ | Nền tảng miễn phí |
|
||||
|:---:|:---:|:---:|
|
||||
| Gọi giao diện OpenAI | Alibaba Bailian, Zhipu ChatGLMVLLM | Zhipu ChatGLMVLLM |
|
||||
| Gọi giao diện openai | Alibaba Bailian, Zhipu ChatGLMVLLM | Zhipu ChatGLMVLLM |
|
||||
|
||||
Trên thực tế, bất kỳ VLLM nào hỗ trợ gọi giao diện OpenAI đều có thể truy cập sử dụng.
|
||||
Trên thực tế, bất kỳ VLLM nào hỗ trợ gọi giao diện openai đều có thể truy cập sử dụng.
|
||||
|
||||
---
|
||||
|
||||
@@ -281,8 +280,8 @@ Trên thực tế, bất kỳ VLLM nào hỗ trợ gọi giao diện OpenAI đ
|
||||
|
||||
| Phương pháp sử dụng | Nền tảng hỗ trợ | Nền tảng miễn phí |
|
||||
|:---:|:---:|:---:|
|
||||
| Gọi giao diện | EdgeTTS, iFLYTEK, Volcano Engine, Tencent Cloud, Alibaba Cloud và Bailian, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, CustomTTS, OpenAITTS, MinimaxTTS | EdgeTTS, CosyVoiceSiliconflow(một phần) |
|
||||
| Dịch vụ cục bộ | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, IndexStreamTTS, PaddleSpeechTTS | IndexStreamTTS, PaddleSpeechTTS, FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3 |
|
||||
| Gọi giao diện | EdgeTTS, iFlytek, Volcano Engine, Tencent Cloud, Alibaba Cloud và Bailian, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, GizwitsTTS, ACGNTTS, OpenAITTS, Lingxi streaming TTS, MinimaxTTS | Lingxi streaming TTS, EdgeTTS, CosyVoiceSiliconflow(một phần) |
|
||||
| Dịch vụ cục bộ | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, Index-TTS, PaddleSpeech | Index-TTS, PaddleSpeech, FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3 |
|
||||
|
||||
---
|
||||
|
||||
@@ -298,8 +297,8 @@ Trên thực tế, bất kỳ VLLM nào hỗ trợ gọi giao diện OpenAI đ
|
||||
|
||||
| Phương pháp sử dụng | Nền tảng hỗ trợ | Nền tảng miễn phí |
|
||||
|:---:|:---:|:---:|
|
||||
| Sử dụng cục bộ | FunASR, SherpaASR, SherpaParaformerASR, VoskASR | FunASR, SherpaASR, SherpaParaformerASR, VoskASR |
|
||||
| Gọi giao diện | FunASRServer, Volcano Engine, iFLYTEK, Tencent Cloud, Alibaba Cloud và Bailian, Baidu Cloud, OpenAI ASR, GroqASR, Qwen3ASRFlash | FunASRServer |
|
||||
| Sử dụng cục bộ | FunASR, SherpaASR | FunASR, SherpaASR |
|
||||
| Gọi giao diện | FunASRServer, Volcano Engine, iFlytek, Tencent Cloud, Alibaba Cloud, Baidu Cloud, OpenAI ASR | FunASRServer |
|
||||
|
||||
---
|
||||
|
||||
@@ -307,9 +306,7 @@ Trên thực tế, bất kỳ VLLM nào hỗ trợ gọi giao diện OpenAI đ
|
||||
|
||||
| Phương pháp sử dụng | Nền tảng hỗ trợ | Nền tảng miễn phí |
|
||||
|:---:|:---:|:---:|
|
||||
| Gọi giao diện | Dịch vụ nhận dạng vân giọng (voiceprint-api) | - |
|
||||
|
||||
Nhận dạng vân giọng cần dịch vụ [voiceprint-api](https://github.com/xinnan-tech/voiceprint-api) được triển khai riêng (dựa trên 3D-Speaker) và MySQL. Xem [hướng dẫn tích hợp voiceprint](../voiceprint-integration.md).
|
||||
| Sử dụng cục bộ | 3D-Speaker | 3D-Speaker |
|
||||
|
||||
---
|
||||
|
||||
@@ -355,10 +352,11 @@ Nhận dạng vân giọng cần dịch vụ [voiceprint-api](https://github.com
|
||||
| <img src="../images/logo_contributors.png" width="160"> | [Người đóng góp mã](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | Cảm ơn [tất cả người đóng góp mã](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors), sự cống hiến của bạn khiến dự án mạnh mẽ và vững chắc hơn. |
|
||||
|
||||
|
||||
<a href="https://www.star-history.com/?repos=xinnan-tech%2Fxiaozhi-esp32-server&type=date&legend=top-left">
|
||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&theme=dark&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=xinnan-tech/xiaozhi-esp32-server&type=date&legend=top-left&sealed_token=cQovHKgZjqEnQ-svfYfN392irNvGuq-6pyv4cA8nd2jQEhQLz1ETV4YHTVk2UZyLMFbCQuZA7jduRh3YbeK5WPYaRLrfmIimGQa3lram652jJL9oQk-UuSZA5H6L4dPIhZc8KCc-Ur_UAUNbly7TePpnTR2otGknBLCOjOliD4fk1st6z7tPEDVjSRx5" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -16,7 +16,7 @@
|
||||
点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`voiceprint-api-main`
|
||||
你需要把它重命名成`voiceprint-api`。
|
||||
|
||||
## 第二步, 确认 MySQL 能访问
|
||||
## 第二步, 创建数据库和表
|
||||
|
||||
声纹识别需要依赖`mysql`数据库。如果你之前已经部署`智控台`,说明你已经安装了`mysql`。你可以共用它。
|
||||
|
||||
@@ -39,7 +39,7 @@ telnet 127.0.0.1 3306
|
||||
networks:
|
||||
- default
|
||||
expose:
|
||||
- 3306
|
||||
- "3306:3306"
|
||||
```
|
||||
|
||||
修改后
|
||||
@@ -58,9 +58,8 @@ telnet 127.0.0.1 3306
|
||||
# 进入你docker-compose_all.yml所在的文件夹,例如我的是xiaozhi-server
|
||||
cd xiaozhi-server
|
||||
docker compose -f docker-compose_all.yml down
|
||||
docker compose -f docker-compose_all.yml up -d
|
||||
docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
数据库服务`xiaozhi-esp32-server-db`在`docker-compose_all.yml`里,最小化的`docker-compose.yml`只有服务本身,所以两条命令都用`docker-compose_all.yml`。
|
||||
|
||||
启动完后,在宿主机再使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
|
||||
```
|
||||
@@ -172,8 +171,6 @@ http://192.168.1.25:8005/voiceprint/health?key=abcd
|
||||
然后搜索参数`server.voice_print`,此时,它的值应该是`null`值。
|
||||
点击修改按钮,把上一步得来的`声纹接口地址`粘贴到`参数值`里。然后保存。
|
||||
|
||||
声纹识别的相似度阈值是另一个系统参数`server.voiceprint_similarity_threshold`(默认0.4,范围0.0-1.0,数值越高越严格),要调整就在同一个`参数管理`页面里改。
|
||||
|
||||
如果能保存成功,说明一切顺利,你可以去智能体查看效果了。如果不成功,说明智控台无法访问声纹识别,很大概率是网络防火墙,或者没有填写正确的局域网ip。
|
||||
|
||||
## 第二步 设置智能体记忆模式
|
||||
@@ -189,7 +186,7 @@ http://192.168.1.25:8005/voiceprint/health?key=abcd
|
||||
在智控台,`智能体管理`页面,在智能体的面板里,有一个`声纹识别`按钮,点击它。在底部有一个`新增按钮`。就可以对某个人说的话进行声纹注册。
|
||||
在弹出的框里,`描述`这个属性建议填写上,可以是这个人的职业、性格、爱好。方便智能体对说话人进行分析和了解。
|
||||
|
||||
## 第五步 验证效果
|
||||
## 第三步 和你的智能体聊天
|
||||
|
||||
将你的设备通电,问它,你知道我是谁吗?如果他能回答得出,说明声纹识别功能正常。
|
||||
|
||||
@@ -208,9 +205,6 @@ voiceprint:
|
||||
- "test1,张三,张三是一个程序员"
|
||||
- "test2,李四,李四是一个产品经理"
|
||||
- "test3,王五,王五是一个设计师"
|
||||
# 声纹识别相似度阈值,范围0.0-1.0,默认0.4
|
||||
# 数值越高越严格,减少误识别但可能增加拒识率
|
||||
similarity_threshold: 0.4
|
||||
```
|
||||
|
||||
把上一步得来的 `声纹接口地址` 粘贴到 `url` 里。然后保存。
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
## 功能简介
|
||||
|
||||
联网搜索插件 `web_search` 支持在对话过程中实时联网搜索信息并返回结果。插件支持四个搜索源:秘塔(Metaso)、Tavily、Serply和Outcrawl,用户可根据需要选择其中一个。
|
||||
联网搜索插件 `web_search` 支持在对话过程中实时联网搜索信息并返回结果。插件支持三个搜索源:秘塔(Metaso)、Tavily和Serply,用户可根据需要选择其中一个。
|
||||
|
||||
## API Key申请指南
|
||||
|
||||
目前我们适配了`秘塔搜索`、`Tavily搜索`、`Serply搜索`和`Outcrawl搜索`。
|
||||
目前我们适配了`秘塔搜索`、`Tavily搜索`和`Serply搜索`。
|
||||
- Tavily搜索:每个月1000次免费额度。
|
||||
- 秘塔搜索:拥有较为优质的国内数据源。
|
||||
- Serply搜索:返回谷歌搜索结果,注册赠送2500次免费额度,额度用完后需要付费。
|
||||
- Outcrawl搜索:面向AI智能体的网页搜索接口,每月1000 credits免费额度。
|
||||
|
||||
## API Key申请指南
|
||||
|
||||
@@ -33,13 +32,6 @@
|
||||
- 复制生成的API Key,这是配置所需的关键信息
|
||||
- 接口说明见 [Serply文档](https://serply.io/docs)
|
||||
|
||||
### 方式四:使用Outcrawl搜索
|
||||
|
||||
- 访问 [Outcrawl](https://outcrawl.ai),注册并登录账号
|
||||
- 在控制台中创建API Key
|
||||
- 复制生成的API Key,这是配置所需的关键信息
|
||||
- 接口说明见 [Outcrawl文档](https://outcrawl.ai/docs/search/overview)
|
||||
|
||||
## 配置方式
|
||||
|
||||
### 方式1. 使用智控台部署(推荐)
|
||||
@@ -48,14 +40,14 @@
|
||||
- 进入"配置角色"页面,选择要配置的智能体
|
||||
- 点击"编辑功能"按钮,在右侧参数配置区域找到"联网搜索"插件
|
||||
- 勾选"联网搜索"
|
||||
- 填入搜索源(`metaso`、`tavily`、`serply`或`outcrawl`),并将对应的`API Key`填入配置项
|
||||
- 填入搜索源(`metaso`、`tavily`或`serply`),并将对应的`API Key`填入配置项
|
||||
- 保存配置,再保存智能体配置
|
||||
|
||||
### 方式2. 单模块xiaozhi-server部署
|
||||
|
||||
在 `data/.config.yaml` 中配置:
|
||||
|
||||
- 将搜索源填入 `provider`,可选值为 `metaso`、`tavily`、`serply` 或 `outcrawl`
|
||||
- 将搜索源填入 `provider`,可选值为 `metaso`、`tavily` 或 `serply`
|
||||
- 将申请到的API Key填入 `api_key`
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -16,28 +16,3 @@ Vue 3.x
|
||||
# 接口文档
|
||||
启动后打开:http://localhost:8002/xiaozhi/doc.html
|
||||
|
||||
# 跑测试
|
||||
测试分两种:纯单元测试,和要真连 MySQL、Redis 的集成测试(`DeviceTest`、`loginControllerTest`、`ModelConfigPersistenceTest`)。
|
||||
|
||||
集成测试需要先起好 MySQL 8 和 Redis 8。没起的时候这类测试整类跳过,不会让构建变红,日志里会写明缺的是哪个依赖。CI 上不一样:依赖连不上就直接让构建失败,不许跳过,免得门禁被悄悄掏空。
|
||||
|
||||
本地用 Docker 起这两个依赖:
|
||||
|
||||
```bash
|
||||
docker run -d --name xiaozhi-mysql -p 3306:3306 \
|
||||
-e MYSQL_ROOT_PASSWORD=123456 -e MYSQL_DATABASE=xiaozhi_esp32_server mysql:8.0
|
||||
docker run -d --name xiaozhi-redis -p 6379:6379 redis:8.0
|
||||
```
|
||||
|
||||
跑全部测试:
|
||||
|
||||
```bash
|
||||
mvn -B test -DskipTests=false
|
||||
```
|
||||
|
||||
只想编译、不跑测试:
|
||||
|
||||
```bash
|
||||
mvn -B package -DskipTests=true
|
||||
```
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<liquibase-core.version>4.20.0</liquibase-core.version>
|
||||
<aliyun-sms-version>4.1.0</aliyun-sms-version>
|
||||
<okio-version>3.4.0</okio-version>
|
||||
<skipTests>false</skipTests>
|
||||
<skipTests>true</skipTests>
|
||||
<argLine></argLine>
|
||||
</properties>
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 检查那几个要连 MySQL 和 Redis 的集成测试是不是真的跑了。
|
||||
# 守卫要是哪天手滑把它们全跳过了,CI 必须红,不能安安静静少跑一半门禁。
|
||||
set -uo pipefail
|
||||
|
||||
REPORT_DIR="${1:-target/surefire-reports}"
|
||||
|
||||
WATCHED=(
|
||||
"xiaozhi.modules.device.DeviceTest"
|
||||
"xiaozhi.modules.model.service.impl.ModelConfigPersistenceTest"
|
||||
"xiaozhi.modules.sys.loginControllerTest"
|
||||
)
|
||||
|
||||
if [ ! -d "$REPORT_DIR" ]; then
|
||||
echo "找不到测试报告目录:$REPORT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
missing=()
|
||||
for class in "${WATCHED[@]}"; do
|
||||
count=0
|
||||
for report in "$REPORT_DIR"/TEST-*.xml; do
|
||||
[ -e "$report" ] || continue
|
||||
name=$(sed -n 's/.*<testsuite[^>]*name="\([^"]*\)".*/\1/p' "$report" | head -1)
|
||||
if [ "$name" = "$class" ]; then
|
||||
count=$(sed -n 's/.*<testsuite[^>]*tests="\([0-9]*\)".*/\1/p' "$report" | head -1)
|
||||
count=${count:-0}
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$count" -eq 0 ]; then
|
||||
missing+=("$class")
|
||||
else
|
||||
echo "OK $class 跑了 $count 条"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -ne 0 ]; then
|
||||
echo "这几个集成测试一条都没跑:${missing[*]}"
|
||||
echo "守卫把门禁掏空了,构建必须失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "集成测试都跑过了"
|
||||
+4
-25
@@ -95,10 +95,6 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
callerNickname = targetBook.get(bookKey(callerMac));
|
||||
}
|
||||
if (StringUtils.isBlank(callerNickname)) {
|
||||
// 主叫显示名取的是主叫方自己那台设备在设备表里的名字,不是对方的。
|
||||
// 对方那边给主叫方起的称呼在上面的 targetBook 里已经查过。
|
||||
// 产品上按「来电显示」的口径处理,所以这里不套通讯录那套「不抄对方私产」的限制
|
||||
// 这个接口没有登录用户上下文,归属校验那个查法在这里会返回空,只能用不校验归属的查法
|
||||
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(callerMac);
|
||||
if (callerDevice != null) {
|
||||
callerNickname = callerDevice.getAlias();
|
||||
@@ -216,18 +212,11 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
DeviceAddressBookEntity entity = new DeviceAddressBookEntity();
|
||||
entity.setMacAddress(macAddress);
|
||||
entity.setTargetMac(targetMac);
|
||||
// 调用方没起名就退回尾号称呼。对方设备在智控台上起的名字是对方的私产,
|
||||
// 抄进调用方的通讯录等于把别人的昵称漏出去,所以别人的设备一律不去设备表取名
|
||||
// 如果 alias 为空,就默认使用设备名称;对方设备尚未登记时退回尾号称呼
|
||||
if (StringUtils.isBlank(alias)) {
|
||||
// 只有给自己那台设备起名时才用设备表里的名字,那本来就是调用方自己的
|
||||
if (isOwnDevice(macAddress, targetMac)) {
|
||||
// 用带归属校验的那个查法。设备表里同一个 MAC 可能有两行只差大小写,
|
||||
// 一次只准取一条的那种查法撞上这种数据会抛 TooManyResultsException,
|
||||
// 整个保存接口带崩;按 id 取第一条,撞行也不报错
|
||||
DeviceEntity ownDevice = deviceService.getOwnedDeviceByMacAddress(macAddress);
|
||||
if (ownDevice != null && StringUtils.isNotBlank(ownDevice.getAlias())) {
|
||||
alias = ownDevice.getAlias();
|
||||
}
|
||||
DeviceEntity targetDevice = deviceService.getDeviceByMacAddress(targetMac);
|
||||
if (targetDevice != null) {
|
||||
alias = targetDevice.getAlias();
|
||||
}
|
||||
// 设备登记了但用户没起过名,alias 建表就是 null,这是默认状态
|
||||
// 不是异常。这时候落个 null 进去,缓存刷新会把整条跳过,
|
||||
@@ -315,16 +304,6 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
return macAddress == null ? null : macAddress.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标设备是不是调用方自己那台
|
||||
*
|
||||
* <p>
|
||||
* MAC 不区分大小写,比较前先按通讯录的键归一化,跟别处的比较口径保持一致
|
||||
*/
|
||||
private static boolean isOwnDevice(String macAddress, String targetMac) {
|
||||
return bookKey(macAddress) != null && bookKey(macAddress).equals(bookKey(targetMac));
|
||||
}
|
||||
|
||||
private String formatMacAsDeviceName(String mac) {
|
||||
if (StringUtils.isBlank(mac) || mac.length() < 2) {
|
||||
return mac;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 添加MCP接入点附带说话人身份的开关参数配置
|
||||
delete from `sys_params` where param_code = 'mcp_forward_speaker';
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark)
|
||||
VALUES (313, 'mcp_forward_speaker', 'true', 'boolean', 1, '是否随MCP调用附带说话人身份');
|
||||
@@ -1,22 +0,0 @@
|
||||
-- PaddleSpeechTTS:智控台协议收敛为仅 websocket(与 xiaozhi-server 实现一致)
|
||||
|
||||
UPDATE `ai_model_provider`
|
||||
SET `fields` = JSON_ARRAY(
|
||||
JSON_OBJECT('key', 'protocol', 'label', '协议类型', 'type', 'string', 'options', JSON_ARRAY('websocket')),
|
||||
JSON_OBJECT('key', 'url', 'label', '服务地址', 'type', 'string'),
|
||||
JSON_OBJECT('key', 'spk_id', 'label', '音色', 'type', 'int'),
|
||||
JSON_OBJECT('key', 'sample_rate', 'label', '采样率', 'type', 'float'),
|
||||
JSON_OBJECT('key', 'speed', 'label', '语速', 'type', 'float'),
|
||||
JSON_OBJECT('key', 'volume', 'label', '音量', 'type', 'float'),
|
||||
JSON_OBJECT('key', 'save_path', 'label', '保存路径', 'type', 'string')
|
||||
)
|
||||
WHERE `id` = 'SYSTEM_TTS_PaddleSpeechTTS';
|
||||
|
||||
UPDATE `ai_model_config`
|
||||
SET `remark` = 'PaddleSpeechTTS 配置说明:
|
||||
1. PaddleSpeech 是百度飞桨开源的语音合成工具,支持本地离线部署和模型训练。paddlepaddle百度飞浆框架地址:https://www.paddlepaddle.org.cn/
|
||||
2. 当前仅支持 WebSocket 协议进行流式传输(参考部署文档:https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/paddlespeech-deploy.md)。
|
||||
3. 使用前要在本地部署 paddlespeech 服务,服务默认运行在 ws://127.0.0.1:8092/paddlespeech/tts/streaming
|
||||
4. 支持自定义发音人、语速、音量和采样率。
|
||||
'
|
||||
WHERE `id` = 'TTS_PaddleSpeechTTS';
|
||||
@@ -1,9 +0,0 @@
|
||||
-- 联网搜索插件新增Outcrawl搜索源
|
||||
|
||||
UPDATE `ai_model_provider`
|
||||
SET `fields` = JSON_SET(
|
||||
`fields`,
|
||||
'$[0].label',
|
||||
'搜索源:metaso / tavily / serply / outcrawl'
|
||||
)
|
||||
WHERE `id` = 'SYSTEM_PLUGIN_WEB_SEARCH';
|
||||
@@ -754,16 +754,9 @@ databaseChangeLog:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202609172100.sql
|
||||
- changeSet:
|
||||
id: 202610302051
|
||||
author: shinjiyu
|
||||
id: 202610261030
|
||||
author: RanChen
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202610302051.sql
|
||||
- changeSet:
|
||||
id: 202611021200
|
||||
author: heydryft
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202611021200.sql
|
||||
path: classpath:db/changelog/202610261030.sql
|
||||
-19
@@ -14,14 +14,9 @@ import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
|
||||
import xiaozhi.modules.agent.dao.AgentCorrectWordMappingDao;
|
||||
import xiaozhi.modules.agent.entity.AgentCorrectWordMappingEntity;
|
||||
import xiaozhi.modules.correctword.dao.CorrectWordFileDao;
|
||||
@@ -42,20 +37,6 @@ class CorrectWordFileServiceImplTest {
|
||||
private final CorrectWordFileServiceImpl service =
|
||||
new CorrectWordFileServiceImpl(fileDao, itemDao, mappingDao);
|
||||
|
||||
@BeforeAll
|
||||
static void registerEntityMetadata() {
|
||||
// 这条用例不起 Spring 上下文,MyBatis-Plus 的实体元数据没人注册,
|
||||
// LambdaQueryWrapper 拼条件时找不到 lambda 缓存就抛异常。
|
||||
// 全量跑的时候是前面的上下文测试顺带注册好的,单独跑就露馅。
|
||||
// 这里自己补上,单独跑和跟着全量跑结果一样。
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
CorrectWordItemEntity.class);
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
CorrectWordFileEntity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAllItemsByAgentId_returns_empty_list_when_no_mappings() {
|
||||
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
@@ -3,7 +3,6 @@ package xiaozhi.modules.device;
|
||||
import java.util.HashMap;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -16,7 +15,6 @@ import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.sys.dto.SysUserDTO;
|
||||
import xiaozhi.modules.sys.service.SysUserService;
|
||||
import xiaozhi.testsupport.IntegrationTestEnvironment;
|
||||
|
||||
@Slf4j
|
||||
@SpringBootTest
|
||||
@@ -24,11 +22,6 @@ import xiaozhi.testsupport.IntegrationTestEnvironment;
|
||||
@DisplayName("设备测试")
|
||||
public class DeviceTest {
|
||||
|
||||
@BeforeAll
|
||||
static void checkDependencies() {
|
||||
IntegrationTestEnvironment.requireDatabaseAndRedis();
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private RedisUtils redisUtils;
|
||||
@Autowired
|
||||
|
||||
+3
-172
@@ -1,6 +1,5 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
@@ -22,7 +21,6 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.apache.ibatis.exceptions.TooManyResultsException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
@@ -77,7 +75,7 @@ class DeviceAddressBookServiceImplTest {
|
||||
addressBookDao, redisUtils, deviceService, sysParamsService);
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
when(deviceService.getOwnedDeviceByMacAddress(anyString())).thenReturn(null);
|
||||
when(deviceService.getDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
|
||||
|
||||
service.saveOrUpdate(MAC, OTHER_MAC, null, true);
|
||||
|
||||
@@ -87,11 +85,10 @@ class DeviceAddressBookServiceImplTest {
|
||||
assertNotNull(entity.getAlias());
|
||||
assertEquals("尾号为66的设备", entity.getAlias());
|
||||
assertTrue(entity.getHasPermission());
|
||||
verify(deviceService, never()).getOwnedDeviceByMacAddress(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankAliasOnAnotherDeviceFallsBackToMacSuffixWithoutAskingTheDeviceTable() {
|
||||
void blankAliasOnRegisteredTargetWithoutNicknameFallsBackToMacSuffix() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
@@ -104,7 +101,6 @@ class DeviceAddressBookServiceImplTest {
|
||||
registered.setMacAddress(OTHER_MAC);
|
||||
registered.setAlias(null);
|
||||
when(deviceService.getDeviceByMacAddress(OTHER_MAC)).thenReturn(registered);
|
||||
when(deviceService.getOwnedDeviceByMacAddress(OTHER_MAC)).thenReturn(registered);
|
||||
|
||||
service.saveOrUpdate(MAC, OTHER_MAC, null, true);
|
||||
|
||||
@@ -112,171 +108,6 @@ class DeviceAddressBookServiceImplTest {
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
assertEquals("尾号为66的设备", entityCaptor.getValue().getAlias(),
|
||||
"登记了但没起名的设备落个空名,缓存刷新会跳过这条,对方永远呼不过来");
|
||||
verify(deviceService, never()).getDeviceByMacAddress(anyString());
|
||||
verify(deviceService, never()).getOwnedDeviceByMacAddress(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankAliasOnRegisteredTargetDoesNotTakeOverTheirNickname() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
SysParamsService sysParamsService = mock(SysParamsService.class);
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, deviceService, sysParamsService);
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
DeviceEntity theirs = new DeviceEntity();
|
||||
theirs.setMacAddress(OTHER_MAC);
|
||||
theirs.setAlias("老王的音响");
|
||||
when(deviceService.getDeviceByMacAddress(OTHER_MAC)).thenReturn(theirs);
|
||||
when(deviceService.getOwnedDeviceByMacAddress(OTHER_MAC)).thenReturn(theirs);
|
||||
|
||||
service.saveOrUpdate(MAC, OTHER_MAC, null, true);
|
||||
|
||||
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
assertEquals("尾号为66的设备", entityCaptor.getValue().getAlias(),
|
||||
"对方在智控台上给这台设备起的名字被抄进了调用方的通讯录");
|
||||
verify(deviceService, never()).getDeviceByMacAddress(anyString());
|
||||
verify(deviceService, never()).getOwnedDeviceByMacAddress(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankAliasOnOwnDeviceUsesTheNameFromItsOwnDeviceRow() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
SysParamsService sysParamsService = mock(SysParamsService.class);
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, deviceService, sysParamsService);
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
DeviceEntity mine = new DeviceEntity();
|
||||
mine.setMacAddress(MAC);
|
||||
mine.setAlias("我的书房");
|
||||
when(deviceService.getOwnedDeviceByMacAddress(MAC)).thenReturn(mine);
|
||||
|
||||
service.saveOrUpdate(MAC, MAC, null, true);
|
||||
|
||||
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
assertEquals("我的书房", entityCaptor.getValue().getAlias(),
|
||||
"给自己那台设备起名,用的就是自己设备表里的名字");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownDeviceWithoutAliasInDeviceTableFallsBackToMacSuffix() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
SysParamsService sysParamsService = mock(SysParamsService.class);
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, deviceService, sysParamsService);
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
DeviceEntity mine = new DeviceEntity();
|
||||
mine.setMacAddress(MAC);
|
||||
mine.setAlias(null);
|
||||
when(deviceService.getOwnedDeviceByMacAddress(MAC)).thenReturn(mine);
|
||||
|
||||
service.saveOrUpdate(MAC, MAC, null, true);
|
||||
|
||||
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
assertEquals("尾号为55的设备", entityCaptor.getValue().getAlias(),
|
||||
"自己的设备在设备表里也没起名,落个空名的话缓存刷新会跳过这条");
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateDeviceRowsDifferingOnlyInCaseDoNotBreakNaming() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
SysParamsService sysParamsService = mock(SysParamsService.class);
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, deviceService, sysParamsService);
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
// 设备表里同一个 MAC 有两行只差大小写,一次只准取一条的那种查法会直接抛异常
|
||||
when(deviceService.getDeviceByMacAddress(anyString()))
|
||||
.thenThrow(new TooManyResultsException("selectOne 撞上两行只差大小写的数据"));
|
||||
DeviceEntity first = new DeviceEntity();
|
||||
first.setMacAddress(MAC);
|
||||
first.setAlias("我的书房");
|
||||
when(deviceService.getOwnedDeviceByMacAddress(MAC)).thenReturn(first);
|
||||
|
||||
assertDoesNotThrow(() -> service.saveOrUpdate(MAC, MAC, null, true),
|
||||
"同 MAC 有两行只差大小写时,取名不能把整个接口带崩");
|
||||
|
||||
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
assertEquals("我的书房", entityCaptor.getValue().getAlias(),
|
||||
"撞行时按 id 取第一条那条的名字");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownDeviceIsRecognizedWhateverCaseTheAddressIsWrittenIn() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
SysParamsService sysParamsService = mock(SysParamsService.class);
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, deviceService, sysParamsService);
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
DeviceEntity mine = new DeviceEntity();
|
||||
mine.setMacAddress(HEX_MAC);
|
||||
mine.setAlias("我的书房");
|
||||
when(deviceService.getOwnedDeviceByMacAddress(HEX_MAC)).thenReturn(mine);
|
||||
|
||||
service.saveOrUpdate(HEX_MAC, HEX_MAC.toUpperCase(), null, true);
|
||||
|
||||
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
assertEquals("我的书房", entityCaptor.getValue().getAlias(),
|
||||
"同一个地址写成大小写两种样子,就被当成别人的设备了");
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitAliasIsKeptAsIs() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
SysParamsService sysParamsService = mock(SysParamsService.class);
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, deviceService, sysParamsService);
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
|
||||
service.saveOrUpdate(MAC, OTHER_MAC, "客厅", true);
|
||||
|
||||
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
assertEquals("客厅", entityCaptor.getValue().getAlias(),
|
||||
"自己给别人的设备起名是通讯录的本意,不能被兜底逻辑改掉");
|
||||
verify(deviceService, never()).getDeviceByMacAddress(anyString());
|
||||
verify(deviceService, never()).getOwnedDeviceByMacAddress(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankAliasOnUnregisteredTargetDoesNotBlowUp() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
SysParamsService sysParamsService = mock(SysParamsService.class);
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, deviceService, sysParamsService);
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
when(deviceService.getOwnedDeviceByMacAddress(anyString())).thenReturn(null);
|
||||
|
||||
assertDoesNotThrow(() -> service.saveOrUpdate(MAC, OTHER_MAC, null, true),
|
||||
"目标设备没登记时接口得正常返回,不能抛异常");
|
||||
|
||||
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
assertEquals("尾号为66的设备", entityCaptor.getValue().getAlias());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -494,7 +325,7 @@ class DeviceAddressBookServiceImplTest {
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
when(deviceService.getOwnedDeviceByMacAddress(anyString())).thenReturn(null);
|
||||
when(deviceService.getDeviceByMacAddress(anyString())).thenReturn(null);
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, deviceService, mock(SysParamsService.class));
|
||||
|
||||
|
||||
-7
@@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -20,18 +19,12 @@ import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
|
||||
import xiaozhi.modules.model.dto.ModelConfigDTO;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
import xiaozhi.testsupport.IntegrationTestEnvironment;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("dev")
|
||||
@Transactional
|
||||
class ModelConfigPersistenceTest {
|
||||
|
||||
@BeforeAll
|
||||
static void checkDependencies() {
|
||||
IntegrationTestEnvironment.requireDatabaseAndRedis();
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private ModelConfigService service;
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -19,18 +18,12 @@ import xiaozhi.modules.security.dto.LoginDTO;
|
||||
import xiaozhi.modules.security.dto.SmsVerificationDTO;
|
||||
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
|
||||
import xiaozhi.modules.sys.service.SysUserService;
|
||||
import xiaozhi.testsupport.IntegrationTestEnvironment;
|
||||
|
||||
@Slf4j
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("dev")
|
||||
class loginControllerTest {
|
||||
|
||||
@BeforeAll
|
||||
static void checkDependencies() {
|
||||
IntegrationTestEnvironment.requireDatabaseAndRedis();
|
||||
}
|
||||
|
||||
@Autowired
|
||||
LoginController loginController;
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package xiaozhi.testsupport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
import org.springframework.core.env.PropertySourcesPropertyResolver;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import io.lettuce.core.RedisClient;
|
||||
import io.lettuce.core.RedisURI;
|
||||
import io.lettuce.core.api.StatefulRedisConnection;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* 几个 @SpringBootTest 要真连 MySQL 和 Redis。本地没起这两个依赖时,
|
||||
* 整类测试跳过,别让 Spring 上下文起不来变成一堆看不懂的报错;
|
||||
* CI 里依赖必须真的在,探不通就直接让构建红。
|
||||
*/
|
||||
@Slf4j
|
||||
public final class IntegrationTestEnvironment {
|
||||
|
||||
/** 集成测试用的配置文件,跟应用本身读的是同一套 */
|
||||
private static final String[] CONFIG_FILES = {"application.yml", "application-dev.yml"};
|
||||
|
||||
private static Boolean available;
|
||||
private static String problem;
|
||||
|
||||
private IntegrationTestEnvironment() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 放在要连库的测试类的 @BeforeAll 里。
|
||||
* 本地依赖不齐就整类跳过,CI 里依赖不齐就直接失败。
|
||||
*/
|
||||
public static void requireDatabaseAndRedis() {
|
||||
String issue = probe();
|
||||
if (issue == null) {
|
||||
return;
|
||||
}
|
||||
if (isCi()) {
|
||||
throw new IllegalStateException(
|
||||
"CI 环境里" + issue + ",这几个集成测试必须真的跑起来,不能跳过");
|
||||
}
|
||||
log.warn("跳过依赖 MySQL 和 Redis 的集成测试:{}", issue);
|
||||
Assumptions.assumeTrue(false, issue);
|
||||
}
|
||||
|
||||
/** GitHub Actions 上会带 CI=true */
|
||||
public static boolean isCi() {
|
||||
String value = System.getenv("CI");
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return !trimmed.isEmpty() && !"false".equalsIgnoreCase(trimmed);
|
||||
}
|
||||
|
||||
/** 依赖都齐返回 null,不齐返回缺什么 */
|
||||
static synchronized String probe() {
|
||||
if (available != null) {
|
||||
return problem;
|
||||
}
|
||||
PropertySourcesPropertyResolver resolver = resolver();
|
||||
StringBuilder issues = new StringBuilder();
|
||||
|
||||
String url = firstNonBlank(resolver.getProperty("spring.datasource.druid.url", ""),
|
||||
resolver.getProperty("spring.datasource.url", ""));
|
||||
String username = firstNonBlank(resolver.getProperty("spring.datasource.druid.username", ""),
|
||||
resolver.getProperty("spring.datasource.username", ""));
|
||||
String password = firstNonBlank(resolver.getProperty("spring.datasource.druid.password", ""),
|
||||
resolver.getProperty("spring.datasource.password", ""));
|
||||
if (url.isBlank()) {
|
||||
issues.append("配置里找不到数据源地址;");
|
||||
} else {
|
||||
try {
|
||||
DriverManager.setLoginTimeout(5);
|
||||
try (Connection connection = DriverManager.getConnection(url, username, password)) {
|
||||
connection.isValid(3);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
issues.append("连不上 MySQL(").append(e.getMessage()).append(");");
|
||||
}
|
||||
}
|
||||
|
||||
String redisHost = resolver.getProperty("spring.data.redis.host", "localhost");
|
||||
int redisPort = resolver.getProperty("spring.data.redis.port", Integer.class, 6379);
|
||||
String redisPassword = resolver.getProperty("spring.data.redis.password", "");
|
||||
try {
|
||||
RedisURI.Builder builder = RedisURI.builder()
|
||||
.withHost(redisHost)
|
||||
.withPort(redisPort)
|
||||
.withTimeout(Duration.ofSeconds(3));
|
||||
if (redisPassword != null && !redisPassword.isBlank()) {
|
||||
builder.withPassword(redisPassword.toCharArray());
|
||||
}
|
||||
RedisClient client = RedisClient.create(builder.build());
|
||||
try {
|
||||
StatefulRedisConnection<String, String> connection = client.connect();
|
||||
connection.sync().ping();
|
||||
connection.close();
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
issues.append("连不上 Redis(").append(e.getMessage()).append(");");
|
||||
}
|
||||
|
||||
if (issues.length() == 0) {
|
||||
available = Boolean.TRUE;
|
||||
problem = null;
|
||||
} else {
|
||||
available = Boolean.FALSE;
|
||||
problem = issues.toString();
|
||||
}
|
||||
return problem;
|
||||
}
|
||||
|
||||
/** 读应用自己那份配置,命令行上的 -D 参数优先级更高,跟应用保持一致 */
|
||||
private static PropertySourcesPropertyResolver resolver() {
|
||||
ConfigurableEnvironment environment = new StandardEnvironment();
|
||||
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
|
||||
for (String name : CONFIG_FILES) {
|
||||
Resource resource = new ClassPathResource(name);
|
||||
if (!resource.exists()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
for (PropertySource<?> source : loader.load(name, resource)) {
|
||||
environment.getPropertySources().addLast(source);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("读配置 {} 失败:{}", name, e.getMessage());
|
||||
}
|
||||
}
|
||||
return new PropertySourcesPropertyResolver(environment.getPropertySources());
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String first, String second) {
|
||||
return first != null && !first.isBlank() ? first : (second == null ? "" : second);
|
||||
}
|
||||
}
|
||||
@@ -77,11 +77,7 @@
|
||||
<el-form :model="formData.configJson" label-width="auto" label-position="left" class="custom-form">
|
||||
<div v-for="(row, rowIndex) in chunkedCallInfoFields" :key="rowIndex" class="form-row">
|
||||
<el-form-item v-for="field in row" :key="field.prop" :label="field.label" :prop="field.prop" style="flex: 1;">
|
||||
<el-select v-if="field.type === 'select'" v-model="formData.configJson[field.prop]"
|
||||
:placeholder="field.placeholder" class="custom-input-bg" style="width: 100%">
|
||||
<el-option v-for="opt in field.options" :key="opt" :label="opt" :value="opt"></el-option>
|
||||
</el-select>
|
||||
<el-input v-else-if="field.type === 'json-textarea'" v-model="formData.configJson[field.prop]" type="textarea"
|
||||
<el-input v-if="field.type === 'json-textarea'" v-model="formData.configJson[field.prop]" type="textarea"
|
||||
:rows="3" :placeholder="field.placeholder" class="custom-input-bg">
|
||||
</el-input>
|
||||
<el-input v-else v-model="formData.configJson[field.prop]" :placeholder="field.placeholder"
|
||||
@@ -168,8 +164,7 @@ export default {
|
||||
fields: JSON.parse(item.fields || '[]').map(f => ({
|
||||
label: f.label,
|
||||
prop: f.key,
|
||||
type: f.type === 'dict' ? 'json-textarea' : f.type === 'password' ? 'password' : (f.type === 'select' ? 'select' : 'text'),
|
||||
options: f.options || [],
|
||||
type: f.type === 'dict' ? 'json-textarea' : f.type === 'password' ? 'password' : 'text',
|
||||
placeholder: `请输入${f.key}`
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -81,11 +81,6 @@
|
||||
"></el-input>
|
||||
</template>
|
||||
|
||||
<el-select v-else-if="field.type === 'select'" v-model="form.configJson[field.prop]"
|
||||
:placeholder="field.placeholder" style="width: 100%">
|
||||
<el-option v-for="opt in field.options" :key="opt" :label="opt" :value="opt"></el-option>
|
||||
</el-select>
|
||||
|
||||
<el-input v-else v-model="form.configJson[field.prop]" :placeholder="field.placeholder" :type="field.type"
|
||||
:show-password="field.type === 'password'" @focus="
|
||||
isSensitiveField(field.prop)
|
||||
@@ -317,10 +312,7 @@ export default {
|
||||
? "json-textarea"
|
||||
: f.type === "password"
|
||||
? "password"
|
||||
: f.type === "select"
|
||||
? "select"
|
||||
: "text",
|
||||
options: f.options || [],
|
||||
: "text",
|
||||
placeholder: `请输入${f.key}`,
|
||||
}));
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from core.http_server import SimpleHttpServer
|
||||
from core.websocket_server import WebSocketServer
|
||||
from core.utils.util import check_ffmpeg_installed
|
||||
from core.utils.gc_manager import get_gc_manager
|
||||
from core.handle.helloHandle import preheat_wakeup_audio_cache
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
@@ -65,9 +64,6 @@ async def main():
|
||||
# 添加 stdin 监控任务
|
||||
stdin_task = asyncio.create_task(monitor_stdin())
|
||||
|
||||
# 预热唤醒词音频编码缓存,让首条连接就命中
|
||||
asyncio.create_task(preheat_wakeup_audio_cache())
|
||||
|
||||
# 启动全局GC管理器(5分钟清理一次)
|
||||
gc_manager = get_gc_manager(interval_seconds=300)
|
||||
await gc_manager.start()
|
||||
|
||||
@@ -119,6 +119,10 @@ wakeup_words:
|
||||
# MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
|
||||
# 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
|
||||
mcp_endpoint: 你的接入点 websocket地址
|
||||
# 声纹识别到说话人后,是否把身份附在 MCP 接入点 tools/call 的 params._meta 上。
|
||||
# true:写入 speakerId、speakerName。未识别到说话人时不附带,请求与原来一致。
|
||||
# 工具参数本身不改。false:完全不附带。
|
||||
mcp_forward_speaker: true
|
||||
|
||||
# 上下文源配置
|
||||
# 用于在系统提示词中注入动态数据,如健康数据、股票信息等
|
||||
@@ -172,19 +176,18 @@ plugins:
|
||||
# ragflow知识库id
|
||||
dataset_ids: ["123456789"]
|
||||
# 联网搜索插件配置
|
||||
# 支持秘塔(metaso)、Tavily、Serply和Outcrawl四个搜索源
|
||||
# 支持秘塔(metaso)、Tavily和Serply三个搜索源
|
||||
# 秘塔API Key申请:https://metaso.cn/search-api/api-keys
|
||||
# Tavily API Key申请:https://app.tavily.com/home
|
||||
# Serply API Key申请:https://serply.io
|
||||
# Outcrawl API Key申请:https://outcrawl.ai
|
||||
web_search:
|
||||
# 搜索源:metaso / tavily / serply / outcrawl
|
||||
# 搜索源:metaso / tavily / serply
|
||||
provider: "metaso"
|
||||
# 工具描述,用于告知LLM何时调用此工具,可根据需要自定义
|
||||
description: "联网搜索工具。当用户明确需要联网搜索问题时使用此工具。"
|
||||
# 返回结果数量(默认5)
|
||||
max_results: 5
|
||||
# 秘塔API Key(mk-前缀) / Tavily API Key(tvly-前缀) / Serply API Key / Outcrawl API Key
|
||||
# 秘塔API Key(mk-前缀) / Tavily API Key(tvly-前缀) / Serply API Key
|
||||
api_key: "mk-xxx"
|
||||
# 声纹识别配置
|
||||
voiceprint:
|
||||
@@ -399,8 +402,8 @@ ASR:
|
||||
# DoubaoASR和DoubaoStreamASR的区别是:DoubaoASR是按次收费,DoubaoStreamASR是按时收费
|
||||
# 一般来说按次收费的更便宜,但是DoubaoStreamASR使用了大模型技术,效果更好
|
||||
type: doubao
|
||||
appid: 你的火山引擎语音识别服务appid
|
||||
access_token: 你的火山引擎语音识别服务access_token
|
||||
appid: 你的火山引擎语音合成服务appid
|
||||
access_token: 你的火山引擎语音合成服务access_token
|
||||
cluster: volcengine_input_common
|
||||
# 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738
|
||||
boosting_table_name: (选填)你的热词文件名称
|
||||
@@ -413,8 +416,8 @@ ASR:
|
||||
# 开通地址https://console.volcengine.com/speech/service/10011
|
||||
# 一般来说按次收费的更便宜,但是DoubaoStreamASR使用了大模型技术,效果更好
|
||||
type: doubao_stream
|
||||
appid: 你的火山引擎语音识别服务appid
|
||||
access_token: 你的火山引擎语音识别服务access_token
|
||||
appid: 你的火山引擎语音合成服务appid
|
||||
access_token: 你的火山引擎语音合成服务access_token
|
||||
# 资源ID:小时版 volc.bigasr.sauc.duration,并发版 volc.bigasr.sauc.concurrent
|
||||
resource_id: volc.bigasr.sauc.duration
|
||||
# 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738
|
||||
@@ -433,8 +436,8 @@ ASR:
|
||||
# 开通地址:https://console.volcengine.com/speech/service/10038
|
||||
# 价格更为便宜,建议在高并发场景下使用
|
||||
type: doubao_stream
|
||||
appid: 你的火山引擎语音识别服务appid
|
||||
access_token: 你的火山引擎语音识别服务access_token
|
||||
appid: 你的火山引擎语音合成服务appid
|
||||
access_token: 你的火山引擎语音合成服务access_token
|
||||
# 资源ID:小时版 volc.seedasr.sauc.duration,并发版 volc.seedasr.sauc.concurrent
|
||||
resource_id: volc.seedasr.sauc.duration
|
||||
# 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738
|
||||
@@ -452,9 +455,9 @@ ASR:
|
||||
# token申请地址:https://console.cloud.tencent.com/cam/capi
|
||||
# 免费领取资源:https://console.cloud.tencent.com/asr/resourcebundle
|
||||
type: tencent
|
||||
appid: 你的腾讯语音识别服务appid
|
||||
secret_id: 你的腾讯语音识别服务secret_id
|
||||
secret_key: 你的腾讯语音识别服务secret_key
|
||||
appid: 你的腾讯语音合成服务appid
|
||||
secret_id: 你的腾讯语音合成服务secret_id
|
||||
secret_key: 你的腾讯语音合成服务secret_key
|
||||
output_dir: tmp/
|
||||
AliyunASR:
|
||||
# 阿里云智能语音交互服务,需要先在阿里云平台开通服务,然后获取验证信息
|
||||
@@ -620,7 +623,7 @@ LLM:
|
||||
# 可在这里找到你的 api_key https://bailian.console.aliyun.com/?apiKey=1#/api-key
|
||||
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
model_name: qwen-flash
|
||||
api_key: 你的阿里云百炼API密钥
|
||||
api_key: 你的deepseek web key
|
||||
temperature: 0.7 # 温度值
|
||||
max_tokens: 500 # 最大生成token数
|
||||
top_p: 1
|
||||
@@ -764,10 +767,10 @@ VLLM:
|
||||
# 先新建应用,在下面的地址
|
||||
# 开通应用地址:https://console.xfyun.cn/app/myapp
|
||||
# 有免费额度,但也要开通服务,才能获取api_key
|
||||
# 每一个模型都需要单独开通,每一个模型的 api_key 都不同,例如 Lite 模型在 https://console.xfyun.cn/services/cbm 开通
|
||||
# 每一个模型都需要单独开通,每一个模型的api_password都不同,例如Lite模型在https://console.xfyun.cn/services/cbm 开通
|
||||
base_url: https://ark.cn-beijing.volces.com/api/v3
|
||||
model_name: lite
|
||||
api_key: 你的api_key
|
||||
api_key: 你的api_password
|
||||
TTS:
|
||||
# 当前支持的type为edge、doubao,可自行适配
|
||||
EdgeTTS:
|
||||
@@ -1118,8 +1121,8 @@ TTS:
|
||||
#SpeechServerDemo https://github.com/PaddlePaddle/PaddleSpeech/tree/develop/demos/speech_server
|
||||
#流式传输请参考 https://github.com/PaddlePaddle/PaddleSpeech/wiki/PaddleSpeech-Server-WebSocket-API
|
||||
type: paddle_speech
|
||||
protocol: websocket # 当前仅实现 websocket;填其他值会在启动时报错
|
||||
url: ws://127.0.0.1:8092/paddlespeech/tts/streaming # TTS 服务地址(未配置时代码兜底同此本机地址)
|
||||
protocol: websocket # protocol choices = ['websocket', 'http']
|
||||
url: ws://127.0.0.1:8092/paddlespeech/tts/streaming # TTS 服务的 URL 地址,指向本地服务器 [websocket默认ws://127.0.0.1:8092/paddlespeech/tts/streaming,http默认http://127.0.0.1:8090/paddlespeech/tts]
|
||||
spk_id: 0 # 发音人 ID,0 通常表示默认的发音人
|
||||
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
||||
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
||||
|
||||
@@ -42,7 +42,7 @@ from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType
|
||||
from config.logger import setup_logging, build_module_string, create_connection_logger
|
||||
from config.manage_api_client import DeviceNotFoundException, DeviceBindException, generate_and_save_chat_title
|
||||
from core.utils.prompt_manager import PromptManager
|
||||
from core.utils.voiceprint_provider import VoiceprintProvider
|
||||
from core.utils.voiceprint_provider import VoiceprintProvider, UNKNOWN_SPEAKER_NAME
|
||||
from core.utils.util import get_system_error_response
|
||||
from core.utils import textUtils
|
||||
|
||||
@@ -158,7 +158,7 @@ class ConnectionHandler:
|
||||
# 所以涉及到ASR的变量,需要在这里定义,属于connection的私有变量
|
||||
self.asr_audio = [] # 存储PCM帧列表,供VAD和ASR共享
|
||||
self.asr_audio_queue = queue.Queue()
|
||||
self.current_speaker = None # 存储当前说话人
|
||||
self.current_speaker = None # 当前说话人展示名
|
||||
self.introduced_speakers = set() # 已"首次引入"的说话人,控制只在首轮带名字
|
||||
self.system_introduced_speakers = set() # 已在 system 注入过身份的说话人,控制 system 身份只首轮出现
|
||||
|
||||
@@ -781,7 +781,7 @@ class ConnectionHandler:
|
||||
self.voiceprint_provider = voiceprint_provider
|
||||
self.logger.bind(tag=TAG).info("声纹识别功能已在连接时动态启用")
|
||||
else:
|
||||
self.logger.bind(tag=TAG).debug("声纹识别功能启用但配置不完整")
|
||||
self.logger.bind(tag=TAG).warning("声纹识别功能启用但配置不完整")
|
||||
else:
|
||||
self.logger.bind(tag=TAG).info("声纹识别功能未启用")
|
||||
except Exception as e:
|
||||
@@ -1052,7 +1052,8 @@ class ConnectionHandler:
|
||||
# 更新系统prompt至上下文
|
||||
self.dialogue.update_system_message(self.prompt)
|
||||
|
||||
def chat(self, query, depth=0):
|
||||
def chat(self, query, depth=0, speaker=None):
|
||||
# speaker: 本轮声纹身份快照,随工具调用下传
|
||||
# 保存当前任务的sentence_id到局部变量,避免被新任务覆盖
|
||||
current_sentence_id = None
|
||||
|
||||
@@ -1122,7 +1123,7 @@ class ConnectionHandler:
|
||||
# 避免每轮在 system 重复出现名字诱导模型反复称呼
|
||||
speaker_for_system = None
|
||||
cs = (self.current_speaker or "").strip()
|
||||
if cs and cs != "未知说话人" and cs not in self.system_introduced_speakers:
|
||||
if cs and cs != UNKNOWN_SPEAKER_NAME and cs not in self.system_introduced_speakers:
|
||||
self.system_introduced_speakers.add(cs)
|
||||
speaker_for_system = cs
|
||||
|
||||
@@ -1337,7 +1338,7 @@ class ConnectionHandler:
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.func_handler.handle_llm_function_call(
|
||||
self, tool_call_data
|
||||
self, tool_call_data, speaker=speaker
|
||||
),
|
||||
self.loop,
|
||||
)
|
||||
@@ -1500,7 +1501,7 @@ class ConnectionHandler:
|
||||
)
|
||||
)
|
||||
|
||||
self.chat(None, depth=depth + 1)
|
||||
self.chat(None, depth=depth + 1, speaker=speaker)
|
||||
|
||||
def _report_worker(self):
|
||||
"""聊天记录上报工作线程"""
|
||||
|
||||
@@ -4,7 +4,6 @@ import uuid
|
||||
import random
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
from config.logger import setup_logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.connection import ConnectionHandler
|
||||
@@ -14,14 +13,9 @@ from core.providers.tts.dto.dto import SentenceType
|
||||
from core.utils.wakeup_word import WakeupWordsConfig
|
||||
from core.handle.sendAudioHandle import sendAudioMessage, send_tts_message
|
||||
from core.utils.util import remove_punctuation_and_length, opus_datas_to_wav_bytes
|
||||
from core.providers.tools.device_mcp import (
|
||||
MCPClient,
|
||||
send_mcp_initialize_message,
|
||||
send_mcp_tools_list_request,
|
||||
)
|
||||
from core.providers.tools.device_mcp import MCPClient, send_mcp_initialize_message
|
||||
|
||||
TAG = __name__
|
||||
_wakeup_logger = setup_logging()
|
||||
|
||||
WAKEUP_CONFIG = {
|
||||
"refresh_time": 10,
|
||||
@@ -69,8 +63,6 @@ async def handleHelloMessage(conn: "ConnectionHandler", msg_json):
|
||||
# The device waits for the server hello before processing MCP messages.
|
||||
if features and features.get("mcp"):
|
||||
asyncio.create_task(send_mcp_initialize_message(conn))
|
||||
# 并发预发 tools/list,让帧排在 audio 流之前到达 client
|
||||
asyncio.create_task(send_mcp_tools_list_request(conn))
|
||||
|
||||
|
||||
async def checkWakeupWords(conn: "ConnectionHandler", text):
|
||||
@@ -113,19 +105,19 @@ async def checkWakeupWords(conn: "ConnectionHandler", text):
|
||||
}
|
||||
|
||||
# 获取音频数据
|
||||
opus_packets = await audio_to_data(response.get("file_path"), use_cache=True)
|
||||
opus_packets = await audio_to_data(response.get("file_path"), use_cache=False)
|
||||
# 播放唤醒词回复
|
||||
conn.client_abort = False
|
||||
|
||||
# 将唤醒词回复视为新会话,生成新的 sentence_id,确保流控器重置
|
||||
conn.sentence_id = str(uuid.uuid4().hex)
|
||||
|
||||
conn.logger.bind(tag=TAG).info(f"播放唤醒词回复: {response.get('text')}")
|
||||
await sendAudioMessage(conn, SentenceType.FIRST, opus_packets, response.get("text"))
|
||||
await sendAudioMessage(conn, SentenceType.LAST, [], None)
|
||||
|
||||
# 补充对话
|
||||
conn.dialogue.put(Message(role="assistant", content=response.get("text")))
|
||||
conn.logger.bind(tag=TAG).info(f"播放唤醒词回复: {response.get('text')}")
|
||||
|
||||
await sendAudioMessage(conn, SentenceType.FIRST, opus_packets, response.get("text"))
|
||||
asyncio.create_task(_finish_wakeup_reply(conn))
|
||||
|
||||
# 检查是否需要更新唤醒词回复
|
||||
if time.time() - response.get("time", 0) > WAKEUP_CONFIG["refresh_time"]:
|
||||
@@ -134,16 +126,6 @@ async def checkWakeupWords(conn: "ConnectionHandler", text):
|
||||
return True
|
||||
|
||||
|
||||
async def _finish_wakeup_reply(conn: "ConnectionHandler"):
|
||||
"""等音频队列清空后再发 stop(时序:全部音频帧→stop),期间不阻塞接收循环"""
|
||||
try:
|
||||
await sendAudioMessage(conn, SentenceType.LAST, [], None)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
conn.logger.bind(tag=TAG).error(f"唤醒词回复收尾失败: {e}")
|
||||
|
||||
|
||||
async def wakeupWordsResponse(conn: "ConnectionHandler"):
|
||||
if not conn.tts:
|
||||
return
|
||||
@@ -178,32 +160,7 @@ async def wakeupWordsResponse(conn: "ConnectionHandler"):
|
||||
f.write(wav_bytes)
|
||||
# 更新配置
|
||||
wakeup_words_config.update_wakeup_response(voice, file_path, result)
|
||||
# 覆写文件后 mtime 变化使旧缓存失效,此处立刻预编码新文件,
|
||||
# 下次唤醒直接命中缓存,编码成本不占用唤醒响应路径
|
||||
await audio_to_data(file_path, use_cache=True)
|
||||
finally:
|
||||
# 确保在任何情况下都释放锁
|
||||
if _wakeup_response_lock.locked():
|
||||
_wakeup_response_lock.release()
|
||||
|
||||
|
||||
async def preheat_wakeup_audio_cache():
|
||||
"""进程启动时预热唤醒词音频编码缓存
|
||||
|
||||
把 wakeup_words/{voice}.wav 在 Opus 编码结果一次性算好塞进全局 cache,
|
||||
后续每条连接上来就能直接命中,省掉 wav→PCM→Opus 的数百毫秒开销。
|
||||
"""
|
||||
# 进程启动时 tts 还未初始化,无法拿到 conn.tts.voice,按常见 fallback 列表预热
|
||||
preheat_voices = ["default", "female-shaonv", "male-qn-jingying"]
|
||||
fallback_path = "config/assets/wakeup_words_short.wav"
|
||||
for voice in preheat_voices:
|
||||
file_path = fallback_path
|
||||
try:
|
||||
response = wakeup_words_config.get_wakeup_response(voice)
|
||||
if response and response.get("file_path"):
|
||||
file_path = response["file_path"]
|
||||
await audio_to_data(file_path, use_cache=True)
|
||||
except Exception as e:
|
||||
_wakeup_logger.bind(tag=TAG).warning(
|
||||
f"预热 voice={voice} 失败: {e}"
|
||||
)
|
||||
@@ -17,7 +17,7 @@ from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType
|
||||
TAG = __name__
|
||||
|
||||
|
||||
async def handle_user_intent(conn: "ConnectionHandler", text):
|
||||
async def handle_user_intent(conn: "ConnectionHandler", text, speaker=None):
|
||||
# 预处理输入文本,处理可能的JSON格式
|
||||
try:
|
||||
if text.strip().startswith("{") and text.strip().endswith("}"):
|
||||
@@ -172,7 +172,7 @@ async def process_intent_result(
|
||||
try:
|
||||
result = asyncio.run_coroutine_threadsafe(
|
||||
conn.func_handler.handle_llm_function_call(
|
||||
conn, function_call_data
|
||||
conn, function_call_data, speaker=speaker
|
||||
),
|
||||
conn.loop,
|
||||
).result(timeout=tool_call_timeout)
|
||||
|
||||
@@ -40,7 +40,8 @@ async def resume_vad_detection(conn: "ConnectionHandler"):
|
||||
conn.just_woken_up = False
|
||||
|
||||
|
||||
async def startToChat(conn: "ConnectionHandler", text):
|
||||
async def startToChat(conn: "ConnectionHandler", text, speaker=None):
|
||||
"""speaker: 本轮声纹身份快照,随调用链传递。"""
|
||||
# 检查输入是否是JSON格式(包含说话人信息)
|
||||
speaker_name = None
|
||||
actual_text = text
|
||||
@@ -69,11 +70,8 @@ async def startToChat(conn: "ConnectionHandler", text):
|
||||
# 如果解析失败,继续使用原始文本
|
||||
pass
|
||||
|
||||
# 保存说话人信息到连接对象
|
||||
if speaker_name:
|
||||
conn.current_speaker = speaker_name
|
||||
else:
|
||||
conn.current_speaker = None
|
||||
# 说话人名供提示词注入;声纹身份走 speaker 快照,不挂连接
|
||||
conn.current_speaker = speaker_name
|
||||
|
||||
if conn.need_bind:
|
||||
await check_bind_device(conn)
|
||||
@@ -92,7 +90,7 @@ async def startToChat(conn: "ConnectionHandler", text):
|
||||
await handleAbortMessage(conn)
|
||||
|
||||
# 首先进行意图分析,使用实际文本内容
|
||||
intent_handled = await handle_user_intent(conn, actual_text)
|
||||
intent_handled = await handle_user_intent(conn, actual_text, speaker=speaker)
|
||||
|
||||
if intent_handled:
|
||||
# 如果意图已被处理,不再进行聊天
|
||||
@@ -104,7 +102,7 @@ async def startToChat(conn: "ConnectionHandler", text):
|
||||
# 准备开始新会话
|
||||
conn.client_abort = False
|
||||
|
||||
conn.executor.submit(conn.chat, actual_text)
|
||||
conn.executor.submit(conn.chat, actual_text, depth=0, speaker=speaker)
|
||||
|
||||
|
||||
async def no_voice_close_connect(conn: "ConnectionHandler", have_voice):
|
||||
|
||||
@@ -12,8 +12,10 @@ from core.providers.tts.dto.dto import SentenceType
|
||||
from core.utils.audioRateController import AudioRateController
|
||||
|
||||
TAG = __name__
|
||||
AUDIO_FRAME_DURATION = 60 # 音频帧时长(毫秒),与 Opus 编码 60ms/帧对齐
|
||||
PRE_BUFFER_COUNT = 2 # 首段发送时不入队的帧数,避免流控延迟拉高首帧到达时间
|
||||
# 音频帧时长(毫秒)
|
||||
AUDIO_FRAME_DURATION = 60
|
||||
# 预缓冲包数量,直接发送以减少延迟
|
||||
PRE_BUFFER_COUNT = 5
|
||||
|
||||
|
||||
async def sendAudioMessage(conn: "ConnectionHandler", sentenceType, audios, text, sentence_id=None):
|
||||
|
||||
@@ -17,6 +17,7 @@ from core.providers.asr.dto.dto import InterfaceType
|
||||
from core.handle.receiveAudioHandle import startToChat
|
||||
from core.handle.reportHandle import enqueue_asr_report
|
||||
from core.utils.util import remove_punctuation_and_length
|
||||
from core.utils.voiceprint_provider import SpeakerIdentification
|
||||
from core.handle.receiveAudioHandle import handleAudioMessage
|
||||
from typing import Optional, Tuple, List, NamedTuple, TYPE_CHECKING
|
||||
|
||||
@@ -101,7 +102,7 @@ class ASRProviderBase(ABC):
|
||||
)
|
||||
|
||||
if conn.voiceprint_provider and wav_data:
|
||||
voiceprint_task = conn.voiceprint_provider.identify_speaker(
|
||||
voiceprint_task = conn.voiceprint_provider.identify_speaker_detail(
|
||||
wav_data, conn.session_id
|
||||
)
|
||||
# 并发等待两个结果
|
||||
@@ -119,11 +120,11 @@ class ASRProviderBase(ABC):
|
||||
else:
|
||||
raw_text, _ = asr_result
|
||||
|
||||
speaker_name = ""
|
||||
if isinstance(voiceprint_result, Exception):
|
||||
logger.bind(tag=TAG).error(f"声纹识别失败: {voiceprint_result}")
|
||||
speaker_name = ""
|
||||
else:
|
||||
speaker_name = voiceprint_result
|
||||
elif voiceprint_result is not None:
|
||||
speaker_name = voiceprint_result.name or ""
|
||||
|
||||
# 判断 ASR 结果类型
|
||||
if isinstance(raw_text, dict):
|
||||
@@ -164,10 +165,16 @@ class ASRProviderBase(ABC):
|
||||
self.stop_ws_connection()
|
||||
|
||||
if text_len > 0:
|
||||
# 本轮声纹身份快照,随调用链供 MCP 接入点工具使用
|
||||
speaker = (
|
||||
voiceprint_result
|
||||
if isinstance(voiceprint_result, SpeakerIdentification)
|
||||
else None
|
||||
)
|
||||
audio_snapshot = asr_audio_task.copy()
|
||||
enqueue_asr_report(conn, enhanced_text, audio_snapshot)
|
||||
# 使用自定义模块进行上报
|
||||
await startToChat(conn, enhanced_text)
|
||||
await startToChat(conn, enhanced_text, speaker=speaker)
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
|
||||
import traceback
|
||||
|
||||
@@ -16,20 +16,12 @@ THINKING_DISABLED_DOMAINS = {
|
||||
"bigmodel.cn": {"thinking": {"type": "disabled"}},
|
||||
"moonshot.cn": {"thinking": {"type": "disabled"}},
|
||||
"volces.com": {"thinking": {"type": "disabled"}},
|
||||
"modelarts-maas.com": {"thinking": {"type": "disabled"}},
|
||||
}
|
||||
|
||||
# 域名规则的模型名豁免表: 强制思考模型不接受该参数注入(如 MaaS glm-5.3 传 thinking 必 400)
|
||||
THINKING_DISABLED_MODEL_EXCLUSIONS = {
|
||||
"modelarts-maas.com": ("glm-5.3",),
|
||||
}
|
||||
|
||||
|
||||
class LLMProvider(LLMProviderBase):
|
||||
def __init__(self, config):
|
||||
self.model_name = config.get("model_name")
|
||||
# 可选实例级思考强度: off/disabled/none/disable | low/medium/high | 空/其他=按域名默认
|
||||
self.thinking_mode = str(config.get("thinking") or "").strip().lower()
|
||||
self.api_key = config.get("api_key")
|
||||
if "base_url" in config:
|
||||
self.base_url = config.get("base_url")
|
||||
@@ -88,34 +80,14 @@ class LLMProvider(LLMProviderBase):
|
||||
return dialogue
|
||||
|
||||
def _apply_thinking_disabled(self, request_params: dict):
|
||||
"""思考模式控制: 实例配置(thinking)优先, 否则按域名默认表"""
|
||||
mode = getattr(self, "thinking_mode", "")
|
||||
if mode in ("keep", "passthrough", "default"):
|
||||
# 不干预: 既不发 thinking 参数也不查域名表(适用于强制思考/拒绝该参数的模型, 如 MaaS glm-5.3)
|
||||
return
|
||||
if mode in ("off", "disabled", "none", "disable"):
|
||||
request_params.setdefault("extra_body", {}).update({"thinking": {"type": "disabled"}})
|
||||
logger.bind(tag=TAG).info("思考强度=off: 已禁用思考模式(实例配置)")
|
||||
return
|
||||
if mode in ("low", "medium", "high"):
|
||||
request_params.setdefault("extra_body", {}).update(
|
||||
{"reasoning_effort": mode, "thinking": {"type": "enabled"}})
|
||||
logger.bind(tag=TAG).info(f"思考强度={mode}: reasoning_effort 已设置(实例配置)")
|
||||
return
|
||||
"""根据域名自动禁用思考模式"""
|
||||
parsed_url = urlparse(self.base_url)
|
||||
domain = parsed_url.netloc
|
||||
model_name = (self.model_name or "").lower()
|
||||
for disabled_domain, params in THINKING_DISABLED_DOMAINS.items():
|
||||
if disabled_domain not in domain:
|
||||
continue
|
||||
exclusions = THINKING_DISABLED_MODEL_EXCLUSIONS.get(disabled_domain, ())
|
||||
if any(ex in model_name for ex in exclusions):
|
||||
logger.bind(tag=TAG).info(
|
||||
f"域名 {domain} 命中禁思考规则, 但模型 {self.model_name} 强制思考且拒绝该参数, 跳过注入")
|
||||
if disabled_domain in domain:
|
||||
request_params.setdefault("extra_body", {}).update(params)
|
||||
logger.bind(tag=TAG).info(f"为域名 {domain} 禁用思考模式,参数: {params}")
|
||||
break
|
||||
request_params.setdefault("extra_body", {}).update(params)
|
||||
logger.bind(tag=TAG).info(f"为域名 {domain} 禁用思考模式,参数: {params}")
|
||||
break
|
||||
|
||||
def response(self, session_id, dialogue, **kwargs):
|
||||
dialogue = self.normalize_dialogue(dialogue)
|
||||
|
||||
@@ -11,9 +11,9 @@ class ToolExecutor(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def execute(
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行工具调用"""
|
||||
"""执行工具调用。speaker: 可选的说话人身份快照。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -15,7 +15,7 @@ class DeviceIoTExecutor(ToolExecutor):
|
||||
self.iot_tools: Dict[str, ToolDefinition] = {}
|
||||
|
||||
async def execute(
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行设备端IoT工具"""
|
||||
if not self.has_tool(tool_name):
|
||||
|
||||
@@ -17,11 +17,7 @@ class MCPClient:
|
||||
self.name_mapping = {}
|
||||
self.ready = False
|
||||
self.call_results = {} # To store Futures for tool call responses
|
||||
# Handshake hardcodes id=1 (initialize) and id=2 (tools/list) in mcp_handler.
|
||||
# JSON-RPC 2.0 forbids reusing request ids on the same connection, and
|
||||
# compliant device MCP servers reject tools/call with id 1/2 (see #3461).
|
||||
# Official protocol sequence expects the first tools/call to use id=3.
|
||||
self.next_id = 3
|
||||
self.next_id = 1
|
||||
self.lock = asyncio.Lock()
|
||||
self._cached_available_tools = None # Cache for get_available_tools
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class DeviceMCPExecutor(ToolExecutor):
|
||||
self.conn = conn
|
||||
|
||||
async def execute(
|
||||
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行设备端MCP工具"""
|
||||
if not hasattr(conn, "mcp_client") or not conn.mcp_client:
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import json
|
||||
import asyncio
|
||||
import re
|
||||
from core.utils.util import get_vision_url
|
||||
from concurrent.futures import Future
|
||||
from core.utils.util import get_vision_url, sanitize_tool_name
|
||||
from core.utils.auth import AuthToken
|
||||
from config.logger import setup_logging
|
||||
from .mcp_client import MCPClient
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -15,6 +15,91 @@ if TYPE_CHECKING:
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class MCPClient:
|
||||
"""设备端MCP客户端,用于管理MCP状态和工具"""
|
||||
|
||||
def __init__(self):
|
||||
self.tools = {} # sanitized_name -> tool_data
|
||||
self.name_mapping = {}
|
||||
self.ready = False
|
||||
self.call_results = {} # To store Futures for tool call responses
|
||||
self.next_id = 1
|
||||
self.lock = asyncio.Lock()
|
||||
self._cached_available_tools = None # Cache for get_available_tools
|
||||
|
||||
def has_tool(self, name: str) -> bool:
|
||||
return name in self.tools
|
||||
|
||||
def get_available_tools(self) -> list:
|
||||
# Check if the cache is valid
|
||||
if self._cached_available_tools is not None:
|
||||
return self._cached_available_tools
|
||||
|
||||
# If cache is not valid, regenerate the list
|
||||
result = []
|
||||
for tool_name, tool_data in self.tools.items():
|
||||
function_def = {
|
||||
"name": tool_name,
|
||||
"description": tool_data["description"],
|
||||
"parameters": {
|
||||
"type": tool_data["inputSchema"].get("type", "object"),
|
||||
"properties": tool_data["inputSchema"].get("properties", {}),
|
||||
"required": tool_data["inputSchema"].get("required", []),
|
||||
},
|
||||
}
|
||||
result.append({"type": "function", "function": function_def})
|
||||
|
||||
self._cached_available_tools = result # Store the generated list in cache
|
||||
return result
|
||||
|
||||
async def is_ready(self) -> bool:
|
||||
async with self.lock:
|
||||
return self.ready
|
||||
|
||||
async def set_ready(self, status: bool):
|
||||
async with self.lock:
|
||||
self.ready = status
|
||||
|
||||
async def add_tool(self, tool_data: dict):
|
||||
async with self.lock:
|
||||
sanitized_name = sanitize_tool_name(tool_data["name"])
|
||||
self.tools[sanitized_name] = tool_data
|
||||
self.name_mapping[sanitized_name] = tool_data["name"]
|
||||
self._cached_available_tools = (
|
||||
None # Invalidate the cache when a tool is added
|
||||
)
|
||||
|
||||
async def get_next_id(self) -> int:
|
||||
async with self.lock:
|
||||
current_id = self.next_id
|
||||
self.next_id += 1
|
||||
return current_id
|
||||
|
||||
async def register_call_result_future(self, id: int, future: Future):
|
||||
async with self.lock:
|
||||
self.call_results[id] = future
|
||||
|
||||
async def resolve_call_result(self, id: int, result: any):
|
||||
async with self.lock:
|
||||
if id in self.call_results:
|
||||
future = self.call_results.pop(id)
|
||||
if not future.done():
|
||||
future.set_result(result)
|
||||
|
||||
async def reject_call_result(self, id: int, exception: Exception):
|
||||
async with self.lock:
|
||||
if id in self.call_results:
|
||||
future = self.call_results.pop(id)
|
||||
if not future.done():
|
||||
future.set_exception(exception)
|
||||
|
||||
async def cleanup_call_result(self, id: int):
|
||||
async with self.lock:
|
||||
if id in self.call_results:
|
||||
self.call_results.pop(id)
|
||||
|
||||
|
||||
async def send_mcp_message(conn: "ConnectionHandler", payload: dict):
|
||||
"""Helper to send MCP messages, encapsulating common logic."""
|
||||
if not conn.features.get("mcp"):
|
||||
@@ -62,6 +147,11 @@ async def handle_mcp_message(
|
||||
logger.bind(tag=TAG).debug(
|
||||
f"客户端MCP服务器信息: name={name}, version={version}"
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
logger.bind(tag=TAG).debug("初始化完成,开始请求MCP工具列表")
|
||||
await send_mcp_tools_list_request(conn)
|
||||
|
||||
return
|
||||
|
||||
elif msg_id == 2: # mcpToolsListID
|
||||
|
||||
@@ -18,9 +18,7 @@ class MCPEndpointClient:
|
||||
self.name_mapping = {}
|
||||
self.ready = False
|
||||
self.call_results = {} # To store Futures for tool call responses
|
||||
# Handshake hardcodes id=1 (initialize) and id=2 (tools/list) in
|
||||
# mcp_endpoint_handler; tools/call must start at 3 to avoid id reuse.
|
||||
self.next_id = 3
|
||||
self.next_id = 1
|
||||
self.lock = asyncio.Lock()
|
||||
self._cached_available_tools = None # Cache for get_available_tools
|
||||
self.websocket = None # WebSocket连接
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""MCP接入点工具执行器"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional
|
||||
from ..base import ToolType, ToolDefinition, ToolExecutor
|
||||
from plugins_func.register import Action, ActionResponse
|
||||
from core.utils.voiceprint_provider import SpeakerIdentification
|
||||
from .mcp_endpoint_handler import call_mcp_endpoint_tool
|
||||
from .mcp_speaker import should_forward_speaker, speaker_call_meta
|
||||
|
||||
|
||||
class MCPEndpointExecutor(ToolExecutor):
|
||||
@@ -13,9 +15,9 @@ class MCPEndpointExecutor(ToolExecutor):
|
||||
self.conn = conn
|
||||
|
||||
async def execute(
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any], speaker: Optional[SpeakerIdentification] = None
|
||||
) -> ActionResponse:
|
||||
"""执行MCP接入点工具"""
|
||||
"""执行MCP接入点工具。speaker: 本轮说话人快照。"""
|
||||
if not hasattr(conn, "mcp_endpoint_client") or not conn.mcp_endpoint_client:
|
||||
return ActionResponse(
|
||||
action=Action.ERROR,
|
||||
@@ -29,14 +31,20 @@ class MCPEndpointExecutor(ToolExecutor):
|
||||
)
|
||||
|
||||
try:
|
||||
# 转换参数为JSON字符串
|
||||
# 转换参数为JSON字符串。说话人身份不写入参数,只放在 _meta。
|
||||
import json
|
||||
|
||||
meta = None
|
||||
if speaker is not None and should_forward_speaker(
|
||||
getattr(conn, "config", None)
|
||||
):
|
||||
meta = speaker_call_meta(speaker.speaker_id, speaker.name)
|
||||
|
||||
args_str = json.dumps(arguments) if arguments else "{}"
|
||||
|
||||
# 调用MCP接入点工具
|
||||
result = await call_mcp_endpoint_tool(
|
||||
conn.mcp_endpoint_client, tool_name, args_str
|
||||
conn.mcp_endpoint_client, tool_name, args_str, meta=meta
|
||||
)
|
||||
|
||||
resultJson = None
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
import websockets
|
||||
from config.logger import setup_logging
|
||||
from .mcp_endpoint_client import MCPEndpointClient
|
||||
from .mcp_speaker import build_mcp_endpoint_tool_payload
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
@@ -284,7 +285,11 @@ async def send_mcp_endpoint_tools_list_continue(
|
||||
|
||||
|
||||
async def call_mcp_endpoint_tool(
|
||||
mcp_client: MCPEndpointClient, tool_name: str, args: str = "{}", timeout: int = 30
|
||||
mcp_client: MCPEndpointClient,
|
||||
tool_name: str,
|
||||
args: str = "{}",
|
||||
timeout: int = 30,
|
||||
meta: dict = None,
|
||||
):
|
||||
"""
|
||||
调用指定的MCP接入点工具,并等待响应
|
||||
@@ -350,16 +355,16 @@ async def call_mcp_endpoint_tool(
|
||||
raise e
|
||||
|
||||
actual_name = mcp_client.name_mapping.get(tool_name, tool_name)
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": tool_call_id,
|
||||
"method": "tools/call",
|
||||
"params": {"name": actual_name, "arguments": arguments},
|
||||
}
|
||||
payload = build_mcp_endpoint_tool_payload(
|
||||
tool_call_id, actual_name, arguments, meta
|
||||
)
|
||||
|
||||
message = json.dumps(payload)
|
||||
log_suffix = ""
|
||||
if meta:
|
||||
log_suffix = f",_meta: {json.dumps(meta, ensure_ascii=False)}"
|
||||
logger.bind(tag=TAG).info(
|
||||
f"发送MCP接入点工具调用请求: {actual_name},参数: {json.dumps(arguments, ensure_ascii=False)}"
|
||||
f"发送MCP接入点工具调用请求: {actual_name},参数: {json.dumps(arguments, ensure_ascii=False)}{log_suffix}"
|
||||
)
|
||||
await mcp_client.send_message(message)
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""把已识别的说话人附到 MCP 接入点 tools/call 上。
|
||||
|
||||
工具参数保持模型给出的原样。身份放在 MCP 的 params._meta 里,
|
||||
接入点上的服务可以读取,不读的服务按协议忽略。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME
|
||||
|
||||
|
||||
def should_forward_speaker(config: Optional[dict]) -> bool:
|
||||
"""mcp_forward_speaker 缺省为开启。显式 false 时不附带身份。"""
|
||||
if not isinstance(config, dict):
|
||||
return True
|
||||
value = config.get("mcp_forward_speaker", True)
|
||||
if value is None:
|
||||
return True
|
||||
return str(value).strip().lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def speaker_call_meta(speaker_id: Any, speaker_name: Any) -> Optional[Dict[str, str]]:
|
||||
"""有真实身份时返回 _meta;没有则返回 None,调用方不写这个字段。"""
|
||||
meta: Dict[str, str] = {}
|
||||
if isinstance(speaker_id, str):
|
||||
speaker_id = speaker_id.strip()
|
||||
if speaker_id:
|
||||
meta["speakerId"] = speaker_id
|
||||
if isinstance(speaker_name, str):
|
||||
speaker_name = speaker_name.strip()
|
||||
if speaker_name and speaker_name != UNKNOWN_SPEAKER_NAME:
|
||||
meta["speakerName"] = speaker_name
|
||||
return meta or None
|
||||
|
||||
|
||||
def build_mcp_endpoint_tool_payload(
|
||||
tool_call_id: int,
|
||||
name: str,
|
||||
arguments: Dict[str, Any],
|
||||
meta: Optional[Dict[str, str]] = None,
|
||||
) -> dict:
|
||||
"""组装 tools/call。meta 为空时不出现 _meta,参数字典原样放进 arguments。"""
|
||||
params: Dict[str, Any] = {"name": name, "arguments": arguments}
|
||||
if meta:
|
||||
params["_meta"] = meta
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": tool_call_id,
|
||||
"method": "tools/call",
|
||||
"params": params,
|
||||
}
|
||||
@@ -107,7 +107,7 @@ class ServerMCPClient:
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.input_schema,
|
||||
"parameters": tool.inputSchema,
|
||||
},
|
||||
}
|
||||
for name, tool in self.tools_dict.items()
|
||||
|
||||
@@ -22,7 +22,7 @@ class ServerMCPExecutor(ToolExecutor):
|
||||
await self.mcp_manager.initialize_servers()
|
||||
|
||||
async def execute(
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行服务端MCP工具"""
|
||||
if not self._initialized or not self.mcp_manager:
|
||||
|
||||
@@ -24,7 +24,7 @@ class ServerMCPManager:
|
||||
self.config_path = get_project_dir() + "data/.mcp_server_settings.json"
|
||||
if not os.path.exists(self.config_path):
|
||||
self.config_path = ""
|
||||
logger.bind(tag=TAG).debug(
|
||||
logger.bind(tag=TAG).warning(
|
||||
f"请检查mcp服务配置文件:data/.mcp_server_settings.json"
|
||||
)
|
||||
self.clients: Dict[str, ServerMCPClient] = {}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""服务端插件工具执行器"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Dict, Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -10,10 +8,6 @@ if TYPE_CHECKING:
|
||||
from ..base import ToolType, ToolDefinition, ToolExecutor
|
||||
from plugins_func.register import all_function_registry, module_func_map, Action, ActionResponse
|
||||
|
||||
# 插件专用线程池:不用 asyncio 默认池(DNS 解析、ASR、initialize_modules 等都在用),
|
||||
# 避免慢插件打满默认池后拖垮所有协程的 DNS 解析和连接建立。
|
||||
_PLUGIN_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="plugin")
|
||||
|
||||
|
||||
class ServerPluginExecutor(ToolExecutor):
|
||||
"""服务端插件工具执行器"""
|
||||
@@ -23,7 +17,7 @@ class ServerPluginExecutor(ToolExecutor):
|
||||
self.config = conn.config
|
||||
|
||||
async def execute(
|
||||
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行服务端插件工具"""
|
||||
func_item = all_function_registry.get(tool_name)
|
||||
@@ -34,21 +28,23 @@ class ServerPluginExecutor(ToolExecutor):
|
||||
|
||||
try:
|
||||
# 根据工具类型决定如何调用
|
||||
func_type = getattr(func_item, "type", None)
|
||||
if func_type is not None and func_type.code in [3, 4, 5]:
|
||||
# CHANGE_SYS_PROMPT, SYSTEM_CTL, IOT_CTL (需要conn参数)
|
||||
call = functools.partial(func_item.func, conn, **arguments)
|
||||
else: # WAIT 等不传conn(含 type 未指定的插件)
|
||||
call = functools.partial(func_item.func, **arguments)
|
||||
|
||||
if asyncio.iscoroutinefunction(func_item.func):
|
||||
result = await call()
|
||||
if hasattr(func_item, "type"):
|
||||
func_type = func_item.type
|
||||
if func_type.code in [4, 5]: # SYSTEM_CTL, IOT_CTL (需要conn参数)
|
||||
result = func_item.func(conn, **arguments)
|
||||
elif func_type.code == 2: # WAIT
|
||||
result = func_item.func(**arguments)
|
||||
elif func_type.code == 3: # CHANGE_SYS_PROMPT
|
||||
result = func_item.func(conn, **arguments)
|
||||
else:
|
||||
result = func_item.func(**arguments)
|
||||
else:
|
||||
# 同步函数在 loop 线程上直接执行会卡住整个事件循环,放专用线程池。
|
||||
result = await asyncio.get_running_loop().run_in_executor(_PLUGIN_POOL, call)
|
||||
# 兼容同步函数返回协程的写法
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
# 默认不传conn参数
|
||||
result = func_item.func(**arguments)
|
||||
|
||||
# 兼容 async def 工具函数
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -137,16 +137,16 @@ class UnifiedToolHandler:
|
||||
return self.tool_manager.has_tool(tool_name)
|
||||
|
||||
async def handle_llm_function_call(
|
||||
self, conn, function_call_data: Dict[str, Any]
|
||||
self, conn, function_call_data: Dict[str, Any], speaker=None
|
||||
) -> Optional[ActionResponse]:
|
||||
"""处理LLM函数调用"""
|
||||
"""处理LLM函数调用。speaker: 本轮说话人快照。"""
|
||||
try:
|
||||
# 处理多函数调用
|
||||
if "function_calls" in function_call_data:
|
||||
responses = []
|
||||
for call in function_call_data["function_calls"]:
|
||||
result = await self.tool_manager.execute_tool(
|
||||
call["name"], call.get("arguments", {})
|
||||
call["name"], call.get("arguments", {}), speaker=speaker
|
||||
)
|
||||
responses.append(result)
|
||||
return self._combine_responses(responses)
|
||||
@@ -175,7 +175,9 @@ class UnifiedToolHandler:
|
||||
self.logger.warning(f"发送工具调用显示消息失败: {e}")
|
||||
|
||||
# 执行工具调用
|
||||
result = await self.tool_manager.execute_tool(function_name, arguments)
|
||||
result = await self.tool_manager.execute_tool(
|
||||
function_name, arguments, speaker=speaker
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -47,12 +47,9 @@ class ToolManager:
|
||||
return all_tools
|
||||
|
||||
def get_function_descriptions(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有工具的函数描述(OpenAI格式)
|
||||
|
||||
返回缓存的浅拷贝,防止调用方就地修改污染缓存。
|
||||
"""
|
||||
"""获取所有工具的函数描述(OpenAI格式)"""
|
||||
if self._cached_function_descriptions is not None:
|
||||
return list(self._cached_function_descriptions)
|
||||
return self._cached_function_descriptions
|
||||
|
||||
descriptions = []
|
||||
tools = self.get_all_tools()
|
||||
@@ -60,7 +57,7 @@ class ToolManager:
|
||||
descriptions.append(tool_definition.description)
|
||||
|
||||
self._cached_function_descriptions = descriptions
|
||||
return list(descriptions)
|
||||
return descriptions
|
||||
|
||||
def has_tool(self, tool_name: str) -> bool:
|
||||
"""检查是否存在指定工具"""
|
||||
@@ -74,9 +71,9 @@ class ToolManager:
|
||||
return tool_def.tool_type if tool_def else None
|
||||
|
||||
async def execute_tool(
|
||||
self, tool_name: str, arguments: Dict[str, Any]
|
||||
self, tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行工具调用"""
|
||||
"""执行工具调用。speaker: 本轮说话人快照。"""
|
||||
try:
|
||||
# 查找工具类型
|
||||
tool_type = self.get_tool_type(tool_name)
|
||||
@@ -96,7 +93,7 @@ class ToolManager:
|
||||
|
||||
# 执行工具
|
||||
self.logger.info(f"执行工具: {tool_name},参数: {arguments}")
|
||||
result = await executor.execute(self.conn, tool_name, arguments)
|
||||
result = await executor.execute(self.conn, tool_name, arguments, speaker=speaker)
|
||||
self.logger.debug(f"工具执行结果: {result}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -23,11 +23,9 @@ class TTSProvider(TTSProviderBase):
|
||||
|
||||
def __init__(self, config, delete_audio_file):
|
||||
super().__init__(config, delete_audio_file)
|
||||
self.url = config.get("url", "ws://127.0.0.1:8092/paddlespeech/tts/streaming")
|
||||
self.url = config.get("url", "ws://192.168.1.10:8092/paddlespeech/tts/streaming")
|
||||
self.protocol = config.get("protocol", "websocket")
|
||||
if self.protocol != "websocket":
|
||||
raise ValueError("Unsupported protocol. Please use 'websocket'.")
|
||||
|
||||
|
||||
if config.get("private_voice"):
|
||||
self.spk_id = int(config.get("private_voice"))
|
||||
else:
|
||||
@@ -84,7 +82,7 @@ class TTSProvider(TTSProviderBase):
|
||||
if self.protocol == "websocket":
|
||||
return await self.text_streaming(text, output_file)
|
||||
else:
|
||||
raise ValueError("Unsupported protocol. Please use 'websocket'.")
|
||||
raise ValueError("Unsupported protocol. Please use 'websocket' or 'http'.")
|
||||
|
||||
async def text_streaming(self, text, output_file):
|
||||
try:
|
||||
|
||||
@@ -3,6 +3,8 @@ import re
|
||||
from typing import List, Dict
|
||||
from datetime import datetime
|
||||
|
||||
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME
|
||||
|
||||
|
||||
class Message:
|
||||
def __init__(
|
||||
@@ -125,7 +127,7 @@ class Dialogue:
|
||||
current_speaker_name = (current_speaker or "").strip()
|
||||
# 仅在本轮注入了有效身份时才输出 speakers_info,避免列表里的名字每轮
|
||||
# 重复出现诱导模型反复称呼;后续轮不再注入身份,靠对话历史首轮保留
|
||||
if current_speaker_name and current_speaker_name != "未知说话人":
|
||||
if current_speaker_name and current_speaker_name != UNKNOWN_SPEAKER_NAME:
|
||||
speakers = voiceprint_config.get("speakers", [])
|
||||
speakers_info = "\n<speakers_info>"
|
||||
speakers_info += f"\n当前说话人:{current_speaker_name}"
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import hashlib
|
||||
import threading
|
||||
from typing import Dict, Any, TYPE_CHECKING
|
||||
|
||||
@@ -121,9 +120,8 @@ class PromptManager:
|
||||
|
||||
# 使用传入的提示词并缓存(如果有设备ID)
|
||||
if device_id:
|
||||
self.cache_manager.set(
|
||||
self.CacheType.DEVICE_PROMPT, device_cache_key, user_prompt
|
||||
)
|
||||
device_cache_key = f"device_prompt:{device_id}"
|
||||
self.cache_manager.set(self.CacheType.DEVICE_PROMPT, device_cache_key, user_prompt)
|
||||
self.logger.bind(tag=TAG).debug(f"设备 {device_id} 的提示词已缓存")
|
||||
|
||||
self.logger.bind(tag=TAG).info(f"使用快速提示词: {user_prompt[:50]}...")
|
||||
@@ -255,18 +253,6 @@ class PromptManager:
|
||||
if not self.base_prompt_template:
|
||||
return user_prompt
|
||||
|
||||
# 缓存键须覆盖所有渲染输入:emoji 开关、设备 prompt 变更否则会命中过期提示词
|
||||
emoji_enabled = kwargs.get("emoji_enabled", True)
|
||||
prompt_hash = hashlib.md5(user_prompt.encode("utf-8")).hexdigest()[:8]
|
||||
enhanced_cache_key = (
|
||||
f"enhanced_prompt:{device_id}:{emoji_enabled}:{prompt_hash}"
|
||||
)
|
||||
cached_prompt = self.cache_manager.get(
|
||||
self.CacheType.DEVICE_PROMPT, enhanced_cache_key
|
||||
)
|
||||
if cached_prompt is not None:
|
||||
return cached_prompt
|
||||
|
||||
try:
|
||||
# 获取最新的时间信息(不缓存)
|
||||
today_date, today_weekday, lunar_date = self._get_current_time_info()
|
||||
@@ -315,13 +301,11 @@ class PromptManager:
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
device_cache_key = f"device_prompt:{device_id}"
|
||||
self.cache_manager.set(
|
||||
self.CacheType.DEVICE_PROMPT,
|
||||
enhanced_cache_key,
|
||||
enhanced_prompt,
|
||||
ttl=300,
|
||||
self.CacheType.DEVICE_PROMPT, device_cache_key, enhanced_prompt
|
||||
)
|
||||
self.logger.bind(tag=TAG).debug(
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"构建增强提示词成功,长度: {len(enhanced_prompt)}"
|
||||
)
|
||||
return enhanced_prompt
|
||||
|
||||
@@ -135,7 +135,7 @@ def get_ip_info(ip_addr, logger):
|
||||
if is_private_ip(ip_addr):
|
||||
ip_addr = ""
|
||||
url = f"https://whois.pconline.com.cn/ipJson.jsp?json=true&ip={ip_addr}"
|
||||
resp = requests.get(url, timeout=5).json()
|
||||
resp = requests.get(url).json()
|
||||
ip_info = {"city": resp.get("city")}
|
||||
|
||||
# 存入缓存
|
||||
@@ -310,12 +310,8 @@ async def audio_to_data(
|
||||
from core.utils.cache.manager import cache_manager
|
||||
from core.utils.cache.config import CacheType
|
||||
|
||||
# 缓存键含 mtime:文件被原地覆写时旧缓存自动失效(如唤醒词回复重新 TTS),避免播到过期音频
|
||||
try:
|
||||
mtime = os.path.getmtime(audio_file_path)
|
||||
cache_key = f"{audio_file_path}:{mtime}:{is_opus}"
|
||||
except OSError:
|
||||
cache_key = f"{audio_file_path}:{is_opus}"
|
||||
# 生成缓存键,包含文件路径和编码类型
|
||||
cache_key = f"{audio_file_path}:{is_opus}"
|
||||
|
||||
# 尝试从缓存获取结果
|
||||
if use_cache:
|
||||
|
||||
@@ -3,7 +3,7 @@ import time
|
||||
import aiohttp
|
||||
import requests
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional, Dict, NamedTuple
|
||||
from config.logger import setup_logging
|
||||
from core.utils.cache.manager import cache_manager
|
||||
from core.utils.cache.config import CacheType
|
||||
@@ -11,6 +11,30 @@ from core.utils.cache.config import CacheType
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
|
||||
# 未命中声纹时沿用的展示名。下游只把这个名字交给 LLM,不把它当成真实身份。
|
||||
UNKNOWN_SPEAKER_NAME = "未知说话人"
|
||||
|
||||
|
||||
class SpeakerIdentification(NamedTuple):
|
||||
"""一次声纹识别结果。speaker_id 仅在命中已配置说话人时有值。"""
|
||||
|
||||
speaker_id: Optional[str]
|
||||
name: str
|
||||
|
||||
|
||||
def resolve_speaker_identification(
|
||||
speaker_id: Optional[str],
|
||||
score: float,
|
||||
speaker_map: Dict[str, Dict[str, str]],
|
||||
similarity_threshold: float,
|
||||
) -> SpeakerIdentification:
|
||||
"""把声纹接口返回的 speaker_id / score 解析成展示名,并保留命中的 id。"""
|
||||
if score < similarity_threshold:
|
||||
return SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME)
|
||||
if speaker_id and speaker_id in speaker_map:
|
||||
return SpeakerIdentification(speaker_id, speaker_map[speaker_id]["name"])
|
||||
return SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME)
|
||||
|
||||
|
||||
class VoiceprintProvider:
|
||||
"""声纹识别服务提供者"""
|
||||
@@ -138,7 +162,16 @@ class VoiceprintProvider:
|
||||
return is_healthy
|
||||
|
||||
async def identify_speaker(self, audio_data: bytes, session_id: str) -> Optional[str]:
|
||||
"""识别说话人"""
|
||||
"""识别说话人,返回展示名。需要 speaker_id 时用 identify_speaker_detail。"""
|
||||
identified = await self.identify_speaker_detail(audio_data, session_id)
|
||||
if identified is None:
|
||||
return None
|
||||
return identified.name
|
||||
|
||||
async def identify_speaker_detail(
|
||||
self, audio_data: bytes, session_id: str
|
||||
) -> Optional[SpeakerIdentification]:
|
||||
"""识别说话人,同时返回声纹 id 和展示名。"""
|
||||
if not self.enabled or not self.api_url or not self.api_key:
|
||||
logger.bind(tag=TAG).debug("声纹识别功能已禁用或未配置,跳过识别")
|
||||
return None
|
||||
@@ -171,18 +204,19 @@ class VoiceprintProvider:
|
||||
|
||||
logger.bind(tag=TAG).info(f"声纹识别耗时: {total_elapsed_time:.3f}s")
|
||||
|
||||
# 相似度阈值检查
|
||||
if score < self.similarity_threshold:
|
||||
identified = resolve_speaker_identification(
|
||||
speaker_id, score, self.speaker_map, self.similarity_threshold
|
||||
)
|
||||
# 三种结果:命中 / 低于阈值 / id不在配置中
|
||||
if identified.speaker_id:
|
||||
logger.bind(tag=TAG).info(
|
||||
f"声纹识别成功: {identified.name} (id: {identified.speaker_id}, 相似度: {score:.3f})"
|
||||
)
|
||||
elif score < self.similarity_threshold:
|
||||
logger.bind(tag=TAG).warning(f"声纹识别相似度{score:.3f}低于阈值{self.similarity_threshold}")
|
||||
return "未知说话人"
|
||||
|
||||
if speaker_id and speaker_id in self.speaker_map:
|
||||
result_name = self.speaker_map[speaker_id]["name"]
|
||||
logger.bind(tag=TAG).info(f"声纹识别成功: {result_name} (相似度: {score:.3f})")
|
||||
return result_name
|
||||
else:
|
||||
logger.bind(tag=TAG).warning(f"未识别的说话人ID: {speaker_id}")
|
||||
return "未知说话人"
|
||||
return identified
|
||||
else:
|
||||
logger.bind(tag=TAG).error(f"声纹识别API错误: HTTP {response.status}")
|
||||
return None
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import importlib.util
|
||||
import asyncio
|
||||
|
||||
print("使用前请根据仓库根目录下 docs/performance_tester.md 的说明准备配置。")
|
||||
print("使用前请根据doc/performance_testerer.md的说明准备配置。")
|
||||
|
||||
|
||||
def list_performance_tester_modules():
|
||||
|
||||
@@ -647,9 +647,8 @@ class StreamTTSPerformanceTester:
|
||||
self.results.append(result)
|
||||
|
||||
# 测试Linkerai TTS
|
||||
if self.config.get("TTS", {}).get("LinkeraiTTS"):
|
||||
result = await self.test_linkerai_tts(test_text, test_count)
|
||||
self.results.append(result)
|
||||
result = await self.test_linkerai_tts(test_text, test_count)
|
||||
self.results.append(result)
|
||||
|
||||
# 测试IndexStreamTTS
|
||||
result = await self.test_indexstream_tts(test_text, test_count)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import random
|
||||
import httpx
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote, urlsplit, urlunsplit, parse_qsl
|
||||
from markitdown import MarkItDown, StreamInfo
|
||||
from config.logger import setup_logging
|
||||
from plugins_func.register import register_function, ToolType, ActionResponse, Action
|
||||
@@ -55,27 +54,6 @@ CHANNEL_MAP = {
|
||||
# 默认新闻来源字典,当配置中没有指定时使用
|
||||
DEFAULT_NEWS_SOURCES = "澎湃新闻;百度热搜;财联社"
|
||||
|
||||
DEFAULT_NEWS_API_URL = "https://newsnow.busiyi.world/api/s?id="
|
||||
|
||||
|
||||
def _build_news_api_url(source, configured_url=None):
|
||||
"""拼出新闻接口的请求地址和查询参数。
|
||||
|
||||
配置里带 {source} 占位符就替换占位符;否则把 id 设成新闻源 id,
|
||||
这样配置里已经写了 id 时也不会拼出脏串。
|
||||
"""
|
||||
api_url = str(configured_url).strip() if configured_url else DEFAULT_NEWS_API_URL
|
||||
if "{source}" in api_url:
|
||||
return api_url.replace("{source}", quote(source, safe="")), None
|
||||
parts = urlsplit(api_url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["id"] = source
|
||||
return (
|
||||
urlunsplit((parts.scheme, parts.netloc, parts.path, "", parts.fragment)),
|
||||
query,
|
||||
)
|
||||
|
||||
|
||||
def _get_newsnow_config(conn):
|
||||
# 从连接配置获取
|
||||
plugins = conn.config.get("plugins", {})
|
||||
@@ -83,8 +61,6 @@ def _get_newsnow_config(conn):
|
||||
sources = newsnow.get("news_sources", "")
|
||||
if isinstance(sources, str) and sources.strip():
|
||||
return sources
|
||||
if isinstance(sources, (list, tuple)):
|
||||
return ";".join(str(item).strip() for item in sources if str(item).strip())
|
||||
|
||||
return ""
|
||||
|
||||
@@ -137,26 +113,23 @@ GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = {
|
||||
async def fetch_news_from_api(conn: "ConnectionHandler", source="thepaper"):
|
||||
"""从API获取新闻列表"""
|
||||
try:
|
||||
api_url = f"https://newsnow.busiyi.world/api/s?id={source}"
|
||||
|
||||
news_config = conn.config.get("plugins", {}).get("get_news_from_newsnow", {})
|
||||
configured_url = news_config.get("url") if isinstance(news_config, dict) else None
|
||||
api_url, request_params = _build_news_api_url(source, configured_url)
|
||||
if news_config.get("url"):
|
||||
api_url = news_config["url"] + source
|
||||
|
||||
headers = {"User-Agent": "Mozilla/5.0"}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||
response = await client.get(api_url, params=request_params, headers=headers)
|
||||
response = await client.get(api_url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.bind(tag=TAG).error(
|
||||
f"获取新闻API失败:HTTP {response.status_code},source={source}"
|
||||
)
|
||||
return []
|
||||
data = response.json()
|
||||
|
||||
items = data.get("items") if isinstance(data, dict) else None
|
||||
if not isinstance(items, list):
|
||||
if "items" in data:
|
||||
return data["items"]
|
||||
else:
|
||||
logger.bind(tag=TAG).error(f"获取新闻API响应格式错误: {data}")
|
||||
return []
|
||||
return [item for item in items if isinstance(item, dict) and item.get("title")]
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"获取新闻API失败: {e}")
|
||||
@@ -266,24 +239,18 @@ async def get_news_from_newsnow(
|
||||
english_source_id = None
|
||||
|
||||
# 检查输入的中文名称是否在配置的新闻源中
|
||||
news_sources_list = [name.strip() for name in news_sources.split(";") if name.strip()]
|
||||
news_sources_list = [
|
||||
name.strip() for name in news_sources.split(";") if name.strip()
|
||||
]
|
||||
if source in news_sources_list:
|
||||
english_source_id = CHANNEL_MAP.get(source)
|
||||
elif not source and news_sources_list:
|
||||
source = news_sources_list[0]
|
||||
# 如果输入的中文名称在配置的新闻源中,在 CHANNEL_MAP 中查找对应的英文ID
|
||||
english_source_id = CHANNEL_MAP.get(source)
|
||||
|
||||
# 如果找不到对应的英文ID,使用默认源
|
||||
if not english_source_id:
|
||||
fallback_source = next(
|
||||
(name for name in news_sources_list if CHANNEL_MAP.get(name)),
|
||||
"澎湃新闻",
|
||||
)
|
||||
logger.bind(tag=TAG).warning(
|
||||
f"无效的新闻源: {source},使用配置中的默认源{fallback_source}"
|
||||
)
|
||||
english_source_id = CHANNEL_MAP[fallback_source]
|
||||
source = fallback_source
|
||||
logger.bind(tag=TAG).warning(f"无效的新闻源: {source},使用默认源澎湃新闻")
|
||||
english_source_id = "thepaper"
|
||||
source = "澎湃新闻"
|
||||
|
||||
logger.bind(tag=TAG).info(f"获取新闻: 新闻源={source}({english_source_id})")
|
||||
|
||||
@@ -299,21 +266,20 @@ async def get_news_from_newsnow(
|
||||
|
||||
# 随机选择一条新闻
|
||||
selected_news = random.choice(news_items)
|
||||
title = selected_news.get("title", "未知标题")
|
||||
|
||||
# 保存当前新闻链接到连接对象,以便后续查询详情
|
||||
if not hasattr(conn, "last_newsnow_link"):
|
||||
conn.last_newsnow_link = {}
|
||||
conn.last_newsnow_link = {
|
||||
"url": selected_news.get("url", "#"),
|
||||
"title": title,
|
||||
"title": selected_news.get("title", "未知标题"),
|
||||
"source_id": english_source_id,
|
||||
}
|
||||
|
||||
# 构建新闻报告
|
||||
news_report = (
|
||||
f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n"
|
||||
f"新闻标题: {title}\n"
|
||||
f"新闻标题: {selected_news['title']}\n"
|
||||
# f"新闻来源: {source}\n"
|
||||
f"(请以自然、流畅的方式向用户播报这条新闻标题,"
|
||||
f"提示用户可以要求获取详细内容,此时会获取新闻的详细内容。)"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from config.logger import setup_logging
|
||||
@@ -113,106 +112,20 @@ WEATHER_CODE_MAP = {
|
||||
|
||||
|
||||
async def fetch_city_info(location, api_key, api_host):
|
||||
url = f"https://{api_host}/geo/v2/city/lookup"
|
||||
url = f"https://{api_host}/geo/v2/city/lookup?key={api_key}&location={location}&lang=zh"
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
params={
|
||||
"key": api_key,
|
||||
"location": location,
|
||||
"lang": "zh",
|
||||
"number": 1,
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
response = await client.get(url, headers=HEADERS)
|
||||
data = response.json()
|
||||
if data.get("error") is not None:
|
||||
logger.bind(tag=TAG).error(
|
||||
f"获取天气城市失败:HTTP {response.status_code} 返回了非 JSON 内容"
|
||||
)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
logger.bind(tag=TAG).error("获取天气城市失败:响应不是 JSON 对象")
|
||||
return None
|
||||
if response.status_code != 200 or data.get("code") != "200":
|
||||
logger.bind(tag=TAG).error(
|
||||
f"获取天气城市失败:HTTP {response.status_code},代码 {data.get('code')}"
|
||||
f"获取天气失败,原因:{data.get('error', {}).get('detail')}"
|
||||
)
|
||||
return None
|
||||
return data.get("location", [])[0] if data.get("location") else None
|
||||
|
||||
|
||||
async def fetch_weather_data(city_info, api_key, api_host):
|
||||
location_id = city_info.get("id")
|
||||
if not location_id:
|
||||
return None
|
||||
|
||||
base_url = f"https://{api_host}/v7/weather"
|
||||
params = {"key": api_key, "location": location_id, "lang": "zh"}
|
||||
|
||||
async def request(client, endpoint):
|
||||
response = await client.get(f"{base_url}/{endpoint}", params=params, headers=HEADERS)
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
if response.status_code != 200 or data.get("code") != "200":
|
||||
logger.bind(tag=TAG).error(
|
||||
f"获取天气数据失败:接口={endpoint} HTTP {response.status_code},代码 {data.get('code')}"
|
||||
)
|
||||
return None
|
||||
return data
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||
now, daily = await asyncio.gather(
|
||||
request(client, "now"), request(client, "7d")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).warning(f"获取天气 JSON 接口失败,将尝试网页接口: {e}")
|
||||
return None
|
||||
if now is None or daily is None:
|
||||
return None
|
||||
return now, daily
|
||||
|
||||
|
||||
def build_weather_report(city_info, now_data, daily_data):
|
||||
city_name = city_info.get("name") or city_info.get("adm2") or "未知位置"
|
||||
now = now_data.get("now", {})
|
||||
weather_report = f"您查询的位置是:{city_name}\n\n"
|
||||
weather_report += f"当前天气: {now.get('text', '未知')}\n"
|
||||
|
||||
current_basic = {
|
||||
"温度": f"{now['temp']}℃" if now.get("temp") is not None else None,
|
||||
"体感温度": f"{now['feelsLike']}℃" if now.get("feelsLike") is not None else None,
|
||||
"湿度": f"{now['humidity']}%" if now.get("humidity") is not None else None,
|
||||
"风向": now.get("windDir"),
|
||||
"风力": now.get("windScale"),
|
||||
"能见度": f"{now['vis']}公里" if now.get("vis") is not None else None,
|
||||
"气压": f"{now['pressure']}百帕" if now.get("pressure") is not None else None,
|
||||
"降水量": f"{now['precip']}毫米" if now.get("precip") is not None else None,
|
||||
}
|
||||
current_basic = {key: value for key, value in current_basic.items() if value}
|
||||
if current_basic:
|
||||
weather_report += "详细参数:\n"
|
||||
weather_report += "".join(f" · {key}: {value}\n" for key, value in current_basic.items())
|
||||
|
||||
weather_report += "\n未来7天预报:\n"
|
||||
for item in daily_data.get("daily", [])[:7]:
|
||||
date = item.get("fxDate", "未知日期")
|
||||
weather = item.get("textDay") or WEATHER_CODE_MAP.get(item.get("iconDay"), "未知")
|
||||
high = item.get("tempMax", "未知")
|
||||
low = item.get("tempMin", "未知")
|
||||
weather_report += f"{date}: {weather},气温 {low}~{high}℃\n"
|
||||
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
|
||||
return weather_report
|
||||
|
||||
|
||||
async def fetch_weather_page(url):
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(10.0, connect=3.0), follow_redirects=True
|
||||
) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||
response = await client.get(url, headers=HEADERS)
|
||||
return BeautifulSoup(response.text, "html.parser") if response.status_code == 200 else None
|
||||
|
||||
@@ -268,8 +181,7 @@ async def get_weather(conn: "ConnectionHandler", location: str = None, lang: str
|
||||
location = cached_ip_info.get("city")
|
||||
else:
|
||||
# 缓存未命中,调用API获取
|
||||
# get_ip_info 是同步 requests 调用,放线程池,避免卡住主事件循环
|
||||
ip_info = await asyncio.to_thread(get_ip_info, client_ip, logger)
|
||||
ip_info = get_ip_info(client_ip, logger)
|
||||
if ip_info:
|
||||
cache_manager.set(CacheType.IP_INFO, client_ip, ip_info)
|
||||
location = ip_info.get("city")
|
||||
@@ -289,31 +201,29 @@ async def get_weather(conn: "ConnectionHandler", location: str = None, lang: str
|
||||
city_info = await fetch_city_info(location, api_key, api_host)
|
||||
if not city_info:
|
||||
return ActionResponse(
|
||||
Action.REQLLM,
|
||||
f"暂时查不到 {location} 的天气(城市未找到或天气服务不可用),请稍后再试",
|
||||
None,
|
||||
Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None
|
||||
)
|
||||
weather_data = await fetch_weather_data(city_info, api_key, api_host)
|
||||
if weather_data:
|
||||
weather_report = build_weather_report(city_info, *weather_data)
|
||||
else:
|
||||
fx_link = city_info.get("fxLink")
|
||||
soup = await fetch_weather_page(fx_link) if fx_link else None
|
||||
if not soup:
|
||||
return ActionResponse(Action.REQLLM, None, "请求天气数据失败")
|
||||
city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup)
|
||||
weather_report = f"您查询的位置是:{city_name}\n\n当前天气: {current_abstract}\n"
|
||||
if current_basic:
|
||||
weather_report += "详细参数:\n"
|
||||
weather_report += "".join(
|
||||
f" · {key}: {value}\n" for key, value in current_basic.items() if value != "0"
|
||||
)
|
||||
weather_report += "\n未来7天预报:\n"
|
||||
weather_report += "".join(
|
||||
f"{date}: {weather},气温 {low}~{high}\n"
|
||||
for date, weather, high, low in temps_list
|
||||
)
|
||||
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
|
||||
soup = await fetch_weather_page(city_info["fxLink"])
|
||||
if not soup:
|
||||
return ActionResponse(Action.REQLLM, None, "请求失败")
|
||||
city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup)
|
||||
|
||||
weather_report = f"您查询的位置是:{city_name}\n\n当前天气: {current_abstract}\n"
|
||||
|
||||
# 添加有效的当前天气参数
|
||||
if current_basic:
|
||||
weather_report += "详细参数:\n"
|
||||
for key, value in current_basic.items():
|
||||
if value != "0": # 过滤无效值
|
||||
weather_report += f" · {key}: {value}\n"
|
||||
|
||||
# 添加7天预报
|
||||
weather_report += "\n未来7天预报:\n"
|
||||
for date, weather, high, low in temps_list:
|
||||
weather_report += f"{date}: {weather},气温 {low}~{high}\n"
|
||||
|
||||
# 提示语
|
||||
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
|
||||
|
||||
# 缓存完整的天气报告
|
||||
cache_manager.set(CacheType.WEATHER, weather_cache_key, weather_report)
|
||||
|
||||
@@ -121,10 +121,13 @@ async def handle_hass_get_state(conn: "ConnectionHandler", entity_id):
|
||||
+ str(response.json()["attributes"]["media_title"])
|
||||
+ " "
|
||||
)
|
||||
volume_level = response.json()["attributes"].get("volume_level")
|
||||
if isinstance(volume_level, (int, float)):
|
||||
# volume_level 量程 0~1,换算成百分比(同 volume_set 的 1~100)
|
||||
responsetext = responsetext + "音量是:" + f"{round(volume_level * 100)}%" + " "
|
||||
if "volume_level" in response.json()["attributes"]:
|
||||
responsetext = (
|
||||
responsetext
|
||||
+ "音量是:"
|
||||
+ str(response.json()["attributes"]["volume_level"])
|
||||
+ " "
|
||||
)
|
||||
if "color_temp_kelvin" in response.json()["attributes"]:
|
||||
responsetext = (
|
||||
responsetext
|
||||
@@ -139,11 +142,13 @@ async def handle_hass_get_state(conn: "ConnectionHandler", entity_id):
|
||||
+ str(response.json()["attributes"]["rgb_color"])
|
||||
+ " "
|
||||
)
|
||||
brightness = response.json()["attributes"].get("brightness")
|
||||
if isinstance(brightness, (int, float)):
|
||||
# brightness 量程 0~255,换算成百分比(同 brightness_pct 的 1~100),开灯最低报 1%
|
||||
percent = max(1, round(brightness * 100 / 255)) if brightness > 0 else 0
|
||||
responsetext = responsetext + "亮度是:" + f"{percent}%" + " "
|
||||
if "brightness" in response.json()["attributes"]:
|
||||
responsetext = (
|
||||
responsetext
|
||||
+ "亮度是:"
|
||||
+ str(response.json()["attributes"]["brightness"])
|
||||
+ " "
|
||||
)
|
||||
logger.bind(tag=TAG).info(f"查询返回内容: {responsetext}")
|
||||
return responsetext
|
||||
else:
|
||||
|
||||
@@ -149,54 +149,6 @@ async def _search_serply(api_key: str, query: str, max_results: int) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _search_outcrawl(api_key: str, query: str, max_results: int) -> str:
|
||||
"""调用Outcrawl搜索API"""
|
||||
url = "https://api.outcrawl.ai/v1/search"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "xiaozhi-esp32-server",
|
||||
}
|
||||
# Outcrawl单次最多返回20条
|
||||
payload = {"query": query, "limit": max(1, min(max_results, 20))}
|
||||
logger.bind(tag=TAG).debug(f"Outcrawl搜索请求 | URL: {url} | payload: {payload}")
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0, connect=3.0)) as client:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
data = response.json()
|
||||
logger.bind(tag=TAG).debug(f"Outcrawl搜索响应 | status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
# 错误信封为 {"code": "...", "message": "...", "details": {}},
|
||||
# code 是官方承诺的封闭枚举
|
||||
code = data.get("code", "") if isinstance(data, dict) else ""
|
||||
detail = data.get("message", "") if isinstance(data, dict) else ""
|
||||
logger.bind(tag=TAG).error(
|
||||
f"Outcrawl搜索失败 | status: {response.status_code} | code: {code} | detail: {detail}"
|
||||
)
|
||||
if code == "quota_exceeded":
|
||||
# 该code覆盖两种情况:每分钟请求数限流,或月度credits用尽
|
||||
return "联网搜索被限流或额度已用完,请稍后重试或检查Outcrawl账户余额。"
|
||||
if code == "unauthorized":
|
||||
return "联网搜索请求失败,请检查API Key是否正确。"
|
||||
# 其余code(bad_request、no_capacity、search_refused、internal等)
|
||||
# 多为输入问题或服务端暂时性故障,统一引导重试
|
||||
return "联网搜索请求失败,请稍后重试。"
|
||||
|
||||
hits = data.get("hits", [])
|
||||
if not hits:
|
||||
return "未找到相关搜索结果。"
|
||||
|
||||
lines = ["【联网搜索结果】"]
|
||||
for i, item in enumerate(hits[:max_results], 1):
|
||||
title = item.get("title") or "无标题"
|
||||
snippet = item.get("snippet", "")
|
||||
lines.append(f"{i}. 标题:{title}")
|
||||
if snippet:
|
||||
lines.append(f" 摘要:{snippet}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@register_function("web_search", WEB_SEARCH_FUNCTION_DESC, ToolType.SYSTEM_CTL)
|
||||
async def web_search(conn: "ConnectionHandler", query: str = None):
|
||||
logger.bind(tag=TAG).info(f"web_search 被调用 | query={query}")
|
||||
@@ -223,8 +175,6 @@ async def web_search(conn: "ConnectionHandler", query: str = None):
|
||||
result_text = await _search_tavily(api_key, query, max_results)
|
||||
elif provider == "serply":
|
||||
result_text = await _search_serply(api_key, query, max_results)
|
||||
elif provider == "outcrawl":
|
||||
result_text = await _search_outcrawl(api_key, query, max_results)
|
||||
else:
|
||||
return ActionResponse(
|
||||
Action.REQLLM,
|
||||
|
||||
@@ -83,11 +83,7 @@ module_func_map = {}
|
||||
|
||||
|
||||
def register_function(name, desc, type=None):
|
||||
"""注册函数到函数注册字典的装饰器
|
||||
|
||||
同步函数在工作线程中执行,函数体内不可调用 asyncio 事件循环 API;
|
||||
需要异步(httpx、await)请定义为 async def。
|
||||
"""
|
||||
"""注册函数到函数注册字典的装饰器"""
|
||||
|
||||
def decorator(func):
|
||||
all_function_registry[name] = FunctionItem(name, desc, func, type)
|
||||
|
||||
@@ -47,29 +47,10 @@ if str(_PROJECT_ROOT) not in sys.path:
|
||||
# ---------------------------------------------------------------------------
|
||||
try:
|
||||
import opuslib_next # noqa: F401
|
||||
except Exception as _opus_import_error:
|
||||
# opuslib_next 是 ctypes 绑定,pip 装上的是纯 Python 包,真正的编解码靠系统里的
|
||||
# libopus.so。runner 上没装 libopus 时 import 会失败,这里换桩顶上。
|
||||
# 桩只能撑住 import 链路,真要编解码得先把 libopus 装上
|
||||
# (apt-get install libopus0,正式镜像 Dockerfile-server-base 里也是这么装的)。
|
||||
print(
|
||||
f"[conftest] opuslib_next 不可用,改用桩:{_opus_import_error}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def _opus_unavailable(_self, *_args, **_kwargs):
|
||||
raise RuntimeError(
|
||||
"libopus 没装,Opus 编解码跑不了。"
|
||||
"装法:apt-get install libopus0(正式镜像 Dockerfile-server-base 里也是这么装的)"
|
||||
)
|
||||
|
||||
except Exception:
|
||||
_stub = types.ModuleType("opuslib_next")
|
||||
_stub.Encoder = type(
|
||||
"Encoder", (), {"__init__": lambda *a, **kw: None, "encode": _opus_unavailable}
|
||||
)
|
||||
_stub.Decoder = type(
|
||||
"Decoder", (), {"__init__": lambda *a, **kw: None, "decode": _opus_unavailable}
|
||||
)
|
||||
_stub.Encoder = type("Encoder", (), {"__init__": lambda *a, **kw: None})
|
||||
_stub.Decoder = type("Decoder", (), {"__init__": lambda *a, **kw: None})
|
||||
_stub.APPLICATION_AUDIO = 0
|
||||
_stub.OpusError = type("OpusError", (Exception,), {})
|
||||
_stub.constants = types.SimpleNamespace()
|
||||
@@ -342,25 +323,35 @@ def _install_mock_layer() -> None:
|
||||
|
||||
asyncio.sleep = _fast_sleep # type: ignore[assignment]
|
||||
|
||||
# 网络层 mock:如果真实 httpx 已加载,把它的 send 替换成
|
||||
# 网络层 mock:如果真实 httpx 已加载,把它的 AsyncClient.send 替换成
|
||||
# 立即抛 ConnectError 的桩,触发 ``_should_retry`` 走到 ``raise``,
|
||||
# 但避免真发请求。Live 模式完全不安装这层。
|
||||
if not LIVE_API_TESTS:
|
||||
try:
|
||||
import httpx as _httpx
|
||||
import httpx as _httpx # noqa: F401
|
||||
|
||||
async def _fake_async_send(self, request, **kwargs):
|
||||
raise _httpx.ConnectError("MOCK: connect refused")
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def _fake_send(self, request, **kwargs):
|
||||
raise _httpx.ConnectError("MOCK: connect refused")
|
||||
async def send(self, *args, **kwargs):
|
||||
raise _httpx.ConnectError("MOCK: connect refused")
|
||||
|
||||
# 只换 send 这一个方法,不换整个类。有些库(openai 2.x 的
|
||||
# SyncHttpxClientWrapper)继承 httpx.Client 再自己拼请求,
|
||||
# 整个类被换掉的话它们继承到的是假类,拼请求时直接
|
||||
# AttributeError,报错还完全看不出是 mock 层的问题
|
||||
_httpx.AsyncClient.send = _fake_async_send # type: ignore[assignment]
|
||||
_httpx.Client.send = _fake_send # type: ignore[assignment]
|
||||
async def aclose(self):
|
||||
return None
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def send(self, *args, **kwargs):
|
||||
raise _httpx.ConnectError("MOCK: connect refused")
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
_httpx.AsyncClient = _FakeAsyncClient # type: ignore[misc]
|
||||
_httpx.Client = _FakeClient # type: ignore[misc]
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""MCP 接入点 tools/call 通过 _meta 携带说话人,不改工具参数。"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from core.providers.tools.mcp_endpoint.mcp_speaker import (
|
||||
build_mcp_endpoint_tool_payload,
|
||||
should_forward_speaker,
|
||||
speaker_call_meta,
|
||||
)
|
||||
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME, SpeakerIdentification
|
||||
|
||||
|
||||
def test_meta_includes_id_and_name():
|
||||
assert speaker_call_meta(" test1 ", " 张三 ") == {
|
||||
"speakerId": "test1",
|
||||
"speakerName": "张三",
|
||||
}
|
||||
|
||||
|
||||
def test_meta_omits_unknown_speaker_and_blank_id():
|
||||
assert speaker_call_meta(None, UNKNOWN_SPEAKER_NAME) is None
|
||||
assert speaker_call_meta(" ", "张三") == {"speakerName": "张三"}
|
||||
assert speaker_call_meta("test1", UNKNOWN_SPEAKER_NAME) == {"speakerId": "test1"}
|
||||
|
||||
|
||||
def test_forwarding_defaults_on_and_can_be_disabled():
|
||||
assert should_forward_speaker(None) is True
|
||||
assert should_forward_speaker({}) is True
|
||||
assert should_forward_speaker({"mcp_forward_speaker": True}) is True
|
||||
assert should_forward_speaker({"mcp_forward_speaker": False}) is False
|
||||
# 字符串 false 可关闭;空值视为缺省开启
|
||||
assert should_forward_speaker({"mcp_forward_speaker": "false"}) is False
|
||||
assert should_forward_speaker({"mcp_forward_speaker": "False"}) is False
|
||||
assert should_forward_speaker({"mcp_forward_speaker": "0"}) is False
|
||||
assert should_forward_speaker({"mcp_forward_speaker": "true"}) is True
|
||||
assert should_forward_speaker({"mcp_forward_speaker": None}) is True
|
||||
# 空串按关闭处理,与 manager-api 的 Boolean.parseBoolean("") 一致
|
||||
assert should_forward_speaker({"mcp_forward_speaker": ""}) is False
|
||||
|
||||
|
||||
def test_payload_adds_meta_without_touching_arguments():
|
||||
arguments = {"name": "灯"}
|
||||
payload = build_mcp_endpoint_tool_payload(
|
||||
7,
|
||||
"HassTurnOn",
|
||||
arguments,
|
||||
{"speakerId": "test1", "speakerName": "张三"},
|
||||
)
|
||||
assert payload["method"] == "tools/call"
|
||||
assert payload["params"]["arguments"] is arguments
|
||||
assert "speakerId" not in arguments
|
||||
assert payload["params"]["_meta"]["speakerId"] == "test1"
|
||||
|
||||
|
||||
def test_payload_without_meta_matches_previous_shape():
|
||||
payload = build_mcp_endpoint_tool_payload(7, "calculator", {"a": 1})
|
||||
assert set(payload["params"]) == {"name", "arguments"}
|
||||
assert "_meta" not in payload["params"]
|
||||
|
||||
|
||||
class _Client:
|
||||
async def is_ready(self):
|
||||
return True
|
||||
|
||||
|
||||
class _Conn:
|
||||
def __init__(self, config=None):
|
||||
self.mcp_endpoint_client = _Client()
|
||||
self.config = {} if config is None else config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_forwards_speaker_in_meta_only(monkeypatch):
|
||||
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
|
||||
captured["tool_name"] = tool_name
|
||||
captured["args"] = args_str
|
||||
captured["meta"] = meta
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
|
||||
arguments = {"area": "客厅"}
|
||||
conn = _Conn()
|
||||
result = await executor_mod.MCPEndpointExecutor(conn).execute(
|
||||
conn,
|
||||
"HassTurnOn",
|
||||
arguments,
|
||||
speaker=SpeakerIdentification("test1", "张三"),
|
||||
)
|
||||
|
||||
assert json.loads(captured["args"]) == {"area": "客厅"}
|
||||
assert captured["meta"] == {"speakerId": "test1", "speakerName": "张三"}
|
||||
assert arguments == {"area": "客厅"}
|
||||
assert result.result == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_skips_meta_when_disabled(monkeypatch):
|
||||
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
|
||||
captured["meta"] = meta
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
|
||||
conn = _Conn(config={"mcp_forward_speaker": False})
|
||||
await executor_mod.MCPEndpointExecutor(conn).execute(
|
||||
conn,
|
||||
"HassTurnOn",
|
||||
{"area": "客厅"},
|
||||
speaker=SpeakerIdentification("test1", "张三"),
|
||||
)
|
||||
assert captured["meta"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_omits_meta_for_unknown_speaker(monkeypatch):
|
||||
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
|
||||
captured["meta"] = meta
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
|
||||
conn = _Conn()
|
||||
await executor_mod.MCPEndpointExecutor(conn).execute(
|
||||
conn,
|
||||
"HassTurnOn",
|
||||
{},
|
||||
speaker=SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME),
|
||||
)
|
||||
assert captured["meta"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_reads_snapshot_not_conn_state(monkeypatch):
|
||||
"""meta 取自快照,不受连接态影响。"""
|
||||
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
|
||||
captured["meta"] = meta
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
|
||||
conn = _Conn()
|
||||
await executor_mod.MCPEndpointExecutor(conn).execute(
|
||||
conn,
|
||||
"HassTurnOn",
|
||||
{},
|
||||
speaker=SpeakerIdentification("test1", "张三"),
|
||||
)
|
||||
assert captured["meta"] == {"speakerId": "test1", "speakerName": "张三"}
|
||||
@@ -1,134 +0,0 @@
|
||||
"""服务端插件执行器:同步插件函数在线程池里跑,不卡事件循环;async 插件照常 await;异常变成 ERROR 响应。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.providers.tools.server_plugins.plugin_executor import ServerPluginExecutor
|
||||
from plugins_func.register import Action, ActionResponse, ToolType, all_function_registry
|
||||
|
||||
|
||||
def _register(name, func, tool_type):
|
||||
item = SimpleNamespace(name=name, func=func, type=tool_type, description={})
|
||||
all_function_registry[name] = item
|
||||
return item
|
||||
|
||||
|
||||
def test_sync_plugin_runs_off_loop_and_async_plugin_is_awaited():
|
||||
conn = SimpleNamespace(config={})
|
||||
|
||||
async def paced_sleep(delay):
|
||||
# 不能用 asyncio.sleep 计时:conftest 在 mock 模式会把它换成不让出循环的桩,
|
||||
# 这里放到线程池里睡,保证 ticker 每拍都真正让出事件循环。
|
||||
await asyncio.get_running_loop().run_in_executor(None, time.sleep, delay)
|
||||
|
||||
def slow_sync(conn_arg, entity_id):
|
||||
time.sleep(0.3) # 模拟阻塞 IO
|
||||
return ActionResponse(Action.RESPONSE, None, f"{entity_id} 已打开")
|
||||
|
||||
async def fast_async(question):
|
||||
await asyncio.sleep(0)
|
||||
return ActionResponse(Action.REQLLM, f"答:{question}", None)
|
||||
|
||||
def boom(conn_arg):
|
||||
raise RuntimeError("连不上")
|
||||
|
||||
_register("t_slow_sync", slow_sync, ToolType.SYSTEM_CTL)
|
||||
_register("t_fast_async", fast_async, ToolType.WAIT)
|
||||
_register("t_boom", boom, ToolType.SYSTEM_CTL)
|
||||
try:
|
||||
async def go():
|
||||
ex = ServerPluginExecutor(conn)
|
||||
ticks = []
|
||||
|
||||
async def ticker():
|
||||
while True:
|
||||
ticks.append(time.monotonic())
|
||||
await paced_sleep(0.02)
|
||||
|
||||
t = asyncio.create_task(ticker())
|
||||
r = await ex.execute(conn, "t_slow_sync", {"entity_id": "light.living"})
|
||||
t.cancel()
|
||||
assert r.action == Action.RESPONSE and r.response == "light.living 已打开"
|
||||
assert len(ticks) >= 5 # 300 ms 里事件循环一直在转(没被 time.sleep 卡住)
|
||||
r2 = await ex.execute(conn, "t_fast_async", {"question": "几点"})
|
||||
assert r2.action == Action.REQLLM and r2.result == "答:几点"
|
||||
r3 = await ex.execute(conn, "t_boom", {})
|
||||
assert r3.action == Action.ERROR and "连不上" in r3.response
|
||||
r4 = await ex.execute(conn, "t_missing", {})
|
||||
assert r4.action == Action.NOTFOUND
|
||||
asyncio.run(go())
|
||||
finally:
|
||||
for n in ("t_slow_sync", "t_fast_async", "t_boom"):
|
||||
all_function_registry.pop(n, None)
|
||||
|
||||
|
||||
def test_conn_passing_rules_and_compat_branches():
|
||||
"""code 3/4/5 传 conn;WAIT/type=None 不传;同步函数返回协程会被兜底 await。"""
|
||||
conn = SimpleNamespace(config={})
|
||||
seen = {}
|
||||
|
||||
def prompt_fn(conn_arg):
|
||||
seen["prompt"] = conn_arg
|
||||
return ActionResponse(Action.RESPONSE, None, "切角色")
|
||||
|
||||
def wait_fn():
|
||||
seen["wait"] = True
|
||||
return ActionResponse(Action.RESPONSE, None, "等")
|
||||
|
||||
def no_type_fn():
|
||||
seen["no_type"] = True
|
||||
return ActionResponse(Action.RESPONSE, None, "无类型")
|
||||
|
||||
def sync_returns_coro(conn_arg):
|
||||
seen["coro"] = conn_arg
|
||||
|
||||
async def inner():
|
||||
return ActionResponse(Action.RESPONSE, None, "协程兜底")
|
||||
|
||||
return inner()
|
||||
|
||||
_register("t_prompt", prompt_fn, ToolType.CHANGE_SYS_PROMPT)
|
||||
_register("t_wait", wait_fn, ToolType.WAIT)
|
||||
_register("t_no_type", no_type_fn, None)
|
||||
_register("t_sync_coro", sync_returns_coro, ToolType.SYSTEM_CTL)
|
||||
try:
|
||||
async def go():
|
||||
ex = ServerPluginExecutor(conn)
|
||||
r1 = await ex.execute(conn, "t_prompt", {})
|
||||
assert seen["prompt"] is conn and r1.response == "切角色"
|
||||
r2 = await ex.execute(conn, "t_wait", {})
|
||||
assert "wait" in seen and r2.response == "等"
|
||||
r3 = await ex.execute(conn, "t_no_type", {})
|
||||
assert "no_type" in seen and r3.response == "无类型"
|
||||
r4 = await ex.execute(conn, "t_sync_coro", {})
|
||||
assert seen["coro"] is conn and r4.response == "协程兜底"
|
||||
asyncio.run(go())
|
||||
finally:
|
||||
for n in ("t_prompt", "t_wait", "t_no_type", "t_sync_coro"):
|
||||
all_function_registry.pop(n, None)
|
||||
|
||||
|
||||
def test_sync_plugin_uses_dedicated_pool():
|
||||
"""同步插件必须跑在专用池上,不能用 asyncio 默认池(DNS 解析等共用默认池)。"""
|
||||
import threading
|
||||
|
||||
conn = SimpleNamespace(config={})
|
||||
seen = {}
|
||||
|
||||
def sync_fn():
|
||||
seen["thread"] = threading.current_thread().name
|
||||
return ActionResponse(Action.RESPONSE, None, "ok")
|
||||
|
||||
_register("t_pool", sync_fn, ToolType.WAIT)
|
||||
try:
|
||||
async def go():
|
||||
ex = ServerPluginExecutor(conn)
|
||||
r = await ex.execute(conn, "t_pool", {})
|
||||
assert r.response == "ok"
|
||||
|
||||
asyncio.run(go())
|
||||
assert seen["thread"].startswith("plugin"), seen["thread"] # 专用池线程名前缀
|
||||
finally:
|
||||
all_function_registry.pop("t_pool", None)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""声纹识别结果要同时保留 speaker_id 和展示名。"""
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from core.utils.voiceprint_provider import (
|
||||
UNKNOWN_SPEAKER_NAME,
|
||||
VoiceprintProvider,
|
||||
resolve_speaker_identification,
|
||||
)
|
||||
|
||||
SPEAKERS = {"test1": {"name": "张三", "description": "程序员"}}
|
||||
|
||||
|
||||
def test_matched_speaker_keeps_id_and_name():
|
||||
identified = resolve_speaker_identification("test1", 0.91, SPEAKERS, 0.4)
|
||||
assert identified.speaker_id == "test1"
|
||||
assert identified.name == "张三"
|
||||
|
||||
|
||||
def test_score_equal_to_threshold_still_matches():
|
||||
identified = resolve_speaker_identification("test1", 0.4, SPEAKERS, 0.4)
|
||||
assert identified.speaker_id == "test1"
|
||||
|
||||
|
||||
def test_low_score_hides_id_and_uses_unknown_name():
|
||||
identified = resolve_speaker_identification("test1", 0.39, SPEAKERS, 0.4)
|
||||
assert identified.speaker_id is None
|
||||
assert identified.name == UNKNOWN_SPEAKER_NAME
|
||||
|
||||
|
||||
def test_unconfigured_speaker_id_is_unknown():
|
||||
identified = resolve_speaker_identification("other", 0.99, SPEAKERS, 0.4)
|
||||
assert identified.speaker_id is None
|
||||
assert identified.name == UNKNOWN_SPEAKER_NAME
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, status, payload):
|
||||
self.status = status
|
||||
self._payload = payload
|
||||
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
return self._response
|
||||
|
||||
|
||||
def _enabled_provider():
|
||||
provider = VoiceprintProvider({})
|
||||
provider.enabled = True
|
||||
provider.api_url = "http://voiceprint.local/voiceprint/identify"
|
||||
provider.api_key = "k"
|
||||
provider.speaker_ids = ["test1"]
|
||||
provider.speaker_map = SPEAKERS
|
||||
provider.similarity_threshold = 0.4
|
||||
return provider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identify_speaker_still_returns_display_name(monkeypatch):
|
||||
provider = _enabled_provider()
|
||||
monkeypatch.setattr(
|
||||
aiohttp,
|
||||
"ClientSession",
|
||||
lambda *args, **kwargs: _Session(
|
||||
_Response(200, {"speaker_id": "test1", "score": 0.88})
|
||||
),
|
||||
)
|
||||
|
||||
assert await provider.identify_speaker(b"wav", "session") == "张三"
|
||||
detail = await provider.identify_speaker_detail(b"wav", "session")
|
||||
assert detail.speaker_id == "test1"
|
||||
assert detail.name == "张三"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identify_speaker_disabled_returns_none():
|
||||
provider = VoiceprintProvider({})
|
||||
assert await provider.identify_speaker(b"wav", "session") is None
|
||||
assert await provider.identify_speaker_detail(b"wav", "session") is None
|
||||
@@ -1,336 +0,0 @@
|
||||
"""Tests for the weather and newsnow plugin helpers.
|
||||
|
||||
The two plugins talk to external APIs, so the HTTP layer is replaced with a
|
||||
fake client and every request is recorded. Nothing here touches the network.
|
||||
"""
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from plugins_func.functions import get_news_from_newsnow as news_module
|
||||
from plugins_func.functions import get_weather as weather_module
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None, text="", json_error=None):
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
self.text = text
|
||||
self.json_error = json_error
|
||||
|
||||
def json(self):
|
||||
if self.json_error is not None:
|
||||
raise self.json_error
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeAsyncClient:
|
||||
"""Stands in for httpx.AsyncClient and records what was requested."""
|
||||
|
||||
requests = []
|
||||
responder = None
|
||||
init_kwargs = {}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
FakeAsyncClient.init_kwargs = kwargs
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def get(self, url, params=None, headers=None):
|
||||
FakeAsyncClient.requests.append({"url": url, "params": params})
|
||||
return FakeAsyncClient.responder(url, params)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_http(monkeypatch):
|
||||
"""Replace httpx.AsyncClient with the recording fake."""
|
||||
FakeAsyncClient.requests = []
|
||||
FakeAsyncClient.responder = lambda url, params: FakeResponse(payload={"code": "200"})
|
||||
FakeAsyncClient.init_kwargs = {}
|
||||
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
||||
return FakeAsyncClient
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self, plugins=None):
|
||||
self.config = {"plugins": plugins or {}}
|
||||
self.client_ip = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 城市查询
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_city_lookup_returns_none_on_error_code(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(payload={"code": "402"})
|
||||
|
||||
result = await weather_module.fetch_city_info("杭州", "key", "host")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_city_lookup_returns_none_on_non_json_body(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
text="<html>oops</html>", json_error=ValueError("not json")
|
||||
)
|
||||
|
||||
result = await weather_module.fetch_city_info("杭州", "key", "host")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_city_lookup_returns_none_on_non_dict_body(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(payload=["not", "an", "object"])
|
||||
|
||||
result = await weather_module.fetch_city_info("杭州", "key", "host")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_city_lookup_returns_none_on_empty_location(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
payload={"code": "200", "location": []}
|
||||
)
|
||||
|
||||
result = await weather_module.fetch_city_info("杭州", "key", "host")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_city_lookup_searches_all_cities(fake_http):
|
||||
"""不带 range 参数就是全球检索,写死 cn 会查不到境外城市。"""
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
payload={"code": "200", "location": [{"id": "101210101", "name": "杭州"}]}
|
||||
)
|
||||
|
||||
result = await weather_module.fetch_city_info("杭州", "key", "host")
|
||||
|
||||
assert result == {"id": "101210101", "name": "杭州"}
|
||||
sent = fake_http.requests[0]["params"]
|
||||
assert "range" not in sent
|
||||
assert sent["location"] == "杭州"
|
||||
assert sent["number"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 天气数据
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _weather_responder(now_payload, daily_payload):
|
||||
def responder(url, params):
|
||||
if url.endswith("/now"):
|
||||
return now_payload if isinstance(now_payload, FakeResponse) else FakeResponse(
|
||||
payload=now_payload
|
||||
)
|
||||
return daily_payload if isinstance(daily_payload, FakeResponse) else FakeResponse(
|
||||
payload=daily_payload
|
||||
)
|
||||
|
||||
return responder
|
||||
|
||||
|
||||
async def test_weather_data_needs_both_now_and_daily(fake_http):
|
||||
fake_http.responder = _weather_responder(
|
||||
{"code": "200", "now": {"text": "晴"}},
|
||||
FakeResponse(status_code=401, payload={"code": "401"}),
|
||||
)
|
||||
|
||||
assert await weather_module.fetch_weather_data({"id": "1"}, "key", "host") is None
|
||||
|
||||
|
||||
async def test_weather_data_needs_daily_when_now_failed(fake_http):
|
||||
fake_http.responder = _weather_responder(
|
||||
FakeResponse(status_code=401, payload={"code": "401"}),
|
||||
{"code": "200", "daily": [{"fxDate": "2026-10-24"}]},
|
||||
)
|
||||
|
||||
assert await weather_module.fetch_weather_data({"id": "1"}, "key", "host") is None
|
||||
|
||||
|
||||
async def test_weather_data_returns_both_when_all_succeed(fake_http):
|
||||
fake_http.responder = _weather_responder(
|
||||
{"code": "200", "now": {"text": "晴"}},
|
||||
{"code": "200", "daily": [{"fxDate": "2026-10-24"}]},
|
||||
)
|
||||
|
||||
now, daily = await weather_module.fetch_weather_data({"id": "1"}, "key", "host")
|
||||
|
||||
assert now["now"]["text"] == "晴"
|
||||
assert daily["daily"][0]["fxDate"] == "2026-10-24"
|
||||
|
||||
|
||||
async def test_weather_page_follows_redirects(fake_http):
|
||||
"""旧形态 fxLink 会 302,不跟随重定向就拿不到内容。"""
|
||||
fake_http.responder = lambda url, params: FakeResponse(text="<html></html>")
|
||||
|
||||
await weather_module.fetch_weather_page("https://www.qweather.com/weather/101210101.html")
|
||||
|
||||
assert fake_http.init_kwargs.get("follow_redirects") is True
|
||||
|
||||
|
||||
def test_weather_report_keeps_pressure_and_precipitation():
|
||||
report = weather_module.build_weather_report(
|
||||
{"name": "杭州"},
|
||||
{"now": {"text": "多云", "temp": "24", "pressure": "1003", "precip": "0.0"}},
|
||||
{"daily": []},
|
||||
)
|
||||
|
||||
assert "气压: 1003百帕" in report
|
||||
assert "降水量: 0.0毫米" in report
|
||||
|
||||
|
||||
async def test_weather_report_keeps_pressure_and_precipitation_in_flow(monkeypatch):
|
||||
"""走完整条链路时报告里也要有气压和降水量。"""
|
||||
monkeypatch.setattr(
|
||||
weather_module,
|
||||
"fetch_city_info",
|
||||
_async_value({"id": "1", "name": "杭州"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
weather_module,
|
||||
"fetch_weather_data",
|
||||
_async_value(
|
||||
({"now": {"text": "多云", "pressure": "1003", "precip": "0.0"}}, {"daily": []})
|
||||
),
|
||||
)
|
||||
|
||||
response = await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}}), location="气压测试市"
|
||||
)
|
||||
|
||||
assert "气压: 1003百帕" in response.result
|
||||
assert "降水量: 0.0毫米" in response.result
|
||||
|
||||
|
||||
async def test_missing_fxlink_does_not_call_web_fallback(monkeypatch):
|
||||
"""城市信息里没有 fxLink 时不该去抓网页,直接报失败。"""
|
||||
called = []
|
||||
|
||||
async def fake_city_info(*args, **kwargs):
|
||||
return {"id": "1", "name": "杭州"}
|
||||
|
||||
async def fake_weather_data(*args, **kwargs):
|
||||
return None
|
||||
|
||||
async def fake_page(url):
|
||||
called.append(url)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(weather_module, "fetch_city_info", fake_city_info)
|
||||
monkeypatch.setattr(weather_module, "fetch_weather_data", fake_weather_data)
|
||||
monkeypatch.setattr(weather_module, "fetch_weather_page", fake_page)
|
||||
|
||||
response = await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}}), location="无链接市"
|
||||
)
|
||||
|
||||
assert called == []
|
||||
assert response.response == "请求天气数据失败"
|
||||
|
||||
|
||||
async def test_city_not_found_message_does_not_blame_the_place_name(monkeypatch):
|
||||
"""key 失效和城市找不到都落到这句话,不能让用户去核对地名。"""
|
||||
|
||||
async def fake_city_info(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(weather_module, "fetch_city_info", fake_city_info)
|
||||
|
||||
response = await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}}), location="随便什么市"
|
||||
)
|
||||
|
||||
assert "请确认地点是否正确" not in response.result
|
||||
assert "天气服务不可用" in response.result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 新闻接口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configured_url, expected_url, expected_params",
|
||||
[
|
||||
(None, "https://newsnow.busiyi.world/api/s", {"id": "thepaper"}),
|
||||
(
|
||||
"https://newsnow.busiyi.world/api/s?id=thepaper",
|
||||
"https://newsnow.busiyi.world/api/s",
|
||||
{"id": "thepaper"},
|
||||
),
|
||||
(
|
||||
"https://newsnow.busiyi.world/api/s?id={source}",
|
||||
"https://newsnow.busiyi.world/api/s?id=thepaper",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"https://newsnow.busiyi.world/api/s",
|
||||
"https://newsnow.busiyi.world/api/s",
|
||||
{"id": "thepaper"},
|
||||
),
|
||||
(
|
||||
"https://newsnow.busiyi.world/api/s?id=",
|
||||
"https://newsnow.busiyi.world/api/s",
|
||||
{"id": "thepaper"},
|
||||
),
|
||||
(
|
||||
"https://newsnow.busiyi.world/api/s?id=thepaper&limit=5",
|
||||
"https://newsnow.busiyi.world/api/s",
|
||||
{"id": "thepaper", "limit": "5"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_news_api_url_forms(configured_url, expected_url, expected_params):
|
||||
url, params = news_module._build_news_api_url("thepaper", configured_url)
|
||||
|
||||
assert url == expected_url
|
||||
assert params == expected_params
|
||||
|
||||
|
||||
async def test_news_api_url_forms_are_actually_requested(fake_http):
|
||||
"""配置里写了 id 也不能拼出脏串。"""
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
payload={"items": [{"id": "1", "title": "标题"}]}
|
||||
)
|
||||
conn = FakeConn({"get_news_from_newsnow": {"url": "https://newsnow.busiyi.world/api/s?id=thepaper"}})
|
||||
|
||||
await news_module.fetch_news_from_api(conn, "thepaper")
|
||||
|
||||
assert fake_http.requests[0]["url"] == "https://newsnow.busiyi.world/api/s"
|
||||
assert fake_http.requests[0]["params"] == {"id": "thepaper"}
|
||||
|
||||
|
||||
async def test_news_items_must_be_a_list(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(payload={"items": "oops"})
|
||||
|
||||
assert await news_module.fetch_news_from_api(FakeConn(), "thepaper") == []
|
||||
|
||||
|
||||
async def test_news_items_without_title_are_filtered_out(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
payload={"items": [{"id": "1"}, {"id": "2", "title": ""}]}
|
||||
)
|
||||
|
||||
assert await news_module.fetch_news_from_api(FakeConn(), "thepaper") == []
|
||||
|
||||
|
||||
async def test_news_items_keep_valid_entries(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
payload={"items": [{"id": "1", "title": ""}, {"id": "2", "title": "有效标题"}]}
|
||||
)
|
||||
|
||||
items = await news_module.fetch_news_from_api(FakeConn(), "thepaper")
|
||||
|
||||
assert items == [{"id": "2", "title": "有效标题"}]
|
||||
|
||||
|
||||
def _async_value(value):
|
||||
async def _inner(*args, **kwargs):
|
||||
return value
|
||||
|
||||
return _inner
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Tests for the Outcrawl source of the web_search plugin.
|
||||
|
||||
The HTTP layer is replaced with a fake client and every request is recorded.
|
||||
Nothing here touches the network.
|
||||
"""
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from plugins_func.functions import web_search as web_search_module
|
||||
from plugins_func.register import Action
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None):
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeAsyncClient:
|
||||
"""Stands in for httpx.AsyncClient and records what was requested."""
|
||||
|
||||
requests = []
|
||||
response = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None, headers=None):
|
||||
FakeAsyncClient.requests.append({"url": url, "json": json, "headers": headers})
|
||||
return FakeAsyncClient.response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_http(monkeypatch):
|
||||
"""Replace httpx.AsyncClient with the recording fake."""
|
||||
FakeAsyncClient.requests = []
|
||||
FakeAsyncClient.response = FakeResponse(payload={"hits": []})
|
||||
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
||||
return FakeAsyncClient
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self, web_search_config):
|
||||
self.config = {"plugins": {"web_search": web_search_config}}
|
||||
|
||||
|
||||
async def test_outcrawl_sends_bearer_key_and_limit(fake_http):
|
||||
await web_search_module._search_outcrawl("oc-key", "上海天气", 5)
|
||||
|
||||
[request] = fake_http.requests
|
||||
assert request["url"] == "https://api.outcrawl.ai/v1/search"
|
||||
assert request["json"] == {"query": "上海天气", "limit": 5}
|
||||
assert request["headers"]["Authorization"] == "Bearer oc-key"
|
||||
assert request["headers"]["User-Agent"] == "xiaozhi-esp32-server"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("max_results", "limit"), [(0, 1), (50, 20)])
|
||||
async def test_outcrawl_clamps_limit(fake_http, max_results, limit):
|
||||
await web_search_module._search_outcrawl("oc-key", "q", max_results)
|
||||
|
||||
assert fake_http.requests[0]["json"]["limit"] == limit
|
||||
|
||||
|
||||
async def test_outcrawl_formats_titles_and_snippets_without_urls(fake_http):
|
||||
fake_http.response = FakeResponse(
|
||||
payload={
|
||||
"hits": [
|
||||
{"url": "https://a.example/1", "title": "标题一", "snippet": "摘要一", "rank": 1},
|
||||
{"url": "https://a.example/2", "title": None, "snippet": "", "rank": 2},
|
||||
{"url": "https://a.example/3", "title": "标题三", "snippet": "摘要三", "rank": 3},
|
||||
],
|
||||
"usage": {"credits": "1.5"},
|
||||
}
|
||||
)
|
||||
|
||||
result = await web_search_module._search_outcrawl("oc-key", "q", 2)
|
||||
|
||||
assert result == "【联网搜索结果】\n1. 标题:标题一\n 摘要:摘要一\n2. 标题:无标题"
|
||||
assert "https://" not in result
|
||||
|
||||
|
||||
async def test_outcrawl_empty_hits(fake_http):
|
||||
fake_http.response = FakeResponse(payload={"hits": []})
|
||||
|
||||
assert await web_search_module._search_outcrawl("oc-key", "q", 3) == "未找到相关搜索结果。"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "message"),
|
||||
[
|
||||
("unauthorized", "联网搜索请求失败,请检查API Key是否正确。"),
|
||||
("quota_exceeded", "联网搜索被限流或额度已用完,请稍后重试或检查Outcrawl账户余额。"),
|
||||
("bad_request", "联网搜索请求失败,请稍后重试。"),
|
||||
("internal", "联网搜索请求失败,请稍后重试。"),
|
||||
],
|
||||
)
|
||||
async def test_outcrawl_error_code(fake_http, code, message):
|
||||
fake_http.response = FakeResponse(
|
||||
status_code=429, payload={"code": code, "message": "denied", "details": {}}
|
||||
)
|
||||
|
||||
assert await web_search_module._search_outcrawl("oc-key", "q", 3) == message
|
||||
|
||||
|
||||
async def test_web_search_dispatches_to_outcrawl(fake_http):
|
||||
fake_http.response = FakeResponse(payload={"hits": [{"url": "https://a.example", "title": "T", "snippet": "S"}]})
|
||||
conn = FakeConn({"provider": "Outcrawl", "api_key": "oc-key", "max_results": 3})
|
||||
|
||||
response = await web_search_module.web_search(conn, "q")
|
||||
|
||||
assert response.action == Action.REQLLM
|
||||
assert response.result == "【联网搜索结果】\n1. 标题:T\n 摘要:S"
|
||||
assert fake_http.requests[0]["json"] == {"query": "q", "limit": 3}
|
||||
@@ -65,13 +65,6 @@ def _build_provider():
|
||||
ptype = get_provider_type("LLM", name)
|
||||
if not ptype:
|
||||
pytest.skip(f"LLM '{name}' 的 type 字段缺失")
|
||||
if not LIVE_API_TESTS:
|
||||
# mock 模式把网络拦了,这条链路拿不到真实回复,跑下来只是一次连接失败。
|
||||
# 真要验这条链路,用 RUN_LIVE_API_TESTS=1 配真 key 跑
|
||||
pytest.skip(
|
||||
f"mock 模式不发真请求,跳过 LLM '{name}' 的链路验证。"
|
||||
"真请求模式:export RUN_LIVE_API_TESTS=1 并填真 key 后再跑。"
|
||||
)
|
||||
from core.utils.llm import create_instance
|
||||
|
||||
return create_instance(ptype, cfg)
|
||||
|
||||
Reference in new issue
Block a user