Compare commits
215
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fd5ed5772 | ||
|
|
92337881a7 | ||
|
|
a93b71f9e7 | ||
|
|
325f52469a | ||
|
|
3230a69c99 | ||
|
|
1ac4c09946 | ||
|
|
1a095d5bed | ||
|
|
01289b8fd4 | ||
|
|
ef2fcdbcb1 | ||
|
|
942ac2fe12 | ||
|
|
4dbed0ab1d | ||
|
|
15d9b0fba1 | ||
|
|
8c5330f742 | ||
|
|
549261fdff | ||
|
|
2cb0562ade | ||
|
|
ccc77799d9 | ||
|
|
d9c2e8c5d9 | ||
|
|
1a675e4d6b | ||
|
|
40a7ed0391 | ||
|
|
62e316f298 | ||
|
|
76cef92cb2 | ||
|
|
826c25d3e5 | ||
|
|
f1965167cc | ||
|
|
6c352e5978 | ||
|
|
032f6f1582 | ||
|
|
a377d89dcd | ||
|
|
d35b9f8b00 | ||
|
|
d687121e99 | ||
|
|
badf6fe3d0 | ||
|
|
adb4a15f2d | ||
|
|
a5f9a782d6 | ||
|
|
7ab4cc5c90 | ||
|
|
9c4790c8d0 | ||
|
|
4fd51c6f7f | ||
|
|
e7e0a886bf | ||
|
|
3ff080bb0d | ||
|
|
ca8ff121d4 | ||
|
|
9f63b255fb | ||
|
|
9d34360a2c | ||
|
|
2f18e2ee0a | ||
|
|
fd05dd5159 | ||
|
|
82d2acc78c | ||
|
|
77be288c8e | ||
|
|
ba22f5b765 | ||
|
|
0492dc5924 | ||
|
|
ffd4ab74d7 | ||
|
|
f0ebebfa85 | ||
|
|
61136b958b | ||
|
|
ee726f49e6 | ||
|
|
05f0da0639 | ||
|
|
af1982c424 | ||
|
|
9bd668aebc | ||
|
|
d027432432 | ||
|
|
3972df6e4e | ||
|
|
8f2dda09cf | ||
|
|
d4ff9c5c17 | ||
|
|
3709540efd | ||
|
|
333050ae2e | ||
|
|
95ce49ffc9 | ||
|
|
be75e7bd39 | ||
|
|
24fbf7eee2 | ||
|
|
855a64cbb9 | ||
|
|
fdf280f84a | ||
|
|
498191519c | ||
|
|
f614489d81 | ||
|
|
6f958ee4a6 | ||
|
|
b226ee5430 | ||
|
|
9c30b5e035 | ||
|
|
d531d04281 | ||
|
|
d8f8a4a083 | ||
|
|
37d4330224 | ||
|
|
7873984f9f | ||
|
|
6840210ea8 | ||
|
|
0a54124d0d | ||
|
|
ddc441bc59 | ||
|
|
9b7fb3e826 | ||
|
|
a6cdbf9410 | ||
|
|
75fd5d8cc3 | ||
|
|
6fe5630346 | ||
|
|
eb9dc8ca0f | ||
|
|
4ed3197a6f | ||
|
|
4dd194ef8b | ||
|
|
8c41faac8b | ||
|
|
597ab5f415 | ||
|
|
4f1cb98503 | ||
|
|
27399a2fb6 | ||
|
|
7c43ecfcb5 | ||
|
|
0be634c627 | ||
|
|
932962e8d3 | ||
|
|
60c2a7ee17 | ||
|
|
a01f0aa29b | ||
|
|
4445aa7510 | ||
|
|
dc86a780a2 | ||
|
|
a5e47f2c4e | ||
|
|
2057b99aeb | ||
|
|
7957817a72 | ||
|
|
a6eeb107f4 | ||
|
|
847337cabb | ||
|
|
b8f23ffa66 | ||
|
|
dc581d7185 | ||
|
|
741ec299c0 | ||
|
|
505925fb21 | ||
|
|
4aa63baf75 | ||
|
|
70ab19d23f | ||
|
|
e5f326285e | ||
|
|
4576e391bb | ||
|
|
ef1d24e40d | ||
|
|
c30c40bf56 | ||
|
|
199c4ce365 | ||
|
|
a6cd755cf2 | ||
|
|
23d0e64d9d | ||
|
|
96c224c4e4 | ||
|
|
2b6483662a | ||
|
|
aef973e6b9 | ||
|
|
7a260ee143 | ||
|
|
87c6df77b0 | ||
|
|
eb51f420a2 | ||
|
|
1eefb4ea86 | ||
|
|
1211f8202b | ||
|
|
788f5301fd | ||
|
|
edad16aedf | ||
|
|
65a3fae9fc | ||
|
|
f4ba65f224 | ||
|
|
87c2bae55a | ||
|
|
628d4cedb7 | ||
|
|
6c3f89cae1 | ||
|
|
4745186cea | ||
|
|
1ddc97b626 | ||
|
|
6afc54a17d | ||
|
|
daccfeb9dd | ||
|
|
1846d3e935 | ||
|
|
b1a7607dd5 | ||
|
|
d6d2da3d76 | ||
|
|
5aa46538d5 | ||
|
|
ed98be5347 | ||
|
|
79a95af399 | ||
|
|
32cba36237 | ||
|
|
82e3c1874f | ||
|
|
c478257517 | ||
|
|
9fdb72cfd7 | ||
|
|
3c31315ecc | ||
|
|
07c6cdf16a | ||
|
|
8a3c2c51a0 | ||
|
|
046fdc8aa4 | ||
|
|
61e4879826 | ||
|
|
eda3c23d51 | ||
|
|
a03a0a73db | ||
|
|
27e780e4c1 | ||
|
|
4b1ccce575 | ||
|
|
323c2248b4 | ||
|
|
a31ba7ef6d | ||
|
|
935184b1d0 | ||
|
|
c7b126ca2f | ||
|
|
95954c8dae | ||
|
|
17c0da7f87 | ||
|
|
dcb3d3e0b0 | ||
|
|
e58de03be2 | ||
|
|
975bbe2cfd | ||
|
|
88d6d13a62 | ||
|
|
c88149a5d4 | ||
|
|
6a3aaf475e | ||
|
|
caab6a145d | ||
|
|
7ac454a1b9 | ||
|
|
ba20bebaeb | ||
|
|
922adbb290 | ||
|
|
6eca417534 | ||
|
|
93d54f175a | ||
|
|
34ab39ba69 | ||
|
|
a0dd138e93 | ||
|
|
b90dd01826 | ||
|
|
22b1703557 | ||
|
|
3363965f63 | ||
|
|
eb4b28c59e | ||
|
|
74cc31d1cc | ||
|
|
c02becdc80 | ||
|
|
b16ed2ed36 | ||
|
|
af9abdd771 | ||
|
|
e1876f1ce1 | ||
|
|
ced7103639 | ||
|
|
cb0b075304 | ||
|
|
cdfb2a1187 | ||
|
|
8b64205522 | ||
|
|
6529b80c64 | ||
|
|
d76825fae5 | ||
|
|
c55dc8282f | ||
|
|
4870b5a0a2 | ||
|
|
ae3f970c92 | ||
|
|
8367f9b607 | ||
|
|
59b51e021b | ||
|
|
2c89b284b2 | ||
|
|
04b3ad5aae | ||
|
|
2c43a13d6e | ||
|
|
17560a7d29 | ||
|
|
db7db3de67 | ||
|
|
d03d0d0982 | ||
|
|
545979873b | ||
|
|
16b6841710 | ||
|
|
2a465f0fb5 | ||
|
|
ea6b0c3746 | ||
|
|
7475ea2b2d | ||
|
|
1fa178cd36 | ||
|
|
93885058e9 | ||
|
|
88eed86dc2 | ||
|
|
a269953787 | ||
|
|
50895d6e33 | ||
|
|
a21a2e34d0 | ||
|
|
7a22b61969 | ||
|
|
477f85b97c | ||
|
|
de45f73efd | ||
|
|
1dc46e30d7 | ||
|
|
5e0d853256 | ||
|
|
2a9f809700 | ||
|
|
523915f9fe | ||
|
|
18e5c47a06 | ||
|
|
4a2c90ec87 |
No files matched your search
+37
-1
@@ -3,4 +3,40 @@ updates:
|
||||
- package-ecosystem: pip
|
||||
directory: /main/xiaozhi-server
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
ai-services:
|
||||
patterns:
|
||||
- "openai*"
|
||||
- "google-generativeai"
|
||||
- "dashscope"
|
||||
- "baidu-aip"
|
||||
- "cozepy"
|
||||
- "markitdown"
|
||||
- "mem0ai"
|
||||
- "powermem"
|
||||
ai-models:
|
||||
patterns:
|
||||
- "funasr"
|
||||
- "vosk"
|
||||
- "silero_vad"
|
||||
- "opuslib_next"
|
||||
- "sherpa_onnx"
|
||||
- "modelscope"
|
||||
web-stack:
|
||||
patterns:
|
||||
- "httpx"
|
||||
- "aiohttp"
|
||||
- "aiohttp_cors"
|
||||
- "requests"
|
||||
- "pysocks"
|
||||
everything-else:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: "torch"
|
||||
- dependency-name: "torchaudio"
|
||||
- dependency-name: "numpy"
|
||||
- dependency-name: "portalocker"
|
||||
- dependency-name: "websockets"
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
python:
|
||||
name: Python (xiaozhi-server)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'main/xiaozhi-server/requirements.txt'
|
||||
- name: Install dependencies
|
||||
working-directory: main/xiaozhi-server
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-asyncio freezegun
|
||||
- name: Run smoke test
|
||||
working-directory: main/xiaozhi-server
|
||||
run: |
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
cache: 'maven'
|
||||
- name: Run tests
|
||||
working-directory: main/manager-api
|
||||
run: mvn -B -q test -DfailIfNoTests=false
|
||||
|
||||
web:
|
||||
name: Vue (manager-web)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: main/manager-web/package-lock.json
|
||||
- name: Run tests
|
||||
working-directory: main/manager-web
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
npm run test:unit
|
||||
@@ -29,4 +29,5 @@ COPY main/xiaozhi-server/requirements.txt .
|
||||
|
||||
# 安装Python依赖
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
||||
pip install --no-cache-dir -r requirements.txt --default-timeout=120 --retries 5
|
||||
# 阿里云 PyPI 镜像偶有元数据 404,加官方源作 fallback
|
||||
pip install --no-cache-dir -r requirements.txt --default-timeout=120 --retries 5 --extra-index-url https://pypi.org/simple
|
||||
@@ -0,0 +1,21 @@
|
||||
.PHONY: test test-fast test-python test-java test-web help
|
||||
|
||||
help:
|
||||
@echo "make test-fast - run all fast unit tests (default)"
|
||||
@echo "make test - alias for test-fast"
|
||||
@echo "make test-python - pytest in main/xiaozhi-server"
|
||||
@echo "make test-java - mvn test in main/manager-api"
|
||||
@echo "make test-web - npm run test:unit in main/manager-web"
|
||||
|
||||
test: test-fast
|
||||
|
||||
test-fast: test-python test-java test-web
|
||||
|
||||
test-python:
|
||||
cd main/xiaozhi-server && python -m pytest -x -q
|
||||
|
||||
test-java:
|
||||
cd main/manager-api && mvn -B -q test -DfailIfNoTests=false
|
||||
|
||||
test-web:
|
||||
cd main/manager-web && npm ci --no-audit --no-fund && npm test
|
||||
@@ -22,6 +22,7 @@
|
||||
<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>
|
||||
@@ -169,12 +170,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`要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) |
|
||||
| **最简化安装** | 智能对话、单智能体管理 | 低配置环境,数据存储在配置文件,无需数据库 | [①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) |
|
||||
|
||||
常见问题及相关教程,可参考[这个链接](./docs/FAQ.md)
|
||||
|
||||
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据,
|
||||
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据。
|
||||
|
||||
```
|
||||
智控台地址: https://2662r3426b.vicp.fun
|
||||
@@ -202,7 +203,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),可按报告中的测试方法在您的环境中实际测试。
|
||||
|
||||
@@ -211,8 +212,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 工具名称 | 位置 | 使用方法 | 功能说明 |
|
||||
|:---:|:---|:---:|:---:|
|
||||
| 音频交互测试工具 | 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(语音合成)三个核心模块的响应速度 |
|
||||
| 音频交互测试工具 | 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(语音合成)等核心模块的响应速度 |
|
||||
|
||||
> 💡 提示:测试模型速度时,只会测试配置了密钥的模型。
|
||||
|
||||
@@ -230,12 +231,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部署和本地部署,提供完整的配置文件管理 |
|
||||
| 插件系统 | 支持功能插件扩展、自定义插件开发和插件热加载 |
|
||||
| 插件系统 | 支持功能插件扩展、自定义插件开发;新增插件文件在下次设备连接时自动加载,改动已加载插件的代码需重启服务 |
|
||||
|
||||
### 正在开发 🚧
|
||||
|
||||
@@ -255,7 +256,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| openai 接口调用 | 阿里百炼、火山引擎、DeepSeek、智谱、Gemini、科大讯飞 | 智谱、Gemini |
|
||||
| OpenAI 接口调用 | 阿里百炼、火山引擎、DeepSeek、智谱、Gemini、科大讯飞、LM Studio | 智谱、Gemini |
|
||||
| ollama 接口调用 | Ollama | - |
|
||||
| dify 接口调用 | Dify | - |
|
||||
| fastgpt 接口调用 | Fastgpt | - |
|
||||
@@ -263,7 +264,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
| xinference 接口调用 | Xinference | - |
|
||||
| homeassistant 接口调用 | HomeAssistant | - |
|
||||
|
||||
实际上,任何支持 openai 接口调用的 LLM 均可接入使用。
|
||||
实际上,任何支持 OpenAI 接口调用的 LLM 均可接入使用。
|
||||
|
||||
---
|
||||
|
||||
@@ -271,9 +272,9 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| openai 接口调用 | 阿里百炼、智谱ChatGLMVLLM | 智谱ChatGLMVLLM |
|
||||
| OpenAI 接口调用 | 阿里百炼、智谱ChatGLMVLLM | 智谱ChatGLMVLLM |
|
||||
|
||||
实际上,任何支持 openai 接口调用的 VLLM 均可接入使用。
|
||||
实际上,任何支持 OpenAI 接口调用的 VLLM 均可接入使用。
|
||||
|
||||
---
|
||||
|
||||
@@ -281,8 +282,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| 接口调用 | 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 |
|
||||
| 接口调用 | 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -298,8 +299,8 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| 本地使用 | FunASR、SherpaASR | FunASR、SherpaASR |
|
||||
| 接口调用 | FunASRServer、火山引擎、科大讯飞、腾讯云、阿里云、百度云、OpenAI ASR | FunASRServer |
|
||||
| 本地使用 | FunASR、SherpaASR、SherpaParaformerASR、VoskASR | FunASR、SherpaASR、SherpaParaformerASR、VoskASR |
|
||||
| 接口调用 | FunASRServer、火山引擎、科大讯飞、腾讯云、阿里云及百炼、百度云、OpenAI ASR、GroqASR、Qwen3ASRFlash | FunASRServer |
|
||||
|
||||
---
|
||||
|
||||
@@ -307,7 +308,9 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
|
||||
| 使用方式 | 支持平台 | 免费平台 |
|
||||
|:---:|:---:|:---:|
|
||||
| 本地使用 | 3D-Speaker | 3D-Speaker |
|
||||
| 接口调用 | 声纹识别服务(voiceprint-api) | - |
|
||||
|
||||
声纹识别需要单独部署 [voiceprint-api](https://github.com/xinnan-tech/voiceprint-api) 服务(基于 3D-Speaker),并依赖 MySQL,详见[声纹识别启用指南](./docs/voiceprint-integration.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -353,11 +356,10 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||
| <img src="./docs/images/logo_contributors.png" width="160"> | [代码贡献者](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors) | 感谢[所有代码贡献者](https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors)贡献者,你们的付出让项目更加健壮和强大。 |
|
||||
|
||||
|
||||
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
<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/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" />
|
||||
<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>
|
||||
+17
-19
@@ -2,7 +2,7 @@
|
||||

|
||||
# 方式一:Docker只运行Server
|
||||
|
||||
`0.8.2`版本开始,本项目发行的docker镜像只支持`x86架构`,如果需要在`arm64架构`的CPU上部署,可按照[这个教程](docker-build.md)在本机编译`arm64的镜像`。
|
||||
本项目发行的docker镜像同时提供`amd64`和`arm64`两种架构,可以直接拉取使用;如需自定义镜像,可按照[这个教程](docker-build.md)在本机编译。
|
||||
|
||||
## 1. 安装docker
|
||||
|
||||
@@ -36,9 +36,9 @@ xiaozhi-server
|
||||
|
||||
#### 1.1.3 下载配置文件
|
||||
|
||||
你需要下载两个配置文件:`docker-compose.yaml` 和 `config.yaml`。需要从项目仓库下载这两个文件。
|
||||
你需要下载两个配置文件:`docker-compose.yml` 和 `config.yaml`。需要从项目仓库下载这两个文件。
|
||||
|
||||
##### 1.1.3.1 下载 docker-compose.yaml
|
||||
##### 1.1.3.1 下载 docker-compose.yml
|
||||
|
||||
用浏览器打开[这个链接](../main/xiaozhi-server/docker-compose.yml)。
|
||||
|
||||
@@ -70,7 +70,7 @@ xiaozhi-server
|
||||
|
||||
## 2. 配置项目文件
|
||||
|
||||
接下里,程序还不能直接运行,你需要配置一下,你到底使用的是什么模型。你可以看这个教程:
|
||||
接下来,程序还不能直接运行,你需要配置一下,你到底使用的是什么模型。你可以看这个教程:
|
||||
[跳转到配置项目文件](#配置项目)
|
||||
|
||||
配置完项目文件后,回到本教程继续往下。
|
||||
@@ -91,25 +91,22 @@ docker logs -f xiaozhi-esp32-server
|
||||
|
||||
这时,你就要留意日志信息,可以根据这个教程,判断是否成功了。[跳转到运行状态确认](#运行状态确认)
|
||||
|
||||
## 5. 版本升级操作
|
||||
## 4. 版本升级操作
|
||||
|
||||
如果后期想升级版本,可以这么操作
|
||||
|
||||
5.1、备份好`data`文件夹中的`.config.yaml`文件,一些关键的配置到时复制到新的`.config.yaml`文件里。
|
||||
4.1、备份好`data`文件夹中的`.config.yaml`文件,一些关键的配置到时复制到新的`.config.yaml`文件里。
|
||||
请注意是对关键密钥逐个复制,不要直接覆盖。因为新的`.config.yaml`文件可能有一些新的配置项,旧的`.config.yaml`文件不一定有。
|
||||
|
||||
5.2、执行以下命令
|
||||
4.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
|
||||
```
|
||||
|
||||
5.3、重新按docker方式部署
|
||||
4.3、重新按docker方式部署
|
||||
|
||||
# 方式二:本地源码只运行Server
|
||||
|
||||
@@ -119,7 +116,7 @@ docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
|
||||
如果确定使用`conda`,则安装好后,开始执行以下命令。
|
||||
|
||||
重要提示!windows 用户,可以通过安装`Anaconda`来管理环境。安装好`Anaconda`后,在`开始`那里搜索`anaconda`相关的关键词,
|
||||
找到`Anaconda Prpmpt`,使用管理员身份运行它。如下图。
|
||||
找到`Anaconda Prompt`,使用管理员身份运行它。如下图。
|
||||
|
||||

|
||||
|
||||
@@ -213,14 +210,14 @@ prompt: |
|
||||
请你像一个人一样说话,请勿返回配置xml及其他特殊字符。
|
||||
|
||||
selected_module:
|
||||
LLM: DoubaoLLM
|
||||
LLM: ChatGLMLLM
|
||||
|
||||
LLM:
|
||||
ChatGLMLLM:
|
||||
api_key: xxxxxxxxxxxxxxx.xxxxxx
|
||||
```
|
||||
|
||||
建议先将最简单的配置运行起来,然后再去`xiaozhi/config.yaml`阅读配置的使用说明。
|
||||
建议先将最简单的配置运行起来,然后再去[`../main/xiaozhi-server/config.yaml`](../main/xiaozhi-server/config.yaml)阅读配置的使用说明。
|
||||
比如你要换更换模型,修改`selected_module`下的配置就行。
|
||||
|
||||
## 模型文件
|
||||
@@ -238,11 +235,12 @@ LLM:
|
||||
如果你能看到,类似以下日志,则是本项目服务启动成功的标志。
|
||||
|
||||
```
|
||||
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-=======================================================
|
||||
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-=============================================================
|
||||
```
|
||||
|
||||
正常来说,如果您是通过源码运行本项目,日志会有你的接口地址信息。
|
||||
|
||||
@@ -335,7 +335,7 @@ npm run serve
|
||||
如果确定使用`conda`,则安装好后,开始执行以下命令。
|
||||
|
||||
重要提示!windows 用户,可以通过安装`Anaconda`来管理环境。安装好`Anaconda`后,在`开始`那里搜索`anaconda`相关的关键词,
|
||||
找到`Anaconda Prpmpt`,使用管理员身份运行它。如下图。
|
||||
找到`Anaconda Prompt`,使用管理员身份运行它。如下图。
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
`上下文源` 在小智在唤醒那一刻,获取外部系统的数据,并将其动态注入到大模型的系统提示词(System Prompt)中。
|
||||
让其做到唤醒时感知世界某个事物的状态。
|
||||
|
||||
它和MCP、记忆有本质的区别:`上下文源`是强制让小智感知世界的数据;`记忆(Mem)`是让他知道之前聊了什么内容;`MCP(functionc all)`是当需要调用某项能力/知识的时候使用调用。
|
||||
它和MCP、记忆有本质的区别:`上下文源`是强制让小智感知世界的数据;`记忆(Mem)`是让他知道之前聊了什么内容;`MCP(function_call)`是当需要调用某项能力/知识的时候使用调用。
|
||||
|
||||
通过这个功能,在小智唤醒的一刹那,“感知”到:
|
||||
- 人体健康传感器状态(体温、血压、血氧状态等)
|
||||
|
||||
@@ -51,7 +51,7 @@ cp 你原来的model.pt完整路径 /home/system/xiaozhi/xiaozhi-esp32-server/ma
|
||||
|
||||
# 第四步 建立三个自动编译文件
|
||||
|
||||
## 4.1 自动编译mananger-web模块
|
||||
## 4.1 自动编译manager-web模块
|
||||
在`/home/system/xiaozhi/`目录下,创建名字为`update_8001.sh`的文件,内容如下
|
||||
|
||||
```
|
||||
@@ -106,7 +106,7 @@ fi
|
||||
|
||||
nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.active=dev &
|
||||
|
||||
tail tail -f nohup.out
|
||||
tail -f nohup.out
|
||||
```
|
||||
|
||||
保存好后执行赋权命令
|
||||
|
||||
@@ -144,5 +144,7 @@
|
||||
### Q: 我叫他呼叫"张山",但是他老是识别成"张三",怎么办?
|
||||
- 可以查阅你使用的asr服务的文档,确认是否支持热词识别。
|
||||
- 如果你用的是`FunASRServer`,可以在容器里的`热词文件`里添加"张山",然后重启容器。
|
||||
- 如果你用的是`火山引擎`的服务,可以在`火山引擎的控制台`里添加`热词文件`,然后回到智控台的`模型配置页面`,把`热词文件名称`配置在`火山引擎的tts`上去。
|
||||
- 如果你用的是`火山引擎`的服务,可以在`火山引擎的控制台`里添加`热词文件`,然后回到智控台的`模型配置`页面,在`语音识别`里找到模型名称为`豆包语音识别(流式)`的那一条,点`修改`,弹窗里的`供应器`是`火山引擎语音识别(流式)`,在弹窗的`调用信息`区域把`热词文件名称`填到对应的输入框,点`确认保存`。
|
||||
- `豆包语音识别2.0(流式)`(供应器是`火山引擎语音识别(流式)`)和`豆包语音识别`(供应器是`火山引擎语音识别`)这两条也都有`热词文件名称`这个字段,用法一样,配置时注意别选错行。
|
||||
- 改完要让设备重新连接才会生效:设备连上时才会把最新的私有配置拉下来,一直连着的设备不会自动变。
|
||||
|
||||
@@ -45,7 +45,7 @@ python download_models.py
|
||||
python -m tools.api_server --listen 0.0.0.0:6006
|
||||
```
|
||||
|
||||
然后用浏览器去到aotodl实例页面
|
||||
然后用浏览器去到autodl实例页面
|
||||
```
|
||||
https://autodl.com/console/instance/list
|
||||
```
|
||||
|
||||
@@ -68,7 +68,7 @@ http://homeassistant.local:8123
|
||||
|
||||
然后在实体中搜索你相关控制的开关,结果出来后,在列表中,点击其中一个结果,这是会出现一个开关的界面。
|
||||
|
||||
在开关的界面,我们尝试点击开关,看看是开发会随着我们的点击开/关。如果能操作,说明是正常联网的。
|
||||
在开关的界面,我们尝试点击开关,看看是否会随着我们的点击开/关。如果能操作,说明是正常联网的。
|
||||
|
||||
接着在开关面板找到设置按钮,点击后,可以查看这个开关的`实体标识符`。
|
||||
|
||||
@@ -107,7 +107,7 @@ http://homeassistant.local:8123
|
||||
|
||||
保存成功后,即可唤醒设备操作。
|
||||
|
||||
#### 3. 唤醒设别进行控制
|
||||
#### 3. 唤醒设备进行控制
|
||||
|
||||
尝试和esp32说,“打开XXX灯”
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
本教程分为4个阶段:准备阶段、配置阶段、克隆阶段、使用阶段。主要是介绍通过智控台配置火山双流式语音合成+音色克隆的过程。
|
||||
|
||||
## 第一阶段:准备阶段
|
||||
超级管理员先预先把火山引擎服务开通好,获取到App Id,Access Token。默认火上引擎会赠送一个音色资源。这个音色资源需要把它复制到本项目里。
|
||||
超级管理员先预先把火山引擎服务开通好,获取到App Id,Access Token。默认火山引擎会赠送一个音色资源。这个音色资源需要把它复制到本项目里。
|
||||
|
||||
如果你想克隆多个音色,需要购买开通多个音色资源。只要把每个音色资源的声音ID(S_xxxxx)复制到本项目。然后分配给系统的账号使用即可。以下是详细步骤:
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
打开完,找到页面中一个绿色的按钮,写着`Code`的按钮,点开它,然后你就看到`Download ZIP`的按钮。
|
||||
|
||||
点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`mcp-calculatorr-main`
|
||||
点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`mcp-calculator-main`
|
||||
你需要把它重命名成`mcp-calculator`。接下来,我们用命令行进入项目目录即安装依赖
|
||||
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ server:
|
||||
|
||||
那我们需要先登录[智谱AI](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)网站,申请密钥。如果你之前已经申请过了密钥,可以复用这个密钥。
|
||||
|
||||
登录`智控台`,顶部菜单点击`模型配置`,在左侧栏点击`视觉打语言模型`,找到`VLLM_ChatGLMVLLM`,点击修改按钮,在弹框中,在`API密钥`输入你密钥,点击保存。
|
||||
登录`智控台`,顶部菜单点击`模型配置`,在左侧栏点击`视觉大语言模型`,找到`VLLM_ChatGLMVLLM`,点击修改按钮,在弹框中,在`API密钥`输入你密钥,点击保存。
|
||||
|
||||
保存成功后,去到你需要测试的智能体哪里,点击`配置角色`,在打开的内容里,查看`视觉大语言模型(VLLM)`是否选择了刚才的视觉模型。点击保存。
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ SERVER_SECRET=Te1st12134 # 服务器密钥,请保持和智控台(server.sec
|
||||
- 注意不要用简单的密码,比如`123456`、`test`等。
|
||||
- 注意不要用简单的密码,比如`123456`、`test`等。
|
||||
|
||||
`SERVER_SECRET` 是用生成websocket连接的认证信息。
|
||||
`SERVER_SECRET` 是用于生成websocket连接的认证信息。
|
||||
|
||||
1、如果你是全模块部署,且你的智控台的参数管理里`server.auth.enabled`设置成了`true`,那么,`SERVER_SECRET`需要和智控台(`server.secret`)保持一致。
|
||||
|
||||
|
||||
@@ -50,10 +50,11 @@ 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.启动服务
|
||||
```yaml
|
||||
```bash
|
||||
paddlespeech_server start --config_file ./demos/streaming_tts_server/conf/tts_online_application.yaml
|
||||
#官方默认启动命令:
|
||||
paddlespeech_server start --config_file ./conf/tts_online_application.yaml
|
||||
@@ -68,10 +69,19 @@ INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:8092 (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
## 三、修改小智的配置文件
|
||||
### 1.```main/xiaozhi-server/core/providers/tts/paddle_speech.py```
|
||||
## 三、配置小智服务
|
||||
### 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
|
||||
```
|
||||
|
||||
这个文件是必需的,少了它小智服务起不来,会直接报错退出。
|
||||
|
||||
### 2.```main/xiaozhi-server/data/.config.yaml```
|
||||
使用单模块部署
|
||||
```yaml
|
||||
selected_module:
|
||||
@@ -79,19 +89,41 @@ selected_module:
|
||||
TTS:
|
||||
PaddleSpeechTTS:
|
||||
type: paddle_speech
|
||||
protocol: websocket
|
||||
protocol: websocket # 当前仅支持 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服务
|
||||
```py
|
||||
|
||||
小智服务用**另一个** 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 再执行
|
||||
python app.py
|
||||
```
|
||||
启动`main/digital-human`下的`python start.py`后,打开`http://127.0.0.1:8006/index.html`,测试连接和发送消息时paddlespeech端是否有输出日志
|
||||
|
||||
要验证语音合成的实际输出,另开一个终端,在仓库根目录下跑数字人页面(这套代码另有一份依赖清单,在 `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端是否有输出日志
|
||||
|
||||
输出日志参考:
|
||||
```
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# 语音识别、大语言模型、非流式语音合成、流式语音合成、视觉模型的性能测试工具使用指南
|
||||
# 语音识别、流式语音识别、大语言模型、非流式语音合成、流式语音合成、视觉模型的性能测试工具使用指南
|
||||
|
||||
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:
|
||||
@@ -16,12 +23,50 @@ 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`)是整项跳过:没配就不报错、结果表里也不会有这一行。
|
||||
@@ -1,6 +1,6 @@
|
||||
# ragflow 集成指南
|
||||
|
||||
本教程主要是是两部分
|
||||
本教程主要是两部分
|
||||
|
||||
- 一、如何部署ragflow
|
||||
- 二、如何在智控台配置ragflow接口
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
ragflow需要依赖`mysql`数据库。如果你之前已经部署`智控台`,说明你已经安装了`mysql`。你可以共用它。
|
||||
|
||||
你可以你试一下在宿主机使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
|
||||
你可以试一下在宿主机使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
|
||||
``` shell
|
||||
telnet 127.0.0.1 3306
|
||||
|
||||
|
||||
+39
-37
@@ -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-Endpunkte und Stimmabdruckerkennung
|
||||
Unterstützt MQTT+UDP-Protokoll, Websocket-Protokoll, MCP-Zugriffspunkte, Stimmabdruckerkennung und Wissensdatenbank
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -22,6 +22,7 @@ Unterstützt MQTT+UDP-Protokoll, Websocket-Protokoll, MCP-Endpunkte und Stimmabd
|
||||
<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>
|
||||
@@ -54,31 +55,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="响应速度感受" src="docs/images/demo9.png" /></picture>
|
||||
<img alt="Das Gefühl der Reaktionsgeschwindigkeit" src="../images/demo9.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||
<picture>
|
||||
<img alt="速度优化秘诀" src="docs/images/demo6.png" /></picture>
|
||||
<img alt="Das Geheimnis der Geschwindigkeitsoptimierung" src="../images/demo6.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1WEcxzFEAT" target="_blank">
|
||||
<picture>
|
||||
<img alt="小智数字人 支持语音唤醒" src="docs/images/demo8.png" /></picture>
|
||||
<img alt="Xiaozhi-Digitalmensch mit Sprachaktivierung" src="../images/demo8.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CKVz6UEuB" target="_blank">
|
||||
<picture>
|
||||
<img alt="设备呼叫设备,打电话" src="docs/images/demo0.png" /></picture>
|
||||
<img alt="Anruf von Gerät zu Gerät" src="../images/demo0.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||
<picture>
|
||||
<img alt="复杂医疗场景" src="docs/images/demo1.png" /></picture>
|
||||
<img alt="Komplexes medizinisches Szenario" src="../images/demo1.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -86,31 +87,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="播放音乐查询天气播报新闻" src="docs/images/demo7.png" /></picture>
|
||||
<img alt="Musik abspielen, Wetter abfragen und Nachrichten vorlesen" src="../images/demo7.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12J7WzBEaH" target="_blank">
|
||||
<picture>
|
||||
<img alt="实时打断" src="docs/images/demo10.png" /></picture>
|
||||
<img alt="Unterbrechung in Echtzeit" src="../images/demo10.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Co76z7EvK" target="_blank">
|
||||
<picture>
|
||||
<img alt="拍照识物品" src="docs/images/demo12.png" /></picture>
|
||||
<img alt="Objekte per Foto erkennen" src="../images/demo12.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
<img alt="控制家电开关" src="docs/images/demo5.png" /></picture>
|
||||
<img alt="Haushaltsgeräte ein- und ausschalten" src="../images/demo5.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<picture>
|
||||
<img alt="多指令任务" src="docs/images/demo11.png" /></picture>
|
||||
<img alt="Aufgaben mit mehreren Befehlen" src="../images/demo11.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -118,31 +119,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接入点" src="docs/images/demo13.png" /></picture>
|
||||
<img alt="MCP-Zugriffspunkt" src="../images/demo13.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
|
||||
<picture>
|
||||
<img alt="MQTT指令下发" src="docs/images/demo4.png" /></picture>
|
||||
<img alt="MQTT-Befehlsübermittlung" src="../images/demo4.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<picture>
|
||||
<img alt="声纹识别" src="docs/images/demo14.png" /></picture>
|
||||
<img alt="Stimmabdruckerkennung" src="../images/demo14.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="自定义音色" src="docs/images/demo2.png" /></picture>
|
||||
<img alt="Eigene Stimmfarbe" src="../images/demo2.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="使用粤语交流" src="docs/images/demo3.png" /></picture>
|
||||
<img alt="Gespräch auf Kantonesisch" src="../images/demo3.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -167,8 +168,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)| 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) |
|
||||
| **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) |
|
||||
|
||||
Häufige Fragen und entsprechende Tutorials finden Sie unter [diesem Link](../FAQ.md)
|
||||
|
||||
@@ -209,15 +210,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` `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 |
|
||||
| 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) |
|
||||
|
||||
> 💡 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 |
|
||||
@@ -225,15 +226,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 LLM-Absichtserkennung, Function Call-Funktionsaufruf, bietet plugin-basierten Absichtsverarbeitungsmechanismus |
|
||||
| Absichtserkennung | Unterstützt externe LLM-Absichtserkennung, autonome Funktionsaufrufe durch große Modelle, 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-Endpunktprotokoll, benutzerdefinierte Werkzeugfunktionen |
|
||||
| Werkzeugaufruf | Unterstützt Client-IOT-Protokoll, Client-MCP-Protokoll, Server-MCP-Protokoll, MCP-Zugriffspunktprotokoll, 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 und englische Anzeige |
|
||||
| Testwerkzeuge | Bietet Leistungstestwerkzeuge, Vision-Modell-Testwerkzeuge und Audio-Interaktionstestwerkzeuge |
|
||||
| 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 |
|
||||
| Deployment-Unterstützung | Unterstützt Docker-Deployment und lokales Deployment, bietet vollständige Konfigurationsdateiverwaltung |
|
||||
| Plugin-System | Unterstützt funktionale Plugin-Erweiterungen, benutzerdefinierte Plugin-Entwicklung und Plugin-Hot-Loading |
|
||||
| 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 |
|
||||
|
||||
### In Entwicklung 🚧
|
||||
|
||||
@@ -253,7 +254,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, DeepSeek, Zhipu ChatGLM, Gemini | Zhipu ChatGLM, Gemini |
|
||||
| OpenAI-Schnittstellenaufrufe | Alibaba Bailian, Volcano Engine Doubao, Volcano Engine AI Gateway, DeepSeek, Zhipu ChatGLM, Gemini, iFLYTEK, LM Studio | Zhipu ChatGLM, Gemini |
|
||||
| Ollama-Schnittstellenaufrufe | Ollama | - |
|
||||
| Dify-Schnittstellenaufrufe | Dify | - |
|
||||
| FastGPT-Schnittstellenaufrufe | FastGPT | - |
|
||||
@@ -279,8 +280,8 @@ Tatsächlich kann jedes VLLM, das OpenAI-Schnittstellenaufrufe unterstützt, int
|
||||
|
||||
| Verwendungsmethode | Unterstützte Plattformen | Kostenlose Plattformen |
|
||||
|:---:|:---:|:---:|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -296,8 +297,8 @@ Tatsächlich kann jedes VLLM, das OpenAI-Schnittstellenaufrufe unterstützt, int
|
||||
|
||||
| Verwendungsmethode | Unterstützte Plattformen | Kostenlose Plattformen |
|
||||
|:---:|:---:|:---:|
|
||||
| Lokale Verwendung | FunASR, SherpaASR | FunASR, SherpaASR |
|
||||
| Schnittstellenaufrufe | DoubaoASR, Doubao Streaming ASR, FunASRServer, TencentASR, AliyunASR, Aliyun Streaming ASR, Baidu ASR, OpenAI ASR | FunASRServer |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -305,7 +306,9 @@ Tatsächlich kann jedes VLLM, das OpenAI-Schnittstellenaufrufe unterstützt, int
|
||||
|
||||
| Verwendungsmethode | Unterstützte Plattformen | Kostenlose Plattformen |
|
||||
|:---:|:---:|:---:|
|
||||
| Lokale Verwendung | 3D-Speaker | 3D-Speaker |
|
||||
| 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -351,11 +354,10 @@ Tatsächlich kann jedes VLLM, das OpenAI-Schnittstellenaufrufe unterstützt, int
|
||||
| <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://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
<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/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" />
|
||||
<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>
|
||||
+38
-36
@@ -22,6 +22,7 @@ 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>
|
||||
@@ -54,31 +55,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="响应速度感受" src="docs/images/demo9.png" /></picture>
|
||||
<img alt="The feel of the response speed" src="../images/demo9.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||
<picture>
|
||||
<img alt="速度优化秘诀" src="docs/images/demo6.png" /></picture>
|
||||
<img alt="The secret to speeding things up" src="../images/demo6.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1WEcxzFEAT" target="_blank">
|
||||
<picture>
|
||||
<img alt="小智数字人 支持语音唤醒" src="docs/images/demo8.png" /></picture>
|
||||
<img alt="Xiaozhi digital human with voice wake-up" src="../images/demo8.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CKVz6UEuB" target="_blank">
|
||||
<picture>
|
||||
<img alt="设备呼叫设备,打电话" src="docs/images/demo0.png" /></picture>
|
||||
<img alt="Device-to-device calling" src="../images/demo0.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||
<picture>
|
||||
<img alt="复杂医疗场景" src="docs/images/demo1.png" /></picture>
|
||||
<img alt="A complex medical scenario" src="../images/demo1.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -86,31 +87,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="播放音乐查询天气播报新闻" src="docs/images/demo7.png" /></picture>
|
||||
<img alt="Play music, check the weather, and read the news" src="../images/demo7.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12J7WzBEaH" target="_blank">
|
||||
<picture>
|
||||
<img alt="实时打断" src="docs/images/demo10.png" /></picture>
|
||||
<img alt="Real-time interruption" src="../images/demo10.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Co76z7EvK" target="_blank">
|
||||
<picture>
|
||||
<img alt="拍照识物品" src="docs/images/demo12.png" /></picture>
|
||||
<img alt="Take a photo to identify objects" src="../images/demo12.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
<img alt="控制家电开关" src="docs/images/demo5.png" /></picture>
|
||||
<img alt="Turn home appliances on and off" src="../images/demo5.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<picture>
|
||||
<img alt="多指令任务" src="docs/images/demo11.png" /></picture>
|
||||
<img alt="Multi-command tasks" src="../images/demo11.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -118,31 +119,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接入点" src="docs/images/demo13.png" /></picture>
|
||||
<img alt="MCP access point" src="../images/demo13.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
|
||||
<picture>
|
||||
<img alt="MQTT指令下发" src="docs/images/demo4.png" /></picture>
|
||||
<img alt="MQTT command delivery" src="../images/demo4.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<picture>
|
||||
<img alt="声纹识别" src="docs/images/demo14.png" /></picture>
|
||||
<img alt="Voiceprint recognition" src="../images/demo14.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="自定义音色" src="docs/images/demo2.png" /></picture>
|
||||
<img alt="Custom voice timbre" src="../images/demo2.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="使用粤语交流" src="docs/images/demo3.png" /></picture>
|
||||
<img alt="Talking in Cantonese" src="../images/demo3.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -167,8 +168,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)| 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) |
|
||||
| **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) |
|
||||
|
||||
For frequently asked questions and related tutorials, please refer to [this link](../FAQ.md)
|
||||
|
||||
@@ -209,15 +210,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 | 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) |
|
||||
| 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) |
|
||||
|
||||
> 💡 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 |
|
||||
@@ -225,15 +226,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 LLM intent recognition, Function Call function calling, provides plugin-based intent processing mechanism |
|
||||
| Intent Recognition | Supports pluggable external LLM intent recognition and autonomous LLM 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 endpoint protocol, custom tool functions |
|
||||
| Tool Calling | Supports client IOT protocol, client MCP protocol, server MCP protocol, MCP access point 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 and English display |
|
||||
| Testing Tools | Provides performance testing tools, vision model 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, English, German, Vietnamese and Brazilian Portuguese display |
|
||||
| Testing Tools | Provides model response performance 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, custom plugin development, and plugin hot-loading |
|
||||
| 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 |
|
||||
|
||||
### Under Development 🚧
|
||||
|
||||
@@ -253,7 +254,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 | Zhipu, Gemini |
|
||||
| OpenAI interface calls | Alibaba Bailian, Volcano Engine, DeepSeek, Zhipu, Gemini, iFLYTEK, LM Studio | Zhipu, Gemini |
|
||||
| Ollama interface calls | Ollama | - |
|
||||
| Dify interface calls | Dify | - |
|
||||
| FastGPT interface calls | FastGPT | - |
|
||||
@@ -279,8 +280,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, 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -296,8 +297,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 | FunASR, SherpaASR |
|
||||
| Interface calls | FunASRServer, Volcano Engine, iFLYTEK, Tencent Cloud, Alibaba Cloud, Baidu Cloud, OpenAI ASR | FunASRServer |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -305,7 +306,9 @@ In fact, any VLLM that supports OpenAI interface calls can be integrated and use
|
||||
|
||||
| Usage Method | Supported Platforms | Free Platforms |
|
||||
|:---:|:---:|:---:|
|
||||
| Local use | 3D-Speaker | 3D-Speaker |
|
||||
| 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -351,11 +354,10 @@ In fact, any VLLM that supports OpenAI interface calls can be integrated and use
|
||||
| <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://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
<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/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" />
|
||||
<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>
|
||||
@@ -0,0 +1,363 @@
|
||||
[](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>
|
||||
+39
-37
@@ -22,6 +22,7 @@ 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>
|
||||
@@ -54,31 +55,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="响应速度感受" src="docs/images/demo9.png" /></picture>
|
||||
<img alt="A sensação da velocidade de resposta" src="../images/demo9.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||
<picture>
|
||||
<img alt="速度优化秘诀" src="docs/images/demo6.png" /></picture>
|
||||
<img alt="O segredo para otimizar a velocidade" src="../images/demo6.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1WEcxzFEAT" target="_blank">
|
||||
<picture>
|
||||
<img alt="小智数字人 支持语音唤醒" src="docs/images/demo8.png" /></picture>
|
||||
<img alt="Humano digital Xiaozhi com ativação por voz" src="../images/demo8.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CKVz6UEuB" target="_blank">
|
||||
<picture>
|
||||
<img alt="设备呼叫设备,打电话" src="docs/images/demo0.png" /></picture>
|
||||
<img alt="Chamadas de dispositivo para dispositivo" src="../images/demo0.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||
<picture>
|
||||
<img alt="复杂医疗场景" src="docs/images/demo1.png" /></picture>
|
||||
<img alt="Cenário médico complexo" src="../images/demo1.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -86,31 +87,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="播放音乐查询天气播报新闻" src="docs/images/demo7.png" /></picture>
|
||||
<img alt="Reproduzir música, consultar o tempo e ler as notícias" src="../images/demo7.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12J7WzBEaH" target="_blank">
|
||||
<picture>
|
||||
<img alt="实时打断" src="docs/images/demo10.png" /></picture>
|
||||
<img alt="Interrupção em tempo real" src="../images/demo10.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Co76z7EvK" target="_blank">
|
||||
<picture>
|
||||
<img alt="拍照识物品" src="docs/images/demo12.png" /></picture>
|
||||
<img alt="Reconhecer objetos com uma foto" src="../images/demo12.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
<img alt="控制家电开关" src="docs/images/demo5.png" /></picture>
|
||||
<img alt="Controlar o acionamento de eletrodomésticos" src="../images/demo5.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<picture>
|
||||
<img alt="多指令任务" src="docs/images/demo11.png" /></picture>
|
||||
<img alt="Tarefas com vários comandos" src="../images/demo11.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -118,31 +119,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="MCP接入点" src="docs/images/demo13.png" /></picture>
|
||||
<img alt="Ponto de acesso MCP" src="../images/demo13.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
|
||||
<picture>
|
||||
<img alt="MQTT指令下发" src="docs/images/demo4.png" /></picture>
|
||||
<img alt="Envio de comandos MQTT" src="../images/demo4.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<picture>
|
||||
<img alt="声纹识别" src="docs/images/demo14.png" /></picture>
|
||||
<img alt="Reconhecimento de impressão vocal" src="../images/demo14.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="自定义音色" src="docs/images/demo2.png" /></picture>
|
||||
<img alt="Tom de voz personalizado" src="../images/demo2.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="使用粤语交流" src="docs/images/demo3.png" /></picture>
|
||||
<img alt="Conversa em cantonês" src="../images/demo3.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -167,8 +168,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)| 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) |
|
||||
| **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) |
|
||||
|
||||
Perguntas frequentes e tutoriais relacionados podem ser consultados [neste link](../FAQ.md)
|
||||
|
||||
@@ -209,15 +210,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 | 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) |
|
||||
| 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) |
|
||||
|
||||
> 💡 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 |
|
||||
@@ -225,15 +226,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, Function Call (chamada de função), fornece mecanismo de processamento de intenção baseado em plugins |
|
||||
| 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 |
|
||||
| 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 endpoint MCP, funções de ferramentas personalizadas |
|
||||
| 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 |
|
||||
| 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 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 |
|
||||
| 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 |
|
||||
| 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, desenvolvimento de plugins personalizados e carregamento dinâmico de plugins |
|
||||
| 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 |
|
||||
|
||||
### Em Desenvolvimento 🚧
|
||||
|
||||
@@ -253,7 +254,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 | Zhipu, Gemini |
|
||||
| Chamadas via interface OpenAI | Alibaba Bailian, Volcano Engine, DeepSeek, Zhipu, Gemini, iFLYTEK, LM Studio | Zhipu, Gemini |
|
||||
| Chamadas via interface Ollama | Ollama | - |
|
||||
| Chamadas via interface Dify | Dify | - |
|
||||
| Chamadas via interface FastGPT | FastGPT | - |
|
||||
@@ -261,7 +262,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -279,8 +280,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, 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -296,8 +297,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 | FunASR, SherpaASR |
|
||||
| Chamadas via interface | FunASRServer, Volcano Engine, iFLYTEK, Tencent Cloud, Alibaba Cloud, Baidu Cloud, OpenAI ASR | FunASRServer |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -305,7 +306,9 @@ Na verdade, qualquer VLLM que suporte chamadas via interface OpenAI pode ser int
|
||||
|
||||
| Método de Uso | Plataformas Suportadas | Plataformas Gratuitas |
|
||||
|:---:|:---:|:---:|
|
||||
| Uso local | 3D-Speaker | 3D-Speaker |
|
||||
| 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -351,11 +354,10 @@ Na verdade, qualquer VLLM que suporte chamadas via interface OpenAI pode ser int
|
||||
| <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://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
<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/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" />
|
||||
<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>
|
||||
+43
-41
@@ -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 giọng nói 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 vân giọng và kho tri thức
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -22,6 +22,7 @@ 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>
|
||||
@@ -38,7 +39,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="华南理工大学" width="50%">
|
||||
<img src="../images/hnlg.jpg" alt="Đại học Bách khoa Nam Trung Quốc" width="50%">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -54,31 +55,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="响应速度感受" src="docs/images/demo9.png" /></picture>
|
||||
<img alt="Cảm giác tốc độ phản hồi" src="../images/demo9.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||
<picture>
|
||||
<img alt="速度优化秘诀" src="docs/images/demo6.png" /></picture>
|
||||
<img alt="Bí quyết tối ưu tốc độ" src="../images/demo6.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1WEcxzFEAT" target="_blank">
|
||||
<picture>
|
||||
<img alt="小智数字人 支持语音唤醒" src="docs/images/demo8.png" /></picture>
|
||||
<img alt="Người ảo số Xiaozhi có đánh thức bằng giọng nói" src="../images/demo8.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CKVz6UEuB" target="_blank">
|
||||
<picture>
|
||||
<img alt="设备呼叫设备,打电话" src="docs/images/demo0.png" /></picture>
|
||||
<img alt="Gọi giữa các thiết bị" src="../images/demo0.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1C1tCzUEZh" target="_blank">
|
||||
<picture>
|
||||
<img alt="复杂医疗场景" src="docs/images/demo1.png" /></picture>
|
||||
<img alt="Tình huống y tế phức tạp" src="../images/demo1.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -86,31 +87,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="播放音乐查询天气播报新闻" src="docs/images/demo7.png" /></picture>
|
||||
<img alt="Phát nhạc, tra cứu thời tiết và đọc tin tức" src="../images/demo7.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12J7WzBEaH" target="_blank">
|
||||
<picture>
|
||||
<img alt="实时打断" src="docs/images/demo10.png" /></picture>
|
||||
<img alt="Ngắt giữa chừng theo thời gian thực" src="../images/demo10.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Co76z7EvK" target="_blank">
|
||||
<picture>
|
||||
<img alt="拍照识物品" src="docs/images/demo12.png" /></picture>
|
||||
<img alt="Nhận dạng vật thể qua ảnh chụp" src="../images/demo12.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
|
||||
<picture>
|
||||
<img alt="控制家电开关" src="docs/images/demo5.png" /></picture>
|
||||
<img alt="Bật tắt thiết bị gia dụng" src="../images/demo5.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1TJ7WzzEo6" target="_blank">
|
||||
<picture>
|
||||
<img alt="多指令任务" src="docs/images/demo11.png" /></picture>
|
||||
<img alt="Nhiệm vụ nhiều lệnh" src="../images/demo11.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -118,31 +119,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="MCP接入点" src="docs/images/demo13.png" /></picture>
|
||||
<img alt="Điểm truy cập MCP" src="../images/demo13.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1zUW5zJEkq" target="_blank">
|
||||
<picture>
|
||||
<img alt="MQTT指令下发" src="docs/images/demo4.png" /></picture>
|
||||
<img alt="Gửi lệnh MQTT" src="../images/demo4.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
|
||||
<picture>
|
||||
<img alt="声纹识别" src="docs/images/demo14.png" /></picture>
|
||||
<img alt="Nhận dạng vân giọng" src="../images/demo14.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
|
||||
<picture>
|
||||
<img alt="自定义音色" src="docs/images/demo2.png" /></picture>
|
||||
<img alt="Giọng nói tùy chỉnh" src="../images/demo2.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
|
||||
<picture>
|
||||
<img alt="使用粤语交流" src="docs/images/demo3.png" /></picture>
|
||||
<img alt="Trò chuyện bằng tiếng Quảng Đông" src="../images/demo3.png" /></picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -168,12 +169,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)| 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à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â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
|
||||
@@ -192,7 +193,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 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ừ 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ên module | Cài đặt miễn phí cho người mới | Cấu hình streaming |
|
||||
|:---:|:---:|:---:|
|
||||
@@ -210,15 +211,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 | 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) |
|
||||
| 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) |
|
||||
|
||||
> 💡 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 |
|
||||
@@ -231,10 +232,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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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, phát triển plugin tùy chỉnh và hot loading plugin |
|
||||
| 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ụ |
|
||||
|
||||
### Đang phát triển 🚧
|
||||
|
||||
@@ -254,7 +255,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 | Zhipu, Gemini |
|
||||
| Gọi giao diện OpenAI | Alibaba Bailian, Volcano Engine, DeepSeek, Zhipu, Gemini, iFLYTEK, LM Studio | Zhipu, Gemini |
|
||||
| Gọi giao diện ollama | Ollama | - |
|
||||
| Gọi giao diện dify | Dify | - |
|
||||
| Gọi giao diện fastgpt | Fastgpt | - |
|
||||
@@ -262,7 +263,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -270,9 +271,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -280,8 +281,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, 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -297,8 +298,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 | FunASR, SherpaASR |
|
||||
| Gọi giao diện | FunASRServer, Volcano Engine, iFlytek, Tencent Cloud, Alibaba Cloud, Baidu Cloud, OpenAI ASR | FunASRServer |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -306,7 +307,9 @@ 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ộ | 3D-Speaker | 3D-Speaker |
|
||||
| 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -352,11 +355,10 @@ Trên thực tế, bất kỳ VLLM nào hỗ trợ gọi giao diện openai đ
|
||||
| <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://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
|
||||
|
||||
<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/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" />
|
||||
<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>
|
||||
@@ -16,11 +16,11 @@
|
||||
点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`voiceprint-api-main`
|
||||
你需要把它重命名成`voiceprint-api`。
|
||||
|
||||
## 第二步, 创建数据库和表
|
||||
## 第二步, 确认 MySQL 能访问
|
||||
|
||||
声纹识别需要依赖`mysql`数据库。如果你之前已经部署`智控台`,说明你已经安装了`mysql`。你可以共用它。
|
||||
|
||||
你可以你试一下在宿主机使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
|
||||
你可以试一下在宿主机使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
|
||||
```
|
||||
telnet 127.0.0.1 3306
|
||||
```
|
||||
@@ -39,7 +39,7 @@ telnet 127.0.0.1 3306
|
||||
networks:
|
||||
- default
|
||||
expose:
|
||||
- "3306:3306"
|
||||
- 3306
|
||||
```
|
||||
|
||||
修改后
|
||||
@@ -58,8 +58,9 @@ 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.yml up -d
|
||||
docker compose -f docker-compose_all.yml up -d
|
||||
```
|
||||
数据库服务`xiaozhi-esp32-server-db`在`docker-compose_all.yml`里,最小化的`docker-compose.yml`只有服务本身,所以两条命令都用`docker-compose_all.yml`。
|
||||
|
||||
启动完后,在宿主机再使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
|
||||
```
|
||||
@@ -171,6 +172,8 @@ 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。
|
||||
|
||||
## 第二步 设置智能体记忆模式
|
||||
@@ -186,7 +189,7 @@ http://192.168.1.25:8005/voiceprint/health?key=abcd
|
||||
在智控台,`智能体管理`页面,在智能体的面板里,有一个`声纹识别`按钮,点击它。在底部有一个`新增按钮`。就可以对某个人说的话进行声纹注册。
|
||||
在弹出的框里,`描述`这个属性建议填写上,可以是这个人的职业、性格、爱好。方便智能体对说话人进行分析和了解。
|
||||
|
||||
## 第三步 和你的智能体聊天
|
||||
## 第五步 验证效果
|
||||
|
||||
将你的设备通电,问它,你知道我是谁吗?如果他能回答得出,说明声纹识别功能正常。
|
||||
|
||||
@@ -205,6 +208,9 @@ voiceprint:
|
||||
- "test1,张三,张三是一个程序员"
|
||||
- "test2,李四,李四是一个产品经理"
|
||||
- "test3,王五,王五是一个设计师"
|
||||
# 声纹识别相似度阈值,范围0.0-1.0,默认0.4
|
||||
# 数值越高越严格,减少误识别但可能增加拒识率
|
||||
similarity_threshold: 0.4
|
||||
```
|
||||
|
||||
把上一步得来的 `声纹接口地址` 粘贴到 `url` 里。然后保存。
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
## 功能简介
|
||||
|
||||
联网搜索插件 `web_search` 支持在对话过程中实时联网搜索信息并返回结果。插件支持两个搜索源:秘塔(Metaso)和Tavily,用户可根据需要选择其中一个。
|
||||
联网搜索插件 `web_search` 支持在对话过程中实时联网搜索信息并返回结果。插件支持三个搜索源:秘塔(Metaso)、Tavily和Serply,用户可根据需要选择其中一个。
|
||||
|
||||
## API Key申请指南
|
||||
|
||||
目前我们适配了`秘塔搜索`和`Tavily搜索`。
|
||||
目前我们适配了`秘塔搜索`、`Tavily搜索`和`Serply搜索`。
|
||||
- Tavily搜索:每个月1000次免费额度。
|
||||
- 秘塔搜索:拥有较为优质的国内数据源。
|
||||
- Serply搜索:返回谷歌搜索结果,注册赠送2500次免费额度,额度用完后需要付费。
|
||||
|
||||
## API Key申请指南
|
||||
|
||||
@@ -24,6 +25,13 @@
|
||||
- 在控制台中创建API Key
|
||||
- 复制生成的API Key(以 `tvly-` 为前缀),这是配置所需的关键信息
|
||||
|
||||
### 方式三:使用Serply搜索
|
||||
|
||||
- 访问 [Serply](https://serply.io),注册并登录账号
|
||||
- 在控制台中创建API Key
|
||||
- 复制生成的API Key,这是配置所需的关键信息
|
||||
- 接口说明见 [Serply文档](https://serply.io/docs)
|
||||
|
||||
## 配置方式
|
||||
|
||||
### 方式1. 使用智控台部署(推荐)
|
||||
@@ -32,14 +40,14 @@
|
||||
- 进入"配置角色"页面,选择要配置的智能体
|
||||
- 点击"编辑功能"按钮,在右侧参数配置区域找到"联网搜索"插件
|
||||
- 勾选"联网搜索"
|
||||
- 填入搜索源(`metaso`或`tavily`),并将对应的`API Key`填入配置项
|
||||
- 填入搜索源(`metaso`、`tavily`或`serply`),并将对应的`API Key`填入配置项
|
||||
- 保存配置,再保存智能体配置
|
||||
|
||||
### 方式2. 单模块xiaozhi-server部署
|
||||
|
||||
在 `data/.config.yaml` 中配置:
|
||||
|
||||
- 将搜索源填入 `provider`,可选值为 `metaso` 或 `tavily`
|
||||
- 将搜索源填入 `provider`,可选值为 `metaso`、`tavily` 或 `serply`
|
||||
- 将申请到的API Key填入 `api_key`
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
<junit.version>5.10.1</junit.version>
|
||||
<druid.version>1.2.20</druid.version>
|
||||
<mybatisplus.version>3.5.17</mybatisplus.version>
|
||||
<hutool.version>5.8.24</hutool.version>
|
||||
<jsoup.version>1.19.1</jsoup.version>
|
||||
<hutool.version>5.8.46</hutool.version>
|
||||
<jsoup.version>1.22.2</jsoup.version>
|
||||
<knife4j.version>4.6.0</knife4j.version>
|
||||
<springdoc.version>2.8.8</springdoc.version>
|
||||
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||
<commons-lang3.version>3.20.0</commons-lang3.version>
|
||||
<shiro.version>2.0.2</shiro.version>
|
||||
<captcha.version>1.6.2</captcha.version>
|
||||
<guava.version>33.0.0-jre</guava.version>
|
||||
<guava.version>33.6.0-jre</guava.version>
|
||||
<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>
|
||||
|
||||
@@ -324,7 +324,7 @@ public interface Constant {
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
public static final String VERSION = "0.9.6";
|
||||
public static final String VERSION = "0.9.8";
|
||||
|
||||
/**
|
||||
* 无效固件URL
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package xiaozhi.common.utils;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/**
|
||||
* 哈希加密算法的工具类
|
||||
* @author zjy
|
||||
*/
|
||||
@Slf4j
|
||||
public class HashEncryptionUtil {
|
||||
/**
|
||||
* 使用md5进行加密
|
||||
* @param context 被加密的内容
|
||||
* @return 哈希值
|
||||
*/
|
||||
public static String Md5hexDigest(String context){
|
||||
return hexDigest(context,"MD5");
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定哈希算法进行加密
|
||||
* @param context 被加密的内容
|
||||
* @param algorithm 哈希算法
|
||||
* @return 哈希值
|
||||
*/
|
||||
public static String hexDigest(String context,String algorithm ){
|
||||
// 获取MD5算法实例
|
||||
MessageDigest md = null;
|
||||
try {
|
||||
md = MessageDigest.getInstance(algorithm);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
log.error("加密失败的算法:{}",algorithm);
|
||||
throw new RuntimeException("加密失败,"+ algorithm +"哈希算法系统不支持");
|
||||
}
|
||||
// 计算智能体id的MD5值
|
||||
byte[] messageDigest = md.digest(context.getBytes());
|
||||
// 将字节数组转换为十六进制字符串
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : messageDigest) {
|
||||
String hex = Integer.toHexString(0xFF & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package xiaozhi.common.utils;
|
||||
|
||||
import cn.hutool.core.util.ReUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 通用工具类
|
||||
*/
|
||||
public class ToolUtil {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ToolUtil.class);
|
||||
|
||||
/**
|
||||
* 对象是否不为空(新增)
|
||||
*/
|
||||
public static boolean isNotEmpty(Object o) {
|
||||
return !isEmpty(o);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对象是否为空
|
||||
*/
|
||||
public static boolean isEmpty(Object o) {
|
||||
if (o == null) {
|
||||
return true;
|
||||
}
|
||||
if (o instanceof String) {
|
||||
if (o.toString().trim().equals("")) {
|
||||
return true;
|
||||
}
|
||||
} else if (o instanceof List) {
|
||||
if (((List) o).size() == 0) {
|
||||
return true;
|
||||
}
|
||||
} else if (o instanceof Map) {
|
||||
if (((Map) o).size() == 0) {
|
||||
return true;
|
||||
}
|
||||
} else if (o instanceof Set) {
|
||||
if (((Set) o).size() == 0) {
|
||||
return true;
|
||||
}
|
||||
} else if (o instanceof Object[]) {
|
||||
if (((Object[]) o).length == 0) {
|
||||
return true;
|
||||
}
|
||||
} else if (o instanceof int[]) {
|
||||
if (((int[]) o).length == 0) {
|
||||
return true;
|
||||
}
|
||||
} else if (o instanceof long[]) {
|
||||
if (((long[]) o).length == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对象组中是否存在空对象
|
||||
*/
|
||||
public static boolean isOneEmpty(Object... os) {
|
||||
for (Object o : os) {
|
||||
if (isEmpty(o)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对象组中是否全是空对象
|
||||
*/
|
||||
public static boolean isAllEmpty(Object... os) {
|
||||
for (Object o : os) {
|
||||
if (!isEmpty(o)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.Parameters;
|
||||
@@ -44,7 +42,6 @@ import xiaozhi.modules.agent.dto.AgentDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
||||
import xiaozhi.modules.agent.dto.AgentTagDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentTagEntity;
|
||||
import xiaozhi.modules.agent.service.AgentTagService;
|
||||
@@ -52,7 +49,6 @@ import xiaozhi.modules.agent.service.AgentChatAudioService;
|
||||
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||
import xiaozhi.modules.agent.service.AgentChatSummaryService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
|
||||
import xiaozhi.modules.agent.vo.AgentInfoVO;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
@@ -65,7 +61,6 @@ public class AgentController {
|
||||
private static final long AUDIO_PLAY_TOKEN_EXPIRE_SECONDS = 300L;
|
||||
|
||||
private final AgentService agentService;
|
||||
private final AgentTemplateService agentTemplateService;
|
||||
private final AgentChatHistoryService agentChatHistoryService;
|
||||
private final AgentChatAudioService agentChatAudioService;
|
||||
private final AgentChatSummaryService agentChatSummaryService;
|
||||
@@ -192,15 +187,6 @@ public class AgentController {
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
@GetMapping("/template")
|
||||
@Operation(summary = "智能体模板模板列表")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<List<AgentTemplateEntity>> templateList() {
|
||||
List<AgentTemplateEntity> list = agentTemplateService
|
||||
.list(new QueryWrapper<AgentTemplateEntity>().orderByAsc("sort"));
|
||||
return new Result<List<AgentTemplateEntity>>().ok(list);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/sessions")
|
||||
@Operation(summary = "获取智能体会话列表")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
|
||||
+11
@@ -41,6 +41,17 @@ public class AgentTemplateController {
|
||||
|
||||
private final AgentTemplateService agentTemplateService;
|
||||
|
||||
// 单独用 normal 权限(而非类级 superAdmin)是有意保留:
|
||||
// 普通用户也需查询可用模板,与 /page 的管理入口分离,保持原行为契约。
|
||||
@GetMapping
|
||||
@Operation(summary = "智能体模板列表")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<List<AgentTemplateEntity>> getAgentTemplates() {
|
||||
List<AgentTemplateEntity> list = agentTemplateService
|
||||
.list(new QueryWrapper<AgentTemplateEntity>().orderByAsc("sort"));
|
||||
return new Result<List<AgentTemplateEntity>>().ok(list);
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "获取模板分页列表")
|
||||
@RequiresPermissions("sys:role:superAdmin")
|
||||
|
||||
+2
-2
@@ -5,6 +5,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -20,7 +21,6 @@ import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.common.utils.ToolUtil;
|
||||
import xiaozhi.modules.agent.Enums.AgentChatHistoryType;
|
||||
import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao;
|
||||
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
||||
@@ -107,7 +107,7 @@ public class AgentChatHistoryServiceImpl extends CrudRepository<AiAgentChatHisto
|
||||
if (deleteAudio) {
|
||||
// 分批删除音频,避免超时
|
||||
List<String> audioIds = baseMapper.getAudioIdsByAgentId(agentId);
|
||||
if (ToolUtil.isNotEmpty(audioIds)) {
|
||||
if (CollUtil.isNotEmpty(audioIds)) {
|
||||
// 每批删除1000条
|
||||
List<List<String>> batch = ListUtil.split(audioIds, 1000);
|
||||
batch.forEach(dataList -> {
|
||||
|
||||
+2
-2
@@ -12,11 +12,11 @@ import java.util.stream.Collectors;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.utils.AESUtils;
|
||||
import xiaozhi.common.utils.HashEncryptionUtil;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.modules.agent.Enums.XiaoZhiMcpJsonRpcJson;
|
||||
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
|
||||
@@ -226,7 +226,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
||||
*/
|
||||
private static String encryptToken(String agentId, String key) {
|
||||
// 使用md5对智能体id进行加密
|
||||
String md5 = HashEncryptionUtil.Md5hexDigest(agentId);
|
||||
String md5 = DigestUtil.md5Hex(agentId);
|
||||
// aes需要加密文本
|
||||
String json = "{\"agentId\": \"%s\"}".formatted(md5);
|
||||
// 加密后成token值
|
||||
|
||||
+4
-4
@@ -18,6 +18,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.repository.IRepository;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
@@ -29,7 +30,6 @@ import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.common.utils.ToolUtil;
|
||||
import xiaozhi.modules.agent.dao.AgentDao;
|
||||
import xiaozhi.modules.agent.dao.AgentTagDao;
|
||||
import xiaozhi.modules.agent.dto.AgentCreateDTO;
|
||||
@@ -243,13 +243,13 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
.map(DeviceEntity::getAgentId)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (ToolUtil.isNotEmpty(agentIds)) {
|
||||
if (CollUtil.isNotEmpty(agentIds)) {
|
||||
w.or().in("id", agentIds);
|
||||
}
|
||||
|
||||
// 按标签名搜索
|
||||
List<String> tagAgentIds = agentTagService.getAgentIdsByTagName(keyword);
|
||||
if (ToolUtil.isNotEmpty(tagAgentIds)) {
|
||||
if (CollUtil.isNotEmpty(tagAgentIds)) {
|
||||
w.or().in("id", tagAgentIds);
|
||||
}
|
||||
});
|
||||
@@ -291,7 +291,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
|
||||
// 获取标签列表
|
||||
List<AgentTagEntity> tags = agentTagDao.selectByAgentId(agent.getId());
|
||||
if (ToolUtil.isNotEmpty(tags)) {
|
||||
if (CollUtil.isNotEmpty(tags)) {
|
||||
dto.setTags(tags.stream().map(this::convertTagToDTO).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -174,6 +174,9 @@ public class DeviceController {
|
||||
@Operation(summary = "获取设备通讯录")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Object> getAddressBook(@PathVariable String macAddress) {
|
||||
if (deviceService.getOwnedDeviceByMacAddress(macAddress) == null) {
|
||||
return new Result<Object>().error(ErrorCode.DEVICE_NOT_EXIST);
|
||||
}
|
||||
return new Result<Object>().ok(deviceAddressBookService.getAddressBookList(macAddress));
|
||||
}
|
||||
|
||||
@@ -192,9 +195,7 @@ public class DeviceController {
|
||||
@Operation(summary = "更新设备通讯录别名")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> updateAlias(@Valid @RequestBody DeviceAddressBookAliasDTO dto) {
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(dto.getMacAddress());
|
||||
if (callerDevice == null || !callerDevice.getUserId().equals(user.getId())) {
|
||||
if (deviceService.getOwnedDeviceByMacAddress(dto.getMacAddress()) == null) {
|
||||
return new Result<Void>().error("无权限操作该设备");
|
||||
}
|
||||
deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), dto.getAlias(), null);
|
||||
@@ -205,9 +206,7 @@ public class DeviceController {
|
||||
@Operation(summary = "更新设备通讯录权限")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> updatePermission(@Valid @RequestBody DeviceAddressBookPermissionDTO dto) {
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(dto.getMacAddress());
|
||||
if (callerDevice == null || !callerDevice.getUserId().equals(user.getId())) {
|
||||
if (deviceService.getOwnedDeviceByMacAddress(dto.getMacAddress()) == null) {
|
||||
return new Result<Void>().error("无权限操作该设备");
|
||||
}
|
||||
deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), null, dto.getHasPermission());
|
||||
|
||||
+4
-11
@@ -5,8 +5,6 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -31,6 +29,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.Parameters;
|
||||
@@ -288,7 +287,7 @@ public class OTAMagController {
|
||||
|
||||
// 返回文件路径
|
||||
return new Result<String>().ok(filePath.toString());
|
||||
} catch (IOException | NoSuchAlgorithmException e) {
|
||||
} catch (IOException e) {
|
||||
return new Result<String>().error("文件上传失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -329,13 +328,7 @@ public class OTAMagController {
|
||||
return result;
|
||||
}
|
||||
|
||||
private String calculateMD5(MultipartFile file) throws IOException, NoSuchAlgorithmException {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(file.getBytes());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : digest) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
private String calculateMD5(MultipartFile file) throws IOException {
|
||||
return DigestUtil.md5Hex(file.getBytes());
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,16 @@ public interface DeviceService extends BaseService<DeviceEntity> {
|
||||
*/
|
||||
DeviceEntity getDeviceByMacAddress(String macAddress);
|
||||
|
||||
/**
|
||||
* 根据MAC地址获取当前登录用户名下的设备信息
|
||||
* <p>
|
||||
* 设备不存在或不属于当前用户时返回null,调用方无需再自行比对归属。
|
||||
*
|
||||
* @param macAddress MAC地址
|
||||
* @return 当前用户名下的设备信息,不存在或无权限时返回null
|
||||
*/
|
||||
DeviceEntity getOwnedDeviceByMacAddress(String macAddress);
|
||||
|
||||
/**
|
||||
* 根据设备ID获取激活码
|
||||
*
|
||||
|
||||
+124
-25
@@ -1,9 +1,13 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -18,6 +22,7 @@ import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
|
||||
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceAddressBookService;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
@@ -40,7 +45,11 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
|
||||
@Override
|
||||
public List<DeviceAddressBookEntity> getAddressBookList(String macAddress) {
|
||||
return deviceAddressBookDao.getAddressBookList(macAddress);
|
||||
List<DeviceAddressBookEntity> rows = assignUniqueAliases(deviceAddressBookDao.getAddressBookList(macAddress));
|
||||
// 取名按固定顺序算完了,前端要看的是最近改过的排前面,放回去
|
||||
rows.sort(Comparator
|
||||
.comparing(DeviceAddressBookEntity::getUpdateDate, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -63,7 +72,7 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
}
|
||||
|
||||
// 主动呼叫模式
|
||||
Map<String, String> callerBook = allBooks.get(callerMac.toLowerCase());
|
||||
Map<String, String> callerBook = allBooks.get(bookKey(callerMac));
|
||||
if (callerBook == null) {
|
||||
return errorResult("未找到备注为'" + nickname + "'的设备");
|
||||
}
|
||||
@@ -80,13 +89,20 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
}
|
||||
|
||||
// 获取目标设备如何称呼主叫方
|
||||
Map<String, String> targetBook = allBooks.get(targetMac.toLowerCase());
|
||||
Map<String, String> targetBook = allBooks.get(bookKey(targetMac));
|
||||
String callerNickname = null;
|
||||
if (targetBook != null) {
|
||||
callerNickname = targetBook.get(callerMac.toLowerCase());
|
||||
callerNickname = targetBook.get(bookKey(callerMac));
|
||||
}
|
||||
if (StringUtils.isBlank(callerNickname)) {
|
||||
callerNickname = deviceService.getDeviceByMacAddress(callerMac).getAlias();
|
||||
// 主叫显示名取的是主叫方自己那台设备在设备表里的名字,不是对方的。
|
||||
// 对方那边给主叫方起的称呼在上面的 targetBook 里已经查过。
|
||||
// 产品上按「来电显示」的口径处理,所以这里不套通讯录那套「不抄对方私产」的限制
|
||||
// 这个接口没有登录用户上下文,归属校验那个查法在这里会返回空,只能用不校验归属的查法
|
||||
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(callerMac);
|
||||
if (callerDevice != null) {
|
||||
callerNickname = callerDevice.getAlias();
|
||||
}
|
||||
if (StringUtils.isBlank(callerNickname)) {
|
||||
callerNickname = formatMacAsDeviceName(callerMac);
|
||||
}
|
||||
@@ -100,32 +116,26 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
@Override
|
||||
public void refreshCache() {
|
||||
Map<String, Map<String, String>> result = new HashMap<>();
|
||||
List<DeviceAddressBookEntity> allRecords = deviceAddressBookDao.selectList(null);
|
||||
List<DeviceAddressBookEntity> allRecords = assignUniqueAliases(deviceAddressBookDao.selectList(null));
|
||||
Map<String, String> reverseMap = new HashMap<>();
|
||||
|
||||
for (DeviceAddressBookEntity entity : allRecords) {
|
||||
String macA = entity.getMacAddress().toLowerCase();
|
||||
String macB = entity.getTargetMac().toLowerCase();
|
||||
String alias = entity.getAlias();
|
||||
Boolean hasPermission = entity.getHasPermission();
|
||||
String macA = bookKey(entity.getMacAddress());
|
||||
String macB = bookKey(entity.getTargetMac());
|
||||
|
||||
// 构建正向映射: A对B的映射 nickname -> macB|permission
|
||||
if (alias != null && !alias.isEmpty()) {
|
||||
result.computeIfAbsent(macA, k -> new HashMap<>());
|
||||
String permStr = (hasPermission != null && hasPermission) ? "1" : "0";
|
||||
result.get(macA).put(alias, macB + "|" + permStr);
|
||||
}
|
||||
Map<String, String> book = result.computeIfAbsent(macA, k -> new HashMap<>());
|
||||
String permStr = (Boolean.TRUE.equals(entity.getHasPermission())) ? "1" : "0";
|
||||
book.put(entity.getAlias(), macB + "|" + permStr);
|
||||
|
||||
// 构建反向记录用于查找B对A的称呼
|
||||
if (alias != null && !alias.isEmpty()) {
|
||||
reverseMap.put(macB + ":" + macA, alias);
|
||||
}
|
||||
// 构建反向记录用于查找B对A的称呼,用同一个去重后的名字,两边才对得上
|
||||
reverseMap.put(macB + ":" + macA, entity.getAlias());
|
||||
}
|
||||
|
||||
// 构建反向映射: B对A的映射 macA -> nickname
|
||||
for (DeviceAddressBookEntity entity : allRecords) {
|
||||
String macA = entity.getMacAddress().toLowerCase();
|
||||
String macB = entity.getTargetMac().toLowerCase();
|
||||
String macA = bookKey(entity.getMacAddress());
|
||||
String macB = bookKey(entity.getTargetMac());
|
||||
String aliasBtoA = reverseMap.get(macA + ":" + macB);
|
||||
if (aliasBtoA != null && !aliasBtoA.isEmpty()) {
|
||||
result.computeIfAbsent(macB, k -> new HashMap<>());
|
||||
@@ -136,6 +146,54 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
redisUtils.set(RedisKeys.getAddressBookKey(), result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给每行算出一个不重名的称呼,返回排好序、alias 已写好的新列表
|
||||
*
|
||||
* 昵称在同一个人的通讯录里是索引,同名后写的会盖掉先写的,按昵称呼会呼错设备。
|
||||
* 库里本来就有重名:空别名补出来的尾号称呼只有 256 种取值,一本通讯录十几条
|
||||
* 没起名就撞上了。所以显示和呼叫都得走这一份,
|
||||
* 不然前端看到的字和呼叫时用的名字对不上,用户照着点的名字呼不到人。
|
||||
* 不改传入的那个列表:它是 DAO 刚查出来的,调用方还要按自己的顺序用
|
||||
*/
|
||||
private List<DeviceAddressBookEntity> assignUniqueAliases(List<DeviceAddressBookEntity> rows) {
|
||||
// 取名要靠先后定谁拿原名,而 selectList 不带 order by,返回顺序不保证。
|
||||
// 排一次,同样的数据每次算出来的结果一样,缓存不会来回变。
|
||||
// 按归一后的地址排:同一个人不管库里是哪种写法都排到一起,
|
||||
// 后面两级比较器再分先后。同一本里 target_mac 不会重复(主键摆着),
|
||||
// 所以顺序还是由这三个键定死的,不会掉回"跟着数据库返回顺序走"
|
||||
List<DeviceAddressBookEntity> ordered = new ArrayList<>(rows);
|
||||
ordered.sort(Comparator
|
||||
.comparing((DeviceAddressBookEntity row) -> bookKey(row.getMacAddress()),
|
||||
Comparator.nullsFirst(Comparator.naturalOrder()))
|
||||
// 权限排在地址前面:撞名时让对方准许呼我的那台先拿原名。
|
||||
// 排在后面等于不起作用,两条地址不同时谁先谁后是地址定的,
|
||||
// 没权限那条照样占住原名,用户照前端看到的名字呼叫命中的就是它,
|
||||
// 界面上只会说没权限,怎么点都不通
|
||||
.thenComparing(DeviceAddressBookEntity::getHasPermission,
|
||||
Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(DeviceAddressBookEntity::getTargetMac, Comparator.nullsFirst(Comparator.naturalOrder())));
|
||||
|
||||
Map<String, Set<String>> takenByOwner = new HashMap<>();
|
||||
for (DeviceAddressBookEntity row : ordered) {
|
||||
// 建表时 alias 默认是空的,用户没起名就是空的,这是默认状态不是异常。
|
||||
// 补个尾号称呼顶上,整条跳过的话接口照样返回成功,对方却再也呼不过来
|
||||
String alias = StringUtils.isBlank(row.getAlias())
|
||||
? formatMacAsDeviceName(row.getTargetMac())
|
||||
: row.getAlias();
|
||||
|
||||
// 加后缀的写法跟 generateUniqueAlias 保持一致,不搞两套命名规则
|
||||
Set<String> taken = takenByOwner.computeIfAbsent(bookKey(row.getMacAddress()), k -> new HashSet<>());
|
||||
String uniqueAlias = alias;
|
||||
int suffix = 1;
|
||||
while (taken.contains(uniqueAlias)) {
|
||||
uniqueAlias = alias + suffix++;
|
||||
}
|
||||
taken.add(uniqueAlias);
|
||||
row.setAlias(uniqueAlias);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateAlias(String macAddress, String targetMac, String alias) {
|
||||
String finalAlias = generateUniqueAlias(macAddress, targetMac, alias);
|
||||
@@ -154,14 +212,29 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
QueryWrapper<DeviceAddressBookEntity> wrapper = new QueryWrapper<>();
|
||||
wrapper.eq("mac_address", macAddress).eq("target_mac", targetMac);
|
||||
DeviceAddressBookEntity record = deviceAddressBookDao.selectOne(wrapper);
|
||||
System.out.println("saveOrUpdate - mac=" + macAddress + ", targetMac=" + targetMac + ", alias=" + alias + ", record=" + (record == null ? "null" : "not null"));
|
||||
if (record == null) {
|
||||
DeviceAddressBookEntity entity = new DeviceAddressBookEntity();
|
||||
entity.setMacAddress(macAddress);
|
||||
entity.setTargetMac(targetMac);
|
||||
// 如果 alias 为空,就默认使用设备名称
|
||||
// 调用方没起名就退回尾号称呼。对方设备在智控台上起的名字是对方的私产,
|
||||
// 抄进调用方的通讯录等于把别人的昵称漏出去,所以别人的设备一律不去设备表取名
|
||||
if (StringUtils.isBlank(alias)) {
|
||||
alias = deviceService.getDeviceByMacAddress(targetMac).getAlias();
|
||||
// 只有给自己那台设备起名时才用设备表里的名字,那本来就是调用方自己的
|
||||
if (isOwnDevice(macAddress, targetMac)) {
|
||||
// 用带归属校验的那个查法。设备表里同一个 MAC 可能有两行只差大小写,
|
||||
// 一次只准取一条的那种查法撞上这种数据会抛 TooManyResultsException,
|
||||
// 整个保存接口带崩;按 id 取第一条,撞行也不报错
|
||||
DeviceEntity ownDevice = deviceService.getOwnedDeviceByMacAddress(macAddress);
|
||||
if (ownDevice != null && StringUtils.isNotBlank(ownDevice.getAlias())) {
|
||||
alias = ownDevice.getAlias();
|
||||
}
|
||||
}
|
||||
// 设备登记了但用户没起过名,alias 建表就是 null,这是默认状态
|
||||
// 不是异常。这时候落个 null 进去,缓存刷新会把整条跳过,
|
||||
// 接口照样返回成功,对方却永远呼不过来
|
||||
if (StringUtils.isBlank(alias)) {
|
||||
alias = formatMacAsDeviceName(targetMac);
|
||||
}
|
||||
}
|
||||
// 检查重名
|
||||
alias = generateUniqueAlias(macAddress, targetMac, alias);
|
||||
@@ -229,11 +302,37 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通讯录里当主人用的地址键
|
||||
*
|
||||
* <p>
|
||||
* 写缓存、查缓存、算称呼都靠它分组,三处必须是同一个值。少归一一次,同一个人的两行
|
||||
* 会被当成两个人:各自拿到同名,写进同一本时后者盖掉前者,一台设备直接从缓存里消失。
|
||||
* 库里确实能出现这种数据——表是 utf8mb4_0900_ai_ci,主键 (mac_address, target_mac)
|
||||
* 大小写不敏感,owner 只差大小写而 target 不同的话两行不撞
|
||||
*/
|
||||
private static String bookKey(String macAddress) {
|
||||
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;
|
||||
}
|
||||
String lastTwo = mac.substring(mac.length() - 2);
|
||||
// MAC 本身不区分大小写,落库、查库走的都是小写化的值。
|
||||
// 这里跟着小写化,不跟着的话同一个地址在两条路径上会算出两个称呼,
|
||||
// 数据搬到智控台上看到的名字就叫不到人了
|
||||
String lastTwo = mac.substring(mac.length() - 2).toLowerCase();
|
||||
return "尾号为" + lastTwo + "的设备";
|
||||
}
|
||||
|
||||
|
||||
+35
-6
@@ -31,6 +31,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
@@ -51,7 +52,6 @@ import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.DateUtils;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.common.utils.ToolUtil;
|
||||
import xiaozhi.modules.device.dao.DeviceDao;
|
||||
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
|
||||
import xiaozhi.modules.device.dto.DevicePageUserDTO;
|
||||
@@ -103,15 +103,15 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
throw new RenException(ErrorCode.ACTIVATION_CODE_EMPTY);
|
||||
}
|
||||
String deviceKey = RedisKeys.getOtaActivationCode(activationCode);
|
||||
Object cacheDeviceId = redisUtils.get(deviceKey);
|
||||
if (ToolUtil.isEmpty(cacheDeviceId)) {
|
||||
String cacheDeviceId = (String) redisUtils.get(deviceKey);
|
||||
if (StringUtils.isBlank(cacheDeviceId)) {
|
||||
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
||||
}
|
||||
String deviceId = (String) cacheDeviceId;
|
||||
String deviceId = cacheDeviceId;
|
||||
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
|
||||
String cacheDeviceKey = RedisKeys.getOtaDeviceActivationInfo(safeDeviceId);
|
||||
Map<String, Object> cacheMap = JsonUtils.toStringObjectMap(redisUtils.get(cacheDeviceKey));
|
||||
if (ToolUtil.isEmpty(cacheMap)) {
|
||||
if (MapUtil.isEmpty(cacheMap)) {
|
||||
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
||||
}
|
||||
String cachedCode = (String) cacheMap.get("activation_code");
|
||||
@@ -181,7 +181,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.builder(new HashMap<String, Set<String>>())
|
||||
.put("clientIds", deviceIds).build();
|
||||
|
||||
if (ToolUtil.isNotEmpty(deviceIds)) {
|
||||
if (CollUtil.isNotEmpty(deviceIds)) {
|
||||
return postToMqttGateway(url, params);
|
||||
}
|
||||
// 返回响应
|
||||
@@ -316,6 +316,12 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
if (device == null) {
|
||||
return;
|
||||
}
|
||||
// 后面删通讯录那步是按 mac_address 删的,不看 user_id。
|
||||
// 设备本身因为带了 user_id 条件删不掉,通讯录却会照删不误,
|
||||
// 任意登录用户知道对方的设备 id 就能把对方整本通讯录清空
|
||||
if (userId == null || device.getUserId() == null || !device.getUserId().equals(userId)) {
|
||||
return;
|
||||
}
|
||||
String macAddress = device.getMacAddress();
|
||||
if (StringUtils.isNotBlank(device.getAgentId())) {
|
||||
// 清除智能体设备数量缓存
|
||||
@@ -400,6 +406,29 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
return baseDao.selectOne(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DeviceEntity getOwnedDeviceByMacAddress(String macAddress) {
|
||||
if (StringUtils.isBlank(macAddress)) {
|
||||
return null;
|
||||
}
|
||||
// 同一 MAC 可能有两行只差大小写,selectOne 会抛 TooManyResultsException
|
||||
// 把整个读通讯录接口带崩。按 id 取第一条做归属校验,撞行不报错
|
||||
QueryWrapper<DeviceEntity> wrapper = new QueryWrapper<>();
|
||||
wrapper.eq("mac_address", macAddress).orderByAsc("id");
|
||||
List<DeviceEntity> devices = baseDao.selectList(wrapper);
|
||||
if (devices == null || devices.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
DeviceEntity device = devices.get(0);
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
// getUser() 在取不到 subject、principal 时返回的是空对象,不是 null,
|
||||
// 所以这里不会拿到 null。匿名调用走的就是空对象那一条,id 是空的,比不上
|
||||
if (device.getUserId() == null || !device.getUserId().equals(user.getId())) {
|
||||
return null;
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
private DeviceReportRespDTO.ServerTime buildServerTime() {
|
||||
DeviceReportRespDTO.ServerTime serverTime = new DeviceReportRespDTO.ServerTime();
|
||||
TimeZone tz = TimeZone.getDefault();
|
||||
|
||||
+3
-3
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -23,7 +24,6 @@ import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.common.utils.ToolUtil;
|
||||
import xiaozhi.modules.knowledge.dto.KnowledgeBaseDTO;
|
||||
import xiaozhi.modules.knowledge.service.KnowledgeBaseService;
|
||||
import xiaozhi.modules.knowledge.service.KnowledgeManagerService;
|
||||
@@ -140,7 +140,7 @@ public class KnowledgeBaseController {
|
||||
List<String> idList = Arrays.asList(ids.split(","));
|
||||
List<KnowledgeBaseDTO> knowledgeBaseDTOs = Optional.ofNullable(knowledgeBaseService.getByDatasetIdList(idList))
|
||||
.orElseGet(ArrayList::new);
|
||||
if (ToolUtil.isNotEmpty(knowledgeBaseDTOs)) {
|
||||
if (CollUtil.isNotEmpty(knowledgeBaseDTOs)) {
|
||||
knowledgeBaseDTOs.forEach(item -> {
|
||||
// 检查权限:用户只能删除自己创建的知识库
|
||||
if (item.getCreator() == null || !item.getCreator().equals(currentUserId)) {
|
||||
@@ -160,4 +160,4 @@ public class KnowledgeBaseController {
|
||||
List<ModelConfigEntity> result = knowledgeBaseService.getRAGModels();
|
||||
return new Result<List<ModelConfigEntity>>().ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -36,6 +36,7 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
|
||||
static {
|
||||
THINKING_DISABLED_DOMAINS.put("aliyuncs.com", Map.of("enable_thinking", false));
|
||||
Map<String, Object> thinkingDisabled = Map.of("thinking", Map.of("type", "disabled"));
|
||||
THINKING_DISABLED_DOMAINS.put("deepseek.com", thinkingDisabled);
|
||||
THINKING_DISABLED_DOMAINS.put("bigmodel.cn", thinkingDisabled);
|
||||
THINKING_DISABLED_DOMAINS.put("moonshot.cn", thinkingDisabled);
|
||||
THINKING_DISABLED_DOMAINS.put("volces.com", thinkingDisabled);
|
||||
|
||||
+8
-7
@@ -110,8 +110,8 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
// 1. 参数验证
|
||||
validateEditParameters(modelType, provideCode, id, modelConfigBodyDTO);
|
||||
|
||||
// 2. 验证模型提供者
|
||||
validateModelProvider(modelType, provideCode);
|
||||
// 2. 验证模型提供者,使用注册类型的大小写
|
||||
String registeredModelType = resolveModelType(modelType, provideCode);
|
||||
|
||||
// 3. 获取原始配置(不经过敏感数据处理)
|
||||
ModelConfigEntity originalEntity = getOriginalConfigFromDb(id);
|
||||
@@ -120,7 +120,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
validateLlmConfiguration(modelConfigBodyDTO);
|
||||
|
||||
// 5. 准备更新实体并处理敏感数据
|
||||
ModelConfigEntity modelConfigEntity = prepareUpdateEntity(modelConfigBodyDTO, originalEntity, modelType, id);
|
||||
ModelConfigEntity modelConfigEntity = prepareUpdateEntity(modelConfigBodyDTO, originalEntity, registeredModelType, id);
|
||||
|
||||
// 6. 执行数据库更新
|
||||
modelConfigDao.updateById(modelConfigEntity);
|
||||
@@ -136,9 +136,9 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
public ModelConfigDTO add(String modelType, String provideCode, ModelConfigBodyDTO modelConfigBodyDTO) {
|
||||
validateAddParameters(modelType, provideCode, modelConfigBodyDTO);
|
||||
|
||||
validateModelProvider(modelType, provideCode);
|
||||
String registeredModelType = resolveModelType(modelType, provideCode);
|
||||
|
||||
ModelConfigEntity modelConfigEntity = prepareAddEntity(modelConfigBodyDTO, modelType);
|
||||
ModelConfigEntity modelConfigEntity = prepareAddEntity(modelConfigBodyDTO, registeredModelType);
|
||||
|
||||
modelConfigDao.insert(modelConfigEntity);
|
||||
|
||||
@@ -283,13 +283,14 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模型提供者
|
||||
* 验证模型提供者并获取注册类型,保留 TTS、Memory、Intent 等类型的大小写
|
||||
*/
|
||||
private void validateModelProvider(String modelType, String provideCode) {
|
||||
private String resolveModelType(String modelType, String provideCode) {
|
||||
List<ModelProviderDTO> providerList = modelProviderService.getList(modelType, provideCode);
|
||||
if (CollectionUtil.isEmpty(providerList)) {
|
||||
throw new RenException(ErrorCode.MODEL_PROVIDER_NOT_EXIST);
|
||||
}
|
||||
return providerList.get(0).getModelType();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,6 +86,7 @@ public class ShiroConfig {
|
||||
filterMap.put("/user/captcha", "anon");
|
||||
filterMap.put("/user/smsVerification", "anon");
|
||||
filterMap.put("/user/login", "anon");
|
||||
filterMap.put("/user/refresh", "anon");
|
||||
filterMap.put("/user/pub-config", "anon");
|
||||
filterMap.put("/user/register", "anon");
|
||||
filterMap.put("/user/retrieve-password", "anon");
|
||||
|
||||
+12
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -110,6 +111,17 @@ public class LoginController {
|
||||
return sysUserTokenService.createToken(userDTO.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
@Operation(summary = "刷新token:用旧token换新token")
|
||||
public Result<TokenDTO> refresh(HttpServletRequest request) {
|
||||
String authorization = request.getHeader(Constant.AUTHORIZATION);
|
||||
if (StringUtils.isBlank(authorization) || !authorization.startsWith("Bearer ")) {
|
||||
throw new RenException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
String oldToken = authorization.replace("Bearer ", "");
|
||||
return sysUserTokenService.refreshToken(oldToken);
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "注册")
|
||||
public Result<Void> register(@RequestBody LoginDTO login) {
|
||||
|
||||
@@ -21,4 +21,14 @@ public interface SysUserTokenDao extends BaseDao<SysUserTokenEntity> {
|
||||
SysUserTokenEntity getByUserId(Long userId);
|
||||
|
||||
void logout(@Param("userId") Long userId, @Param("expireDate") Date expireDate);
|
||||
|
||||
/**
|
||||
* 用旧 token 换新 token 的 CAS 更新:仅当旧 token 未过期且属于指定 user 时才覆盖。
|
||||
* 返回受影响行数(0 表示条件不满足 = token 已被并发刷新或过期)。
|
||||
*/
|
||||
int refreshTokenCAS(@Param("userId") Long userId,
|
||||
@Param("oldToken") String oldToken,
|
||||
@Param("newToken") String newToken,
|
||||
@Param("newExpire") Date newExpire,
|
||||
@Param("updateDate") Date updateDate);
|
||||
}
|
||||
+7
@@ -21,6 +21,13 @@ public interface SysUserTokenService extends BaseService<SysUserTokenEntity> {
|
||||
*/
|
||||
Result<TokenDTO> createToken(Long userId);
|
||||
|
||||
/**
|
||||
* 用旧token换新token,旧token立即失效
|
||||
*
|
||||
* @param oldToken 旧token字符串
|
||||
*/
|
||||
Result<TokenDTO> refreshToken(String oldToken);
|
||||
|
||||
SysUserDTO getUserByToken(String token);
|
||||
|
||||
/**
|
||||
|
||||
+5
-21
@@ -1,8 +1,7 @@
|
||||
package xiaozhi.modules.security.service.impl;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.time.Duration;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -13,6 +12,7 @@ import com.google.common.cache.CacheBuilder;
|
||||
import com.wf.captcha.SpecCaptcha;
|
||||
import com.wf.captcha.base.Captcha;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
@@ -41,7 +41,7 @@ public class CaptchaServiceImpl implements CaptchaService {
|
||||
* Local Cache 5分钟过期
|
||||
*/
|
||||
Cache<String, String> localCache = CacheBuilder.newBuilder().maximumSize(1000)
|
||||
.expireAfterAccess(5, TimeUnit.MINUTES).build();
|
||||
.expireAfterAccess(Duration.ofMinutes(5)).build();
|
||||
|
||||
@Override
|
||||
public void create(HttpServletResponse response, String uuid) throws IOException {
|
||||
@@ -113,7 +113,7 @@ public class CaptchaServiceImpl implements CaptchaService {
|
||||
}
|
||||
|
||||
String key = RedisKeys.getSMSValidateCodeKey(phone);
|
||||
String validateCodes = generateValidateCode(6);
|
||||
String validateCodes = RandomUtil.randomNumbers(6);
|
||||
|
||||
// 设置验证码
|
||||
setCache(key, validateCodes);
|
||||
@@ -135,22 +135,6 @@ public class CaptchaServiceImpl implements CaptchaService {
|
||||
return validate(key, code, delete);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成指定数量的随机数验证码
|
||||
*
|
||||
* @param length 数量
|
||||
* @return 随机码
|
||||
*/
|
||||
private String generateValidateCode(Integer length) {
|
||||
String chars = "0123456789"; // 字符范围可以自定义:数字
|
||||
Random random = new Random();
|
||||
StringBuilder code = new StringBuilder();
|
||||
for (int i = 0; i < length; i++) {
|
||||
code.append(chars.charAt(random.nextInt(chars.length())));
|
||||
}
|
||||
return code.toString();
|
||||
}
|
||||
|
||||
private void setCache(String key, String value) {
|
||||
if (open) {
|
||||
key = RedisKeys.getCaptchaKey(key);
|
||||
@@ -180,4 +164,4 @@ public class CaptchaServiceImpl implements CaptchaService {
|
||||
}
|
||||
return captcha;
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -2,7 +2,9 @@ package xiaozhi.modules.security.service.impl;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -81,6 +83,36 @@ public class SysUserTokenServiceImpl extends BaseServiceImpl<SysUserTokenDao, Sy
|
||||
return new Result<TokenDTO>().ok(tokenDTO);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Result<TokenDTO> refreshToken(String oldToken) {
|
||||
if (StringUtils.isBlank(oldToken)) {
|
||||
throw new RenException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
SysUserTokenEntity tokenEntity = baseDao.getByToken(oldToken);
|
||||
if (tokenEntity == null) {
|
||||
throw new RenException(ErrorCode.TOKEN_INVALID);
|
||||
}
|
||||
if (tokenEntity.getExpireDate().before(new Date())) {
|
||||
throw new RenException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// CAS 更新:并发场景下只有一个请求能匹配到该 oldToken,另一个返 0 行
|
||||
String newToken = TokenGenerator.generateValue();
|
||||
Date now = new Date();
|
||||
Date expireTime = new Date(now.getTime() + EXPIRE * 1000L);
|
||||
int affected = baseDao.refreshTokenCAS(tokenEntity.getUserId(), oldToken, newToken, expireTime, now);
|
||||
if (affected == 0) {
|
||||
throw new RenException(ErrorCode.TOKEN_INVALID);
|
||||
}
|
||||
|
||||
TokenDTO dto = new TokenDTO();
|
||||
dto.setToken(newToken);
|
||||
dto.setExpire(EXPIRE);
|
||||
dto.setClientHash(HttpContextUtils.getClientCode());
|
||||
return new Result<TokenDTO>().ok(dto);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SysUserDTO getUserByToken(String token) {
|
||||
SysUserTokenEntity userToken = baseDao.getByToken(token);
|
||||
|
||||
+3
-3
@@ -12,6 +12,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
@@ -21,7 +22,6 @@ import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.common.utils.ToolUtil;
|
||||
import xiaozhi.modules.sys.dao.SysDictDataDao;
|
||||
import xiaozhi.modules.sys.dao.SysUserDao;
|
||||
import xiaozhi.modules.sys.dto.SysDictDataDTO;
|
||||
@@ -104,13 +104,13 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long[] ids) {
|
||||
List<Long> idList = Arrays.asList(ids);
|
||||
if (ToolUtil.isNotEmpty(idList)) {
|
||||
if (CollUtil.isNotEmpty(idList)) {
|
||||
//批量删除redis字典
|
||||
List<String> redisKeyList = new ArrayList<>();
|
||||
//批量获取字典类型
|
||||
List<String> dictTypeList = Optional.ofNullable(baseDao.getDictTypesByIdList(idList)).orElseGet(ArrayList::new);
|
||||
dictTypeList.forEach(dictType -> redisKeyList.add(RedisKeys.getDictDataByTypeKey(dictType)));
|
||||
if (ToolUtil.isNotEmpty(redisKeyList)) {
|
||||
if (CollUtil.isNotEmpty(redisKeyList)) {
|
||||
//清除缓存
|
||||
redisUtils.delete(redisKeyList);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public class TimbreDataDTO {
|
||||
|
||||
@Schema(description = "排序")
|
||||
@Min(value = 0, message = "{sort.number}")
|
||||
private long sort;
|
||||
private Long sort;
|
||||
|
||||
@Schema(description = "对应 TTS 模型主键")
|
||||
@NotBlank(message = "{timbre.ttsModelId.require}")
|
||||
@@ -46,4 +46,4 @@ public class TimbreDataDTO {
|
||||
|
||||
@Schema(description = "音频播放地址")
|
||||
private String voiceDemo;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package xiaozhi.modules.timbre.entity;
|
||||
import java.util.Date;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
@@ -41,7 +42,8 @@ public class TimbreEntity {
|
||||
private String referenceText;
|
||||
|
||||
@Schema(description = "排序")
|
||||
private long sort;
|
||||
@TableField(updateStrategy = FieldStrategy.NOT_NULL)
|
||||
private Long sort;
|
||||
|
||||
@Schema(description = "对应 TTS 模型主键")
|
||||
private String ttsModelId;
|
||||
@@ -68,4 +70,4 @@ public class TimbreEntity {
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private Date createDate;
|
||||
|
||||
}
|
||||
}
|
||||
+3
@@ -99,6 +99,9 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void save(TimbreDataDTO dto) {
|
||||
isTtsModelId(dto.getTtsModelId());
|
||||
if (dto.getSort() == null) {
|
||||
dto.setSort(0L);
|
||||
}
|
||||
TimbreEntity timbreEntity = ConvertUtils.sourceToTarget(dto, TimbreEntity.class);
|
||||
baseDao.insert(timbreEntity);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ public class TimbreDetailsVO implements Serializable {
|
||||
private String referenceText;
|
||||
|
||||
@Schema(description = "排序")
|
||||
private long sort;
|
||||
private Long sort;
|
||||
|
||||
@Schema(description = "对应 TTS 模型主键")
|
||||
private String ttsModelId;
|
||||
|
||||
+2
-2
@@ -17,6 +17,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
@@ -26,7 +27,6 @@ import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.DateUtils;
|
||||
import xiaozhi.common.utils.ToolUtil;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
import xiaozhi.modules.sys.dao.SysUserDao;
|
||||
@@ -120,7 +120,7 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
|
||||
entity.setTrainStatus(0); // 默认训练中
|
||||
batchInsertList.add(entity);
|
||||
}
|
||||
if (ToolUtil.isNotEmpty(batchInsertList)) {
|
||||
if (CollUtil.isNotEmpty(batchInsertList)) {
|
||||
insertBatch(batchInsertList);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- 插入新的 hass_state 插件记录(合并 get_state 的配置字段)
|
||||
INSERT INTO ai_model_provider (id, model_type, provider_code, name, fields,
|
||||
sort, creator, create_date, updater, update_date)
|
||||
VALUES ('SYSTEM_PLUGIN_HA_STATE',
|
||||
'Plugin',
|
||||
'hass_state',
|
||||
'HomeAssistant设备控制',
|
||||
JSON_ARRAY(
|
||||
JSON_OBJECT(
|
||||
'key', 'base_url',
|
||||
'type', 'string',
|
||||
'label', 'HA 服务器地址',
|
||||
'default',
|
||||
(SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.base_url')
|
||||
),
|
||||
JSON_OBJECT(
|
||||
'key', 'api_key',
|
||||
'type', 'string',
|
||||
'label', 'HA API 访问令牌',
|
||||
'default',
|
||||
(SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.api_key')
|
||||
),
|
||||
JSON_OBJECT(
|
||||
'key', 'devices',
|
||||
'type', 'array',
|
||||
'label', '设备列表(名称,实体ID;…)',
|
||||
'default',
|
||||
(SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.devices')
|
||||
)
|
||||
),
|
||||
50, 0, NOW(), 0, NOW());
|
||||
|
||||
-- 迁移已配置的 agent:把指向 HA_GET_STATE 的改成 HA_STATE(保留参数)
|
||||
UPDATE ai_agent_plugin_mapping
|
||||
SET plugin_id = 'SYSTEM_PLUGIN_HA_STATE'
|
||||
WHERE plugin_id = 'SYSTEM_PLUGIN_HA_GET_STATE';
|
||||
|
||||
-- HA_SET_STATE 本来就没有配置字段(fields=[]),直接删除其 mapping
|
||||
DELETE FROM ai_agent_plugin_mapping
|
||||
WHERE plugin_id = 'SYSTEM_PLUGIN_HA_SET_STATE';
|
||||
|
||||
-- 删除旧的插件定义
|
||||
DELETE FROM ai_model_provider
|
||||
WHERE id IN ('SYSTEM_PLUGIN_HA_GET_STATE', 'SYSTEM_PLUGIN_HA_SET_STATE');
|
||||
@@ -0,0 +1,4 @@
|
||||
UPDATE `ai_model_config`
|
||||
SET `config_json` = JSON_SET(`config_json`, '$.model_name', 'deepseek-v4-flash')
|
||||
WHERE `id` = 'LLM_DeepSeekLLM'
|
||||
AND JSON_EXTRACT(`config_json`, '$.model_name') = 'deepseek-chat';
|
||||
@@ -0,0 +1,22 @@
|
||||
UPDATE `ai_model_config`
|
||||
SET `config_json` = JSON_SET(`config_json`, '$.model_name', 'doubao-seed-2-0-lite-260215')
|
||||
WHERE `id` = 'LLM_DoubaoLLM'
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.model_name')) IN (
|
||||
'doubao-1-5-pro-32k-250115'
|
||||
);
|
||||
|
||||
UPDATE `ai_model_config`
|
||||
SET `remark` = REPLACE(
|
||||
REPLACE(
|
||||
`remark`,
|
||||
'开通Doubao-1.5-pro服务',
|
||||
'开通 Doubao-Seed-2.0-Lite 服务'
|
||||
),
|
||||
'当前建议使用doubao-1-5-pro-32k-250115',
|
||||
'当前建议使用 doubao-seed-2-0-lite-260215'
|
||||
)
|
||||
WHERE `id` = 'LLM_DoubaoLLM'
|
||||
AND (
|
||||
`remark` LIKE '%Doubao-1.5-pro%'
|
||||
OR `remark` LIKE '%doubao-1-5-pro-32k-250115%'
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
UPDATE `sys_params`
|
||||
SET `param_value` = '15'
|
||||
WHERE `param_code` = 'tts_timeout'
|
||||
AND `param_value` = '10';
|
||||
@@ -0,0 +1,42 @@
|
||||
-- 阿里百炼流式 TTS 支持业务空间 WebSocket 地址
|
||||
|
||||
UPDATE `ai_model_provider`
|
||||
SET `fields` = JSON_ARRAY(
|
||||
JSON_OBJECT('key', 'api_key', 'type', 'string', 'label', 'API密钥'),
|
||||
JSON_OBJECT(
|
||||
'key', 'ws_url',
|
||||
'type', 'string',
|
||||
'label', 'WebSocket地址(含Workspace ID)',
|
||||
'default', 'wss://dashscope.aliyuncs.com/api-ws/v1/inference/'
|
||||
),
|
||||
JSON_OBJECT('key', 'output_dir', 'type', 'string', 'label', '输出目录'),
|
||||
JSON_OBJECT('key', 'model', 'type', 'string', 'label', '模型'),
|
||||
JSON_OBJECT('key', 'voice', 'type', 'string', 'label', '音色'),
|
||||
JSON_OBJECT('key', 'format', 'type', 'string', 'label', '音频格式'),
|
||||
JSON_OBJECT('key', 'sample_rate', 'type', 'number', 'label', '采样率'),
|
||||
JSON_OBJECT('key', 'volume', 'type', 'number', 'label', '音量'),
|
||||
JSON_OBJECT('key', 'rate', 'type', 'number', 'label', '语速'),
|
||||
JSON_OBJECT('key', 'pitch', 'type', 'number', 'label', '音调')
|
||||
)
|
||||
WHERE `id` = 'SYSTEM_TTS_AliBLStreamTTS';
|
||||
|
||||
UPDATE `ai_model_config`
|
||||
SET `config_json` = JSON_SET(
|
||||
`config_json`,
|
||||
'$.ws_url',
|
||||
COALESCE(
|
||||
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.ws_url')), ''),
|
||||
'wss://dashscope.aliyuncs.com/api-ws/v1/inference/'
|
||||
)
|
||||
)
|
||||
WHERE `id` = 'TTS_AliBLStreamTTS';
|
||||
|
||||
UPDATE `ai_model_config`
|
||||
SET `remark` = '阿里百炼流式 TTS 配置说明:
|
||||
1. API密钥填写当前业务空间可用的 DashScope API Key
|
||||
2. 公有模式可使用 wss://dashscope.aliyuncs.com/api-ws/v1/inference/
|
||||
3. 华北2(北京)业务空间填写 wss://<WorkspaceId>.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference
|
||||
4. 新加坡业务空间填写 wss://<WorkspaceId>.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference
|
||||
5. 将 <WorkspaceId> 替换为真实业务空间 ID;仅支持阿里云官方 WSS 推理地址
|
||||
6. 支持 CosyVoice 流式合成及音量、语速、音调配置'
|
||||
WHERE `id` = 'TTS_AliBLStreamTTS';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 联网搜索插件新增Serply搜索源
|
||||
|
||||
UPDATE `ai_model_provider`
|
||||
SET `fields` = JSON_SET(
|
||||
`fields`,
|
||||
'$[0].label',
|
||||
'搜索源:metaso / tavily / serply'
|
||||
)
|
||||
WHERE `id` = 'SYSTEM_PLUGIN_WEB_SEARCH';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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';
|
||||
@@ -711,3 +711,52 @@ databaseChangeLog:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607101200.sql
|
||||
- changeSet:
|
||||
id: 202607290930
|
||||
author: cgd
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607290930.sql
|
||||
- changeSet:
|
||||
id: 202608061000
|
||||
author: cgd
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202608061030.sql
|
||||
- changeSet:
|
||||
id: 202608061800
|
||||
author: cgd
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202608061800.sql
|
||||
- changeSet:
|
||||
id: 202608131030
|
||||
author: tykechen
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202608131030.sql
|
||||
- changeSet:
|
||||
id: 202609021000
|
||||
author: JacobNg1
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202609021000.sql
|
||||
- changeSet:
|
||||
id: 202609172100
|
||||
author: googio
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202609172100.sql
|
||||
- changeSet:
|
||||
id: 202610302051
|
||||
author: shinjiyu
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202610302051.sql
|
||||
@@ -14,4 +14,12 @@
|
||||
<update id="logout">
|
||||
update sys_user_token set expire_date = #{expireDate} where user_id = #{userId}
|
||||
</update>
|
||||
|
||||
<update id="refreshTokenCAS">
|
||||
update sys_user_token
|
||||
set token = #{newToken}, update_date = #{updateDate}, expire_date = #{newExpire}
|
||||
where token = #{oldToken}
|
||||
and expire_date > NOW()
|
||||
and user_id = #{userId}
|
||||
</update>
|
||||
</mapper>
|
||||
@@ -0,0 +1,83 @@
|
||||
package xiaozhi.common.utils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
|
||||
class SensitiveDataUtilsTest {
|
||||
|
||||
@Test
|
||||
void isSensitiveField_recognizes_known_field_names() {
|
||||
assertTrue(SensitiveDataUtils.isSensitiveField("api_key"));
|
||||
assertTrue(SensitiveDataUtils.isSensitiveField("API_KEY"));
|
||||
assertTrue(SensitiveDataUtils.isSensitiveField("personal_access_token"));
|
||||
assertTrue(SensitiveDataUtils.isSensitiveField("secret"));
|
||||
assertTrue(SensitiveDataUtils.isSensitiveField("secret_key"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSensitiveField_rejects_blank_and_unknown_field_names() {
|
||||
assertFalse(SensitiveDataUtils.isSensitiveField(""));
|
||||
assertFalse(SensitiveDataUtils.isSensitiveField(" "));
|
||||
assertFalse(SensitiveDataUtils.isSensitiveField(null));
|
||||
assertFalse(SensitiveDataUtils.isSensitiveField("not_a_secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void maskMiddle_returns_input_unchanged_for_short_or_blank_values() {
|
||||
assertNull(SensitiveDataUtils.maskMiddle(null));
|
||||
assertEquals("", SensitiveDataUtils.maskMiddle(""));
|
||||
assertEquals("a", SensitiveDataUtils.maskMiddle("a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void maskMiddle_keeps_two_at_each_end_for_short_strings() {
|
||||
assertEquals("ab****yz", SensitiveDataUtils.maskMiddle("abcdefyz"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void maskMiddle_keeps_four_at_each_end_for_long_strings() {
|
||||
String masked = SensitiveDataUtils.maskMiddle("verylongapikeyvalue123");
|
||||
assertTrue(masked.startsWith("very"));
|
||||
assertTrue(masked.endsWith("e123"));
|
||||
assertTrue(masked.contains("****"));
|
||||
assertEquals("verylongapikeyvalue123".length(), masked.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isMaskedValue_detects_masked_values() {
|
||||
assertTrue(SensitiveDataUtils.isMaskedValue("ab****yz"));
|
||||
assertTrue(SensitiveDataUtils.isMaskedValue("abc****def****ghi"));
|
||||
assertFalse(SensitiveDataUtils.isMaskedValue("plainstring"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isMaskedValue_rejects_blank_values() {
|
||||
assertFalse(SensitiveDataUtils.isMaskedValue(null));
|
||||
assertFalse(SensitiveDataUtils.isMaskedValue(""));
|
||||
assertFalse(SensitiveDataUtils.isMaskedValue(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void maskSensitiveFields_masks_known_sensitive_keys_only() {
|
||||
JSONObject source = new JSONObject();
|
||||
source.set("api_key", "sk-supersecretapikey");
|
||||
source.set("name", "alice");
|
||||
|
||||
JSONObject masked = SensitiveDataUtils.maskSensitiveFields(source);
|
||||
|
||||
String apiKey = (String) masked.get("api_key");
|
||||
assertTrue(apiKey.contains("****"));
|
||||
assertEquals("alice", masked.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void maskSensitiveFields_returns_null_when_input_is_null() {
|
||||
assertNull(SensitiveDataUtils.maskSensitiveFields(null));
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package xiaozhi.modules.correctword.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import xiaozhi.modules.agent.dao.AgentCorrectWordMappingDao;
|
||||
import xiaozhi.modules.agent.entity.AgentCorrectWordMappingEntity;
|
||||
import xiaozhi.modules.correctword.dao.CorrectWordFileDao;
|
||||
import xiaozhi.modules.correctword.dao.CorrectWordItemDao;
|
||||
import xiaozhi.modules.correctword.entity.CorrectWordFileEntity;
|
||||
import xiaozhi.modules.correctword.entity.CorrectWordItemEntity;
|
||||
import xiaozhi.modules.correctword.vo.CorrectWordFileVO;
|
||||
import xiaozhi.modules.correctword.vo.CorrectWordSimpleVO;
|
||||
|
||||
class CorrectWordFileServiceImplTest {
|
||||
|
||||
private static final String AGENT_ID = "agent-1";
|
||||
private static final String FILE_ID = "file-1";
|
||||
|
||||
private final CorrectWordFileDao fileDao = mock(CorrectWordFileDao.class);
|
||||
private final CorrectWordItemDao itemDao = mock(CorrectWordItemDao.class);
|
||||
private final AgentCorrectWordMappingDao mappingDao = mock(AgentCorrectWordMappingDao.class);
|
||||
private final CorrectWordFileServiceImpl service =
|
||||
new CorrectWordFileServiceImpl(fileDao, itemDao, mappingDao);
|
||||
|
||||
@Test
|
||||
void getAllItemsByAgentId_returns_empty_list_when_no_mappings() {
|
||||
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
List<CorrectWordSimpleVO> result = service.getAllItemsByAgentId(AGENT_ID);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
verify(itemDao, never()).selectList(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAllItemsByAgentId_returns_mapped_items_when_mappings_exist() {
|
||||
AgentCorrectWordMappingEntity mapping = new AgentCorrectWordMappingEntity();
|
||||
mapping.setFileId(FILE_ID);
|
||||
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(List.of(mapping));
|
||||
|
||||
CorrectWordItemEntity item = new CorrectWordItemEntity();
|
||||
item.setSourceWord("hello");
|
||||
item.setTargetWord("hi");
|
||||
when(itemDao.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(item));
|
||||
|
||||
List<CorrectWordSimpleVO> result = service.getAllItemsByAgentId(AGENT_ID);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("hello", result.get(0).getSourceWord());
|
||||
assertEquals("hi", result.get(0).getTargetWord());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAllItemsByAgentId_returns_empty_list_when_mappings_are_null() {
|
||||
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(null);
|
||||
|
||||
List<CorrectWordSimpleVO> result = service.getAllItemsByAgentId(AGENT_ID);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAgentCorrectWordFileIds_returns_empty_list_when_no_mappings() {
|
||||
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
List<String> result = service.getAgentCorrectWordFileIds(AGENT_ID);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAgentCorrectWordFileIds_returns_file_ids_when_mappings_exist() {
|
||||
AgentCorrectWordMappingEntity m1 = new AgentCorrectWordMappingEntity();
|
||||
m1.setFileId("f1");
|
||||
AgentCorrectWordMappingEntity m2 = new AgentCorrectWordMappingEntity();
|
||||
m2.setFileId("f2");
|
||||
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(List.of(m1, m2));
|
||||
|
||||
List<String> result = service.getAgentCorrectWordFileIds(AGENT_ID);
|
||||
|
||||
assertEquals(List.of("f1", "f2"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFileContent_returns_null_when_entity_not_found() {
|
||||
when(fileDao.selectById(FILE_ID)).thenReturn(null);
|
||||
|
||||
CorrectWordFileVO result = service.getFileContent(FILE_ID);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFileContent_returns_vo_when_entity_found() {
|
||||
CorrectWordFileEntity entity = new CorrectWordFileEntity();
|
||||
entity.setId(FILE_ID);
|
||||
entity.setFileName("dict.txt");
|
||||
entity.setWordCount(2);
|
||||
entity.setContent("hello|hi\nbye|goodbye");
|
||||
entity.setCreatedAt(new Date());
|
||||
when(fileDao.selectById(FILE_ID)).thenReturn(entity);
|
||||
|
||||
CorrectWordFileVO result = service.getFileContent(FILE_ID);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(FILE_ID, result.getId());
|
||||
assertEquals("dict.txt", result.getFileName());
|
||||
assertEquals(2, result.getWordCount().intValue());
|
||||
assertEquals(2, result.getContent().size());
|
||||
assertEquals("hello|hi", result.getContent().get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteMappingsByAgentId_delegates_to_dao() {
|
||||
service.deleteMappingsByAgentId(AGENT_ID);
|
||||
|
||||
verify(mappingDao).deleteByAgentId(AGENT_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchDeleteFiles_skips_null_and_empty_ids() {
|
||||
List<String> ids = new ArrayList<>();
|
||||
ids.add(null);
|
||||
ids.add("");
|
||||
ids.add(" ");
|
||||
ids.add(FILE_ID);
|
||||
|
||||
service.batchDeleteFiles(ids);
|
||||
|
||||
verify(fileDao).deleteById(FILE_ID);
|
||||
}
|
||||
}
|
||||
+86
-3
@@ -1,11 +1,15 @@
|
||||
package xiaozhi.modules.device.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
@@ -15,7 +19,10 @@ import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.MessageUtils;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.modules.device.dto.DeviceAddressBookAliasDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceAddressBookPermissionDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceUpdateDTO;
|
||||
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceAddressBookService;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
@@ -26,6 +33,8 @@ import xiaozhi.modules.sys.service.SysParamsService;
|
||||
class DeviceControllerTest {
|
||||
|
||||
private static final String DEVICE_ID = "device-id";
|
||||
private static final String MAC = "00:11:22:33:44:55";
|
||||
private static final String OTHER_MAC = "00:11:22:33:44:66";
|
||||
private static final long USER_ID = 1L;
|
||||
|
||||
@Test
|
||||
@@ -35,7 +44,7 @@ class DeviceControllerTest {
|
||||
DeviceEntity entity = ownedDevice();
|
||||
when(deviceService.selectById(DEVICE_ID)).thenReturn(entity);
|
||||
when(deviceService.updateById(entity)).thenReturn(false);
|
||||
DeviceController controller = controller(deviceService);
|
||||
DeviceController controller = controller(deviceService, mock(DeviceAddressBookService.class));
|
||||
|
||||
DeviceUpdateDTO update = new DeviceUpdateDTO();
|
||||
update.setAutoUpdate(0);
|
||||
@@ -54,10 +63,84 @@ class DeviceControllerTest {
|
||||
}
|
||||
}
|
||||
|
||||
private DeviceController controller(DeviceService deviceService) {
|
||||
@Test
|
||||
@DisplayName("读取他人设备通讯录时一律按设备不存在处理")
|
||||
void addressBookOfDeviceOwnedByAnotherUserIsRejected() {
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
|
||||
when(deviceService.getOwnedDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
|
||||
DeviceController controller = controller(deviceService, addressBookService);
|
||||
|
||||
try (MockedStatic<MessageUtils> messageUtils = mockStatic(MessageUtils.class)) {
|
||||
messageUtils.when(() -> MessageUtils.getMessage(ErrorCode.DEVICE_NOT_EXIST))
|
||||
.thenReturn("Device not exist");
|
||||
|
||||
Result<Object> result = controller.getAddressBook(OTHER_MAC);
|
||||
|
||||
assertEquals(ErrorCode.DEVICE_NOT_EXIST, result.getCode());
|
||||
assertNull(result.getData());
|
||||
verify(addressBookService, never()).getAddressBookList(OTHER_MAC);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("读取本机设备通讯录照常返回")
|
||||
void addressBookOfOwnDeviceIsReturned() {
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
|
||||
List<DeviceAddressBookEntity> entries = List.of(new DeviceAddressBookEntity());
|
||||
when(deviceService.getOwnedDeviceByMacAddress(MAC)).thenReturn(ownedDevice());
|
||||
when(addressBookService.getAddressBookList(MAC)).thenReturn(entries);
|
||||
DeviceController controller = controller(deviceService, addressBookService);
|
||||
|
||||
Result<Object> result = controller.getAddressBook(MAC);
|
||||
|
||||
assertEquals(0, result.getCode());
|
||||
assertEquals(entries, result.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("以他人设备名义改别名被拒")
|
||||
void updateAliasOnDeviceOwnedByAnotherUserIsRejected() {
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
|
||||
when(deviceService.getOwnedDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
|
||||
DeviceController controller = controller(deviceService, addressBookService);
|
||||
|
||||
DeviceAddressBookAliasDTO dto = new DeviceAddressBookAliasDTO();
|
||||
dto.setMacAddress(OTHER_MAC);
|
||||
dto.setTargetMac(MAC);
|
||||
dto.setAlias("office");
|
||||
|
||||
Result<Void> result = controller.updateAlias(dto);
|
||||
|
||||
assertEquals("无权限操作该设备", result.getMsg());
|
||||
verify(addressBookService, never()).saveOrUpdate(OTHER_MAC, MAC, "office", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("以他人设备名义改权限被拒")
|
||||
void updatePermissionOnDeviceOwnedByAnotherUserIsRejected() {
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
|
||||
when(deviceService.getOwnedDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
|
||||
DeviceController controller = controller(deviceService, addressBookService);
|
||||
|
||||
DeviceAddressBookPermissionDTO dto = new DeviceAddressBookPermissionDTO();
|
||||
dto.setMacAddress(OTHER_MAC);
|
||||
dto.setTargetMac(MAC);
|
||||
dto.setHasPermission(true);
|
||||
|
||||
Result<Void> result = controller.updatePermission(dto);
|
||||
|
||||
assertEquals("无权限操作该设备", result.getMsg());
|
||||
verify(addressBookService, never()).saveOrUpdate(OTHER_MAC, MAC, null, true);
|
||||
}
|
||||
|
||||
private DeviceController controller(DeviceService deviceService, DeviceAddressBookService addressBookService) {
|
||||
return new DeviceController(
|
||||
deviceService,
|
||||
mock(DeviceAddressBookService.class),
|
||||
addressBookService,
|
||||
mock(RedisUtils.class),
|
||||
mock(SysParamsService.class));
|
||||
}
|
||||
|
||||
+541
-3
@@ -1,26 +1,49 @@
|
||||
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;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
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;
|
||||
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
|
||||
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
|
||||
class DeviceAddressBookServiceImplTest {
|
||||
|
||||
private static final String MAC = "00:11:22:33:44:55";
|
||||
private static final String OTHER_MAC = "00:11:22:33:44:66";
|
||||
private static final String THIRD_MAC = "00:11:22:33:44:77";
|
||||
// 纯数字的 MAC 大小写化后跟原来一模一样,拿它造不出"只差大小写"的两行
|
||||
private static final String HEX_MAC = "00:11:22:33:44:ab";
|
||||
|
||||
@Test
|
||||
void newEntryUsesTheDedicatedAddressBookInsertMapping() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
@@ -32,15 +55,530 @@ class DeviceAddressBookServiceImplTest {
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
|
||||
service.saveOrUpdate("00:11:22:33:44:55", "00:11:22:33:44:66", "living-room", true);
|
||||
service.saveOrUpdate(MAC, OTHER_MAC, "living-room", true);
|
||||
|
||||
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
DeviceAddressBookEntity entity = entityCaptor.getValue();
|
||||
assertEquals("00:11:22:33:44:55", entity.getMacAddress());
|
||||
assertEquals("00:11:22:33:44:66", entity.getTargetMac());
|
||||
assertEquals(MAC, entity.getMacAddress());
|
||||
assertEquals(OTHER_MAC, entity.getTargetMac());
|
||||
assertEquals("living-room", entity.getAlias());
|
||||
assertTrue(entity.getHasPermission());
|
||||
verify(addressBookDao, never()).insert(any(DeviceAddressBookEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankAliasOnUnregisteredTargetFallsBackToMacSuffix() {
|
||||
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);
|
||||
|
||||
service.saveOrUpdate(MAC, OTHER_MAC, null, true);
|
||||
|
||||
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
DeviceAddressBookEntity entity = entityCaptor.getValue();
|
||||
assertNotNull(entity.getAlias());
|
||||
assertEquals("尾号为66的设备", entity.getAlias());
|
||||
assertTrue(entity.getHasPermission());
|
||||
verify(deviceService, never()).getOwnedDeviceByMacAddress(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankAliasOnAnotherDeviceFallsBackToMacSuffixWithoutAskingTheDeviceTable() {
|
||||
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 registered = new DeviceEntity();
|
||||
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);
|
||||
|
||||
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 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
|
||||
void refreshCacheKeepsEntriesWhoseAliasWasNeverFilledIn() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of(row(MAC, OTHER_MAC, null)));
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
|
||||
|
||||
service.refreshCache();
|
||||
|
||||
Map<String, String> book = cachedBook(service, redisUtils).get(MAC);
|
||||
assertNotNull(book, "空别名整条被跳过,这条设备就再也呼不过来了");
|
||||
assertEquals(OTHER_MAC + "|1", book.get("尾号为66的设备"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshCacheDisambiguatesTwoEntriesSharingAnAlias() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of(
|
||||
row(MAC, OTHER_MAC, "客厅"),
|
||||
row(MAC, THIRD_MAC, "客厅")));
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
|
||||
|
||||
service.refreshCache();
|
||||
|
||||
Map<String, String> book = cachedBook(service, redisUtils).get(MAC);
|
||||
assertEquals(2, book.size(), "撞名的两台设备只留得下一台:" + book);
|
||||
assertEquals(OTHER_MAC + "|1", book.get("客厅"));
|
||||
assertEquals(THIRD_MAC + "|1", book.get("客厅1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshCacheGivesTheSameResultWhateverOrderRowsComeBackIn() {
|
||||
DeviceAddressBookEntity first = row(MAC, OTHER_MAC, "客厅");
|
||||
DeviceAddressBookEntity second = row(MAC, THIRD_MAC, "客厅");
|
||||
|
||||
Map<String, String> inOrder = refreshAndRead(List.of(first, second));
|
||||
Map<String, String> reversed = refreshAndRead(List.of(second, first));
|
||||
|
||||
assertEquals(inOrder, reversed, "同样的数据两次刷新结果不一样,缓存里的称呼会来回变");
|
||||
}
|
||||
|
||||
/**
|
||||
* 库里能同时存在两条 owner 只差大小写的行:表是 utf8mb4_0900_ai_ci,
|
||||
* 主键 (mac_address, target_mac) 大小写不敏感,target 不同就不会撞。
|
||||
* 这是同一个人,算称呼和写缓存都得按小写归成一本
|
||||
*/
|
||||
@Test
|
||||
void ownersDifferingOnlyInCaseShareOneBook() {
|
||||
String upper = HEX_MAC.toUpperCase();
|
||||
assertNotEquals(HEX_MAC, upper, "这个用例得真的造出大小写差异才有效");
|
||||
|
||||
Map<String, Map<String, String>> books = refreshAndReadAll(List.of(
|
||||
row(upper, OTHER_MAC, "客厅"),
|
||||
row(HEX_MAC, THIRD_MAC, "客厅")));
|
||||
|
||||
Map<String, String> book = books.get(HEX_MAC);
|
||||
assertNotNull(book, "同一个人被算成了两个人:" + books.keySet());
|
||||
assertEquals(2, book.size(), "两台设备只剩一台,另一台从缓存里消失了:" + book);
|
||||
assertEquals(OTHER_MAC + "|1", book.get("客厅"));
|
||||
assertEquals(THIRD_MAC + "|1", book.get("客厅1"));
|
||||
assertEquals(1, books.size(), "两种写法的地址被拆成了两本:" + books.keySet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAndCacheAgreeWhenOwnersDifferOnlyInCase() {
|
||||
String upper = HEX_MAC.toUpperCase();
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
when(addressBookDao.getAddressBookList(upper)).thenReturn(new ArrayList<>(List.of(
|
||||
row(upper, OTHER_MAC, "客厅"), row(HEX_MAC, THIRD_MAC, "客厅"))));
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of(
|
||||
row(upper, OTHER_MAC, "客厅"), row(HEX_MAC, THIRD_MAC, "客厅")));
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
|
||||
|
||||
Map<String, String> shownToConsole = namesByTargetMac(service.getAddressBookList(upper));
|
||||
assertEquals(2, shownToConsole.size(),
|
||||
"接口按原值分组,同一个人的两台设备被算成一个,前端只能看到一台:" + shownToConsole);
|
||||
|
||||
service.refreshCache();
|
||||
Map<String, String> callableNames = new HashMap<>();
|
||||
cachedBook(service, redisUtils).get(HEX_MAC)
|
||||
.forEach((name, macAndPerm) -> callableNames.put(name, macAndPerm.split("\\|")[0]));
|
||||
|
||||
assertEquals(shownToConsole, callableNames,
|
||||
"接口按原值分组、缓存按小写分组,用户照前端看到的名字呼,命中的不是同一台设备");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nameAssignmentDoesNotDependOnWhichSpellingOfTheOwnerCameBack() {
|
||||
String upper = HEX_MAC.toUpperCase();
|
||||
Map<String, String> expected = Map.of("客厅", OTHER_MAC + "|1", "客厅1", THIRD_MAC + "|1");
|
||||
|
||||
Map<String, String> lowerFirst = refreshAndRead(HEX_MAC, List.of(
|
||||
row(HEX_MAC, OTHER_MAC, "客厅"), row(upper, THIRD_MAC, "客厅")));
|
||||
Map<String, String> upperFirst = refreshAndRead(HEX_MAC, List.of(
|
||||
row(upper, THIRD_MAC, "客厅"), row(HEX_MAC, OTHER_MAC, "客厅")));
|
||||
|
||||
assertEquals(expected, lowerFirst,
|
||||
"同一个人的称呼取决于库里哪种写法先返回,刷新一次就换一次名字");
|
||||
assertEquals(expected, upperFirst, "顺序反过来结果就该不一样:" + upperFirst);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Map<String, String>> refreshAndReadAll(List<DeviceAddressBookEntity> rows) {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
when(addressBookDao.selectList(null)).thenReturn(rows);
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
|
||||
|
||||
service.refreshCache();
|
||||
|
||||
return cachedBook(service, redisUtils);
|
||||
}
|
||||
|
||||
private Map<String, String> refreshAndRead(List<DeviceAddressBookEntity> rows) {
|
||||
return refreshAndRead(MAC, rows);
|
||||
}
|
||||
|
||||
private Map<String, String> refreshAndRead(String owner, List<DeviceAddressBookEntity> rows) {
|
||||
return refreshAndReadAll(rows).get(owner);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Map<String, String>> cachedBook(DeviceAddressBookServiceImpl service, RedisUtils redisUtils) {
|
||||
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
|
||||
verify(redisUtils).set(eq(RedisKeys.getAddressBookKey()), captor.capture());
|
||||
return (Map<String, Map<String, String>>) captor.getValue();
|
||||
}
|
||||
|
||||
private DeviceAddressBookEntity row(String macAddress, String targetMac, String alias) {
|
||||
return row(macAddress, targetMac, alias, Boolean.TRUE);
|
||||
}
|
||||
|
||||
private DeviceAddressBookEntity row(String macAddress, String targetMac, String alias, Boolean hasPermission) {
|
||||
DeviceAddressBookEntity entity = new DeviceAddressBookEntity();
|
||||
entity.setMacAddress(macAddress);
|
||||
entity.setTargetMac(targetMac);
|
||||
entity.setAlias(alias);
|
||||
entity.setHasPermission(hasPermission);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAndCacheHandOutTheSameNameForCollidingEntries() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
// 两次 DAO 调用各给一份新行:取名会写回实体,共用同一份的话第二遍读到的是算过的名字
|
||||
// 用可变列表,跟 MyBatis 实际返回的一致,免得排序那步先炸掉、断言根本没跑到
|
||||
when(addressBookDao.getAddressBookList(MAC)).thenReturn(
|
||||
new ArrayList<>(List.of(row(MAC, OTHER_MAC, "客厅"), row(MAC, THIRD_MAC, "客厅"))));
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of(
|
||||
row(MAC, OTHER_MAC, "客厅"), row(MAC, THIRD_MAC, "客厅")));
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
|
||||
|
||||
Map<String, String> shownToConsole = namesByTargetMac(service.getAddressBookList(MAC));
|
||||
service.refreshCache();
|
||||
Map<String, String> callableNames = new HashMap<>();
|
||||
cachedBook(service, redisUtils).get(MAC)
|
||||
.forEach((name, macAndPerm) -> callableNames.put(name, macAndPerm.split("\\|")[0]));
|
||||
|
||||
assertEquals(callableNames, shownToConsole,
|
||||
"接口和缓存给的是两个名字,用户照前端看到的那个名字呼叫,命中的不是同一台设备");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theCallableEntryKeepsThePlainNameWhenTwoCollide() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
// OTHER_MAC 的 target_mac 排在前面,没权限的那条会先占住原名
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of(
|
||||
row(MAC, OTHER_MAC, "客厅", Boolean.FALSE),
|
||||
row(MAC, THIRD_MAC, "客厅", Boolean.TRUE)));
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
|
||||
|
||||
service.refreshCache();
|
||||
|
||||
Map<String, String> book = cachedBook(service, redisUtils).get(MAC);
|
||||
assertEquals(THIRD_MAC + "|1", book.get("客厅"),
|
||||
"能呼的那台被挤到后缀名上了,用户照前端看到的名字点,界面只会说没权限");
|
||||
assertEquals(OTHER_MAC + "|0", book.get("客厅1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listFillsInBlankAliasesSoTheConsoleShowsWhatCanBeCalled() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
when(addressBookDao.getAddressBookList(MAC)).thenReturn(new ArrayList<>(List.of(row(MAC, OTHER_MAC, null))));
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
|
||||
|
||||
List<DeviceAddressBookEntity> rows = service.getAddressBookList(MAC);
|
||||
|
||||
assertEquals("尾号为66的设备", rows.get(0).getAlias(),
|
||||
"空别名原样返回的话前端回落显示设备名,用户照着那个名字呼叫却只会说找不到备注");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUppercaseMacGetsTheSameNameWhicheverPathAddsItToTheBook() {
|
||||
// 兜底称呼只有这一处算法:存进去的时候算一次,之后接口和缓存里显示的
|
||||
// 都是同一个字符串。不跟着小写化的话同一个地址在两条路径上算出两个称呼,
|
||||
// 用户照着看到的名字呼过去,命中的不是自己刚存的那台
|
||||
//
|
||||
// 运行期这一侧:saveOrUpdate 传的是 DTO 原样过来的地址,大小写没归一
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
DeviceService deviceService = mock(DeviceService.class);
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
when(deviceService.getOwnedDeviceByMacAddress(anyString())).thenReturn(null);
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, deviceService, mock(SysParamsService.class));
|
||||
|
||||
service.saveOrUpdate(MAC, "ff:ee:dd:cc:bb:AB", null, true);
|
||||
|
||||
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
assertEquals("尾号为ab的设备", entityCaptor.getValue().getAlias(),
|
||||
"MAC 大小写不区分,末尾两位要跟着小写化,否则接口和呼叫那边算出来的称呼对不上");
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口按最近改过的排前面
|
||||
*
|
||||
* <p>
|
||||
* 智控台那条 Vue 是按 targetMac 把 alias 合进 allDevices 的,接口顺序被丢掉了,
|
||||
* 这条现在对 console 没有效果。用例留着是把这个契约钉住:哪天当无用代码删了,
|
||||
* 会有用例报出来,而不是静悄悄换掉接口的返回顺序
|
||||
*/
|
||||
@Test
|
||||
void listPutsTheMostRecentlyTouchedEntryFirst() {
|
||||
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
DeviceAddressBookEntity older = row(MAC, OTHER_MAC, "客厅");
|
||||
older.setUpdateDate(new Date(1767321840000L));
|
||||
DeviceAddressBookEntity newer = row(MAC, THIRD_MAC, "书房");
|
||||
newer.setUpdateDate(new Date(1778000000000L));
|
||||
DeviceAddressBookEntity neverTouched = row(MAC, "00:11:22:33:44:88", "阳台");
|
||||
when(addressBookDao.getAddressBookList(MAC)).thenReturn(
|
||||
new ArrayList<>(List.of(older, neverTouched, newer)));
|
||||
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
|
||||
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
|
||||
|
||||
List<DeviceAddressBookEntity> rows = service.getAddressBookList(MAC);
|
||||
|
||||
assertEquals(List.of("书房", "客厅", "阳台"), rows.stream().map(DeviceAddressBookEntity::getAlias).toList(),
|
||||
"最近改过的应该排前面,没改过的排最后");
|
||||
}
|
||||
|
||||
|
||||
private Map<String, String> namesByTargetMac(List<DeviceAddressBookEntity> rows) {
|
||||
Map<String, String> byAlias = new HashMap<>();
|
||||
for (DeviceAddressBookEntity row : rows) {
|
||||
byAlias.put(row.getAlias(), row.getTargetMac());
|
||||
}
|
||||
return byAlias;
|
||||
}
|
||||
|
||||
@Test
|
||||
void callByNicknameFallsBackToMacSuffixWhenCallerHasNoAlias() {
|
||||
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);
|
||||
|
||||
Map<String, Map<String, String>> books = new HashMap<>();
|
||||
Map<String, String> callerBook = new HashMap<>();
|
||||
callerBook.put("office", OTHER_MAC + "|1");
|
||||
books.put(MAC, callerBook);
|
||||
when(redisUtils.get(RedisKeys.getAddressBookKey())).thenReturn(books);
|
||||
when(deviceService.getDeviceByMacAddress(MAC)).thenReturn(null);
|
||||
when(sysParamsService.getValue("server.mqtt_manager_api", true)).thenReturn("127.0.0.1:1883");
|
||||
when(sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true)).thenReturn("gateway-key");
|
||||
|
||||
// 网关是真的 http 调用,桩掉它,否则测试会往 127.0.0.1:1883 真发请求
|
||||
AtomicReference<String> sentUrl = new AtomicReference<>();
|
||||
AtomicReference<String> sentBody = new AtomicReference<>();
|
||||
Map<String, Object> result;
|
||||
try (MockedStatic<MqttGatewayAuthorization> gateway = mockStatic(MqttGatewayAuthorization.class)) {
|
||||
gateway.when(() -> MqttGatewayAuthorization.postJson(
|
||||
anyString(), anyString(), anyString(), any(), anyInt()))
|
||||
.thenAnswer(invocation -> {
|
||||
sentUrl.set(invocation.getArgument(0));
|
||||
sentBody.set(invocation.getArgument(1));
|
||||
return "{\"status\":\"success\",\"message\":\"ok\"}";
|
||||
});
|
||||
result = service.callByNickname(MAC, "office", false);
|
||||
}
|
||||
|
||||
assertEquals("success", result.get("status"));
|
||||
assertEquals("http://127.0.0.1:1883/api/call/request", sentUrl.get());
|
||||
assertTrue(sentBody.get().contains("尾号为55的设备"),
|
||||
"主叫方没登记也没起名时该退回尾号称呼:" + sentBody.get());
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.modules.device.dao.DeviceDao;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceAddressBookService;
|
||||
import xiaozhi.modules.device.service.OtaService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
import xiaozhi.modules.sys.service.SysUserUtilService;
|
||||
|
||||
@DisplayName("设备归属校验回归测试")
|
||||
class DeviceServiceImplTest {
|
||||
|
||||
private static final String MAC = "00:11:22:33:44:55";
|
||||
private static final long USER_ID = 1L;
|
||||
private static final long OTHER_USER_ID = 2L;
|
||||
|
||||
@Test
|
||||
@DisplayName("设备属于当前用户时返回设备")
|
||||
void deviceOwnedByCurrentUserIsReturned() {
|
||||
DeviceEntity device = device(USER_ID);
|
||||
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device));
|
||||
|
||||
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
|
||||
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
|
||||
|
||||
assertSame(device, service.getOwnedDeviceByMacAddress(MAC));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("设备属于他人时按查不到处理")
|
||||
void deviceOwnedByAnotherUserIsHidden() {
|
||||
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device(OTHER_USER_ID)));
|
||||
|
||||
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
|
||||
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
|
||||
|
||||
assertNull(service.getOwnedDeviceByMacAddress(MAC));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("设备未登记时按查不到处理")
|
||||
void unregisteredDeviceIsHidden() {
|
||||
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(List.of()));
|
||||
|
||||
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
|
||||
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
|
||||
|
||||
assertNull(service.getOwnedDeviceByMacAddress(MAC));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("设备没有归属人时按查不到处理")
|
||||
void unboundDeviceIsHidden() {
|
||||
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device(null)));
|
||||
|
||||
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
|
||||
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
|
||||
|
||||
assertNull(service.getOwnedDeviceByMacAddress(MAC));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有登录用户时按查不到处理")
|
||||
void anonymousCallerGetsNothing() {
|
||||
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device(USER_ID)));
|
||||
|
||||
// 匿名调用的实际形态:getUser() 返回空对象,id 是空的,不是返回 null
|
||||
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
|
||||
securityUser.when(SecurityUser::getUser).thenReturn(new UserDetail());
|
||||
|
||||
assertNull(service.getOwnedDeviceByMacAddress(MAC));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("库里同一 MAC 有两行只差大小写时,取第一条做归属校验,不再抛异常")
|
||||
void duplicateMacDifferingOnlyInCaseStillReturnsTheDevice() {
|
||||
DeviceEntity first = device(USER_ID, "device-1");
|
||||
DeviceEntity second = device(USER_ID, "device-2");
|
||||
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(List.of(first, second)));
|
||||
|
||||
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
|
||||
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
|
||||
|
||||
assertSame(first, service.getOwnedDeviceByMacAddress(MAC));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("解绑自己的设备时,通讯录跟着一起清")
|
||||
void unbindOwnDeviceClearsItsAddressBook() {
|
||||
DeviceDao dao = mock(DeviceDao.class);
|
||||
when(dao.selectById("device-1")).thenReturn(device(USER_ID, "device-1"));
|
||||
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
|
||||
DeviceServiceImpl service = serviceWithDao(dao, addressBookService);
|
||||
|
||||
service.unbindDevice(USER_ID, "device-1");
|
||||
|
||||
verify(dao).delete(any(UpdateWrapper.class));
|
||||
verify(addressBookService).deleteByMacAddresses(List.of(MAC));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("解绑别人的设备时什么都不删,通讯录不能跟着没")
|
||||
void unbindAnotherUsersDeviceDeletesNothing() {
|
||||
DeviceDao dao = mock(DeviceDao.class);
|
||||
when(dao.selectById("device-1")).thenReturn(device(OTHER_USER_ID, "device-1"));
|
||||
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
|
||||
DeviceServiceImpl service = serviceWithDao(dao, addressBookService);
|
||||
|
||||
service.unbindDevice(USER_ID, "device-1");
|
||||
|
||||
verify(dao, never()).delete(any(UpdateWrapper.class));
|
||||
verify(addressBookService, never()).deleteByMacAddresses(anyList());
|
||||
}
|
||||
|
||||
private DeviceServiceImpl serviceWithDao(DeviceDao dao, DeviceAddressBookService addressBookService) {
|
||||
// baseDao 由父类持有,子类内部赋给 this,测试类自己碰不到它
|
||||
return new DeviceServiceImpl(
|
||||
dao, mock(SysUserUtilService.class), mock(SysParamsService.class),
|
||||
mock(RedisUtils.class), mock(OtaService.class), addressBookService) {
|
||||
{
|
||||
this.baseDao = dao;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private DeviceServiceImpl serviceWithDeviceDao(DeviceDao dao) {
|
||||
return serviceWithDao(dao, mock(DeviceAddressBookService.class));
|
||||
}
|
||||
|
||||
private DeviceDao daoReturning(DeviceEntity device) {
|
||||
return daoReturning(List.of(device));
|
||||
}
|
||||
|
||||
private DeviceDao daoReturning(List<DeviceEntity> rows) {
|
||||
DeviceDao dao = mock(DeviceDao.class);
|
||||
when(dao.selectList(any())).thenReturn(rows);
|
||||
return dao;
|
||||
}
|
||||
|
||||
private DeviceEntity device(Long userId) {
|
||||
return device(userId, null);
|
||||
}
|
||||
|
||||
private DeviceEntity device(Long userId, String id) {
|
||||
DeviceEntity entity = new DeviceEntity();
|
||||
entity.setId(id);
|
||||
entity.setMacAddress(MAC);
|
||||
entity.setUserId(userId);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private UserDetail user(long id) {
|
||||
UserDetail user = new UserDetail();
|
||||
user.setId(id);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package xiaozhi.modules.knowledge.rag;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.utils.MessageUtils;
|
||||
import xiaozhi.modules.knowledge.dto.KnowledgeFilesDTO;
|
||||
import xiaozhi.modules.knowledge.dto.dataset.DatasetDTO;
|
||||
import xiaozhi.modules.knowledge.dto.document.ChunkDTO;
|
||||
import xiaozhi.modules.knowledge.dto.document.DocumentDTO;
|
||||
import xiaozhi.modules.knowledge.dto.document.RetrievalDTO;
|
||||
|
||||
class KnowledgeBaseAdapterFactoryTest {
|
||||
|
||||
private static final String REGISTERED_TYPE = "kb-test-registered";
|
||||
private static final String UNKNOWN_TYPE = "kb-test-unknown";
|
||||
|
||||
@BeforeEach
|
||||
void resetCache() {
|
||||
KnowledgeBaseAdapterFactory.clearCache();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanupCache() {
|
||||
KnowledgeBaseAdapterFactory.clearCache();
|
||||
KnowledgeBaseAdapterFactory.removeCacheByType(REGISTERED_TYPE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAdapter_returns_non_null_instance_for_registered_type() {
|
||||
KnowledgeBaseAdapterFactory.registerAdapter(REGISTERED_TYPE, StubAdapter.class);
|
||||
|
||||
KnowledgeBaseAdapter adapter = KnowledgeBaseAdapterFactory.getAdapter(REGISTERED_TYPE);
|
||||
|
||||
assertNotNull(adapter);
|
||||
assertEquals(REGISTERED_TYPE, adapter.getAdapterType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAdapter_returns_cached_instance_for_repeated_calls() {
|
||||
KnowledgeBaseAdapterFactory.registerAdapter(REGISTERED_TYPE, StubAdapter.class);
|
||||
|
||||
KnowledgeBaseAdapter first = KnowledgeBaseAdapterFactory.getAdapter(REGISTERED_TYPE);
|
||||
KnowledgeBaseAdapter second = KnowledgeBaseAdapterFactory.getAdapter(REGISTERED_TYPE);
|
||||
|
||||
assertSame(first, second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAdapter_throws_RenException_for_unknown_type() {
|
||||
try (MockedStatic<MessageUtils> messageUtils = mockStatic(MessageUtils.class)) {
|
||||
messageUtils.when(() -> MessageUtils.getMessage(anyInt())).thenReturn("mocked message");
|
||||
messageUtils.when(() -> MessageUtils.getMessage(anyInt(), any(String[].class)))
|
||||
.thenReturn("mocked message");
|
||||
|
||||
RenException ex = assertThrows(RenException.class,
|
||||
() -> KnowledgeBaseAdapterFactory.getAdapter(UNKNOWN_TYPE));
|
||||
|
||||
assertEquals(ErrorCode.RAG_ADAPTER_TYPE_NOT_SUPPORTED, ex.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void isAdapterTypeRegistered_returns_true_for_registered_types() {
|
||||
KnowledgeBaseAdapterFactory.registerAdapter(REGISTERED_TYPE, StubAdapter.class);
|
||||
|
||||
assertTrue(KnowledgeBaseAdapterFactory.isAdapterTypeRegistered(REGISTERED_TYPE));
|
||||
assertTrue(KnowledgeBaseAdapterFactory.isAdapterTypeRegistered("ragflow"));
|
||||
assertFalse(KnowledgeBaseAdapterFactory.isAdapterTypeRegistered(UNKNOWN_TYPE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRegisteredAdapterTypes_contains_all_registered_types() {
|
||||
KnowledgeBaseAdapterFactory.registerAdapter(REGISTERED_TYPE, StubAdapter.class);
|
||||
|
||||
Set<String> types = KnowledgeBaseAdapterFactory.getRegisteredAdapterTypes();
|
||||
|
||||
assertTrue(types.contains(REGISTERED_TYPE));
|
||||
assertTrue(types.contains("ragflow"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearCache_empties_cached_instance_count() {
|
||||
KnowledgeBaseAdapterFactory.registerAdapter(REGISTERED_TYPE, StubAdapter.class);
|
||||
KnowledgeBaseAdapterFactory.getAdapter(REGISTERED_TYPE);
|
||||
|
||||
KnowledgeBaseAdapterFactory.clearCache();
|
||||
Map<String, Object> status = KnowledgeBaseAdapterFactory.getFactoryStatus();
|
||||
|
||||
assertEquals(0, status.get("cachedAdapterCount"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test stub adapter that satisfies the abstract API surface so the factory's
|
||||
* reflection-based instantiation works in the unit test.
|
||||
*/
|
||||
public static class StubAdapter extends KnowledgeBaseAdapter {
|
||||
@Override
|
||||
public String getAdapterType() {
|
||||
return REGISTERED_TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize(Map<String, Object> config) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean validateConfig(Map<String, Object> config) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageData<KnowledgeFilesDTO> getDocumentList(String datasetId,
|
||||
DocumentDTO.ListReq req) {
|
||||
return new PageData<>(java.util.Collections.emptyList(), 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DocumentDTO.InfoVO getDocumentById(String datasetId, String documentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KnowledgeFilesDTO uploadDocument(DocumentDTO.UploadReq req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageData<KnowledgeFilesDTO> getDocumentListByStatus(String datasetId,
|
||||
Integer status, Integer page, Integer limit) {
|
||||
return new PageData<>(java.util.Collections.emptyList(), 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteDocument(String datasetId, DocumentDTO.BatchIdReq req) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean parseDocuments(String datasetId, java.util.List<String> documentIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChunkDTO.ListVO listChunks(String datasetId, String documentId,
|
||||
ChunkDTO.ListReq req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RetrievalDTO.ResultVO retrievalTest(RetrievalDTO.TestReq req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean testConnection() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getStatus() {
|
||||
return java.util.Collections.emptyMap();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getSupportedConfig() {
|
||||
return java.util.Collections.emptyMap();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getDefaultConfig() {
|
||||
return java.util.Collections.emptyMap();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatasetDTO.InfoVO createDataset(DatasetDTO.CreateReq req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatasetDTO.InfoVO updateDataset(String datasetId, DatasetDTO.UpdateReq req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatasetDTO.BatchOperationVO deleteDataset(DatasetDTO.BatchIdReq req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getDocumentCount(String datasetId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatasetDTO.InfoVO getDatasetInfo(String datasetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postStream(String endpoint, Object body, Consumer<String> onData) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object postSearchBotAsk(Map<String, Object> config, Object body,
|
||||
Consumer<String> onData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postAgentBotCompletion(Map<String, Object> config, String agentId,
|
||||
Object body, Consumer<String> onData) {
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package xiaozhi.modules.model.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
|
||||
import xiaozhi.modules.model.dto.ModelConfigDTO;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("dev")
|
||||
@Transactional
|
||||
class ModelConfigPersistenceTest {
|
||||
|
||||
@Autowired
|
||||
private ModelConfigService service;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private RedisUtils redis;
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"tts,TTS", "memory,Memory", "intent,Intent"})
|
||||
void preservesRegisteredTypeWhenAddingAndEditing(String requestedType, String registeredType) {
|
||||
String id = UUID.randomUUID().toString().replace("-", "");
|
||||
String providerCode = "test-" + id;
|
||||
jdbc.update("INSERT INTO ai_model_provider (id, model_type, provider_code, name, fields) VALUES (?, ?, ?, ?, ?)",
|
||||
id, registeredType, providerCode, "Persistence test provider", "[]");
|
||||
|
||||
ModelConfigBodyDTO body = new ModelConfigBodyDTO();
|
||||
body.setId(id);
|
||||
body.setModelCode(providerCode);
|
||||
body.setModelName("Persistence test model");
|
||||
body.setIsDefault(0);
|
||||
body.setIsEnabled(1);
|
||||
body.setSort(1);
|
||||
body.setConfigJson(new JSONObject().set("type", providerCode));
|
||||
|
||||
ModelConfigDTO added = service.add(requestedType, providerCode, body);
|
||||
assertEquals(registeredType, added.getModelType());
|
||||
assertEquals(registeredType,
|
||||
jdbc.queryForObject("SELECT model_type FROM ai_model_config WHERE id = ?", String.class, id));
|
||||
|
||||
String cacheKey = RedisKeys.getModelConfigById(id);
|
||||
try {
|
||||
redis.set(cacheKey, "cached model");
|
||||
body.setModelName("Updated persistence test model");
|
||||
ModelConfigDTO edited = service.edit(requestedType, providerCode, id, body);
|
||||
|
||||
assertEquals(registeredType, edited.getModelType());
|
||||
assertEquals(registeredType,
|
||||
jdbc.queryForObject("SELECT model_type FROM ai_model_config WHERE id = ?", String.class, id));
|
||||
assertEquals(body.getModelName(),
|
||||
jdbc.queryForObject("SELECT model_name FROM ai_model_config WHERE id = ?", String.class, id));
|
||||
assertNull(redis.get(cacheKey));
|
||||
} finally {
|
||||
redis.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package xiaozhi.modules.model.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.utils.MessageUtils;
|
||||
import xiaozhi.modules.agent.dao.AgentDao;
|
||||
import xiaozhi.modules.model.dao.ModelConfigDao;
|
||||
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
|
||||
import xiaozhi.modules.model.dto.ModelConfigDTO;
|
||||
import xiaozhi.modules.model.dto.ModelProviderDTO;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelProviderService;
|
||||
|
||||
class ModelConfigServiceImplTest {
|
||||
|
||||
private ModelConfigDao modelConfigDao;
|
||||
private ModelProviderService modelProviderService;
|
||||
private ModelConfigServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
modelConfigDao = mock(ModelConfigDao.class);
|
||||
modelProviderService = mock(ModelProviderService.class);
|
||||
service = new ModelConfigServiceImpl(
|
||||
modelConfigDao, modelProviderService, mock(RedisUtils.class), mock(AgentDao.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"vad, VAD", "asr, ASR", "tts, TTS", "llm, LLM", "vllm, VLLM",
|
||||
"memory, Memory", "intent, Intent", "rag, RAG", "LLM, LLM"
|
||||
})
|
||||
void addPreservesRegisteredProviderModelType(String requestedType, String registeredType) {
|
||||
registerProvider(requestedType, registeredType);
|
||||
|
||||
ModelConfigDTO result = service.add(requestedType, "test-provider", modelBody());
|
||||
|
||||
ArgumentCaptor<ModelConfigEntity> saved = ArgumentCaptor.forClass(ModelConfigEntity.class);
|
||||
verify(modelConfigDao).insert(saved.capture());
|
||||
assertEquals(registeredType, saved.getValue().getModelType());
|
||||
assertEquals(registeredType, result.getModelType());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"vad, VAD", "asr, ASR", "tts, TTS", "llm, LLM", "vllm, VLLM",
|
||||
"memory, Memory", "intent, Intent", "rag, RAG", "LLM, LLM"
|
||||
})
|
||||
void editPreservesRegisteredProviderModelType(String requestedType, String registeredType) {
|
||||
registerProvider(requestedType, registeredType);
|
||||
ModelConfigEntity original = new ModelConfigEntity();
|
||||
original.setId("test-model");
|
||||
original.setModelType(registeredType);
|
||||
when(modelConfigDao.selectById("test-model")).thenReturn(original);
|
||||
|
||||
ModelConfigDTO result = service.edit(requestedType, "test-provider", "test-model", modelBody());
|
||||
|
||||
ArgumentCaptor<ModelConfigEntity> saved = ArgumentCaptor.forClass(ModelConfigEntity.class);
|
||||
verify(modelConfigDao).updateById(saved.capture());
|
||||
assertEquals(registeredType, saved.getValue().getModelType());
|
||||
assertEquals(registeredType, result.getModelType());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void rejectsUnknownProviderBeforeWriting(boolean editing) {
|
||||
when(modelProviderService.getList("tts", "missing")).thenReturn(List.of());
|
||||
|
||||
try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
|
||||
messages.when(() -> MessageUtils.getMessage(ErrorCode.MODEL_PROVIDER_NOT_EXIST))
|
||||
.thenReturn("Model provider does not exist");
|
||||
RenException error = assertThrows(RenException.class, () -> {
|
||||
if (editing) {
|
||||
service.edit("tts", "missing", "test-model", modelBody());
|
||||
} else {
|
||||
service.add("tts", "missing", modelBody());
|
||||
}
|
||||
});
|
||||
assertEquals(ErrorCode.MODEL_PROVIDER_NOT_EXIST, error.getCode());
|
||||
}
|
||||
|
||||
verify(modelConfigDao, never()).insert(any(ModelConfigEntity.class));
|
||||
verify(modelConfigDao, never()).updateById(any(ModelConfigEntity.class));
|
||||
}
|
||||
|
||||
private void registerProvider(String requestedType, String registeredType) {
|
||||
ModelProviderDTO provider = new ModelProviderDTO();
|
||||
provider.setModelType(registeredType);
|
||||
provider.setProviderCode("test-provider");
|
||||
when(modelProviderService.getList(requestedType, "test-provider")).thenReturn(List.of(provider));
|
||||
}
|
||||
|
||||
private ModelConfigBodyDTO modelBody() {
|
||||
ModelConfigBodyDTO body = new ModelConfigBodyDTO();
|
||||
body.setId("test-model");
|
||||
body.setModelName("Test model");
|
||||
body.setIsEnabled(1);
|
||||
body.setSort(1);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
+74
@@ -2,16 +2,20 @@ package xiaozhi.modules.timbre.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.timbre.dao.TimbreDao;
|
||||
import xiaozhi.modules.timbre.dto.TimbreDataDTO;
|
||||
import xiaozhi.modules.timbre.entity.TimbreEntity;
|
||||
import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
|
||||
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
|
||||
@@ -54,4 +58,74 @@ class TimbreServiceImplTest {
|
||||
|
||||
assertNull(service.getDefaultLanguageById("voice-id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateLeavesSortOutOfTheUpdateWhenRequestOmitsIt() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, mock(VoiceCloneDao.class), redisUtils);
|
||||
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
|
||||
|
||||
TimbreDataDTO dto = validTimbreData();
|
||||
service.update("voice-id", dto);
|
||||
|
||||
verify(timbreDao, never()).selectById("voice-id");
|
||||
verify(timbreDao).updateById(argThat((TimbreEntity entity) ->
|
||||
"voice-id".equals(entity.getId()) && entity.getSort() == null));
|
||||
verify(redisUtils).delete("timbre:details:voice-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUsesExplicitSortWithoutLoadingExistingTimbre() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(
|
||||
timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class));
|
||||
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
|
||||
TimbreDataDTO dto = validTimbreData();
|
||||
dto.setSort(0L);
|
||||
|
||||
service.update("voice-id", dto);
|
||||
|
||||
verify(timbreDao, never()).selectById("voice-id");
|
||||
verify(timbreDao).updateById(argThat((TimbreEntity entity) -> entity.getSort() == 0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveDefaultsOmittedSortToZero() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(
|
||||
timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class));
|
||||
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
|
||||
|
||||
service.save(validTimbreData());
|
||||
|
||||
verify(timbreDao).insert(argThat((TimbreEntity entity) ->
|
||||
"测试音色".equals(entity.getName()) && entity.getSort() == 0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSupportsLegacyRowsWithNullSort() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(
|
||||
timbreDao, mock(VoiceCloneDao.class), redisUtils);
|
||||
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
|
||||
TimbreEntity entity = new TimbreEntity();
|
||||
entity.setId("voice-id");
|
||||
entity.setSort(null);
|
||||
when(timbreDao.selectById("voice-id")).thenReturn(entity);
|
||||
|
||||
TimbreDetailsVO details = service.get("voice-id");
|
||||
|
||||
assertNull(details.getSort());
|
||||
}
|
||||
|
||||
private TimbreDataDTO validTimbreData() {
|
||||
TimbreDataDTO dto = new TimbreDataDTO();
|
||||
dto.setLanguages("中文");
|
||||
dto.setName("测试音色");
|
||||
dto.setTtsModelId("TTS_Test");
|
||||
dto.setTtsVoice("test-voice");
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@
|
||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs",
|
||||
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs src/pages/agent/components/voicePreviewUtils.test.mjs src/pages/device/deviceTimeUtils.test.mjs",
|
||||
"openapi-ts-request": "openapi-ts",
|
||||
"prepare": "git init && husky",
|
||||
"lint": "eslint",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ModelOption,
|
||||
PageData,
|
||||
RoleTemplate,
|
||||
TtsVoice,
|
||||
} from './types'
|
||||
import { http } from '@/http/request/alova'
|
||||
|
||||
@@ -89,7 +90,7 @@ export function deleteAgent(id: string) {
|
||||
|
||||
// 获取TTS音色列表
|
||||
export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
|
||||
return http.Get<{ id: string, name: string }[]>(`/models/${ttsModelId}/voices`, {
|
||||
return http.Get<TtsVoice[]>(`/models/${ttsModelId}/voices`, {
|
||||
params: {
|
||||
voiceName,
|
||||
},
|
||||
@@ -214,7 +215,7 @@ export function updateAgentTags(agentId: string, data) {
|
||||
|
||||
// 获取所有语言
|
||||
export function getAllLanguage(modelId: string) {
|
||||
return http.Get<{ id: string, name: string, languages: string }[]>(`/models/${modelId}/voices`, {
|
||||
return http.Get<TtsVoice[]>(`/models/${modelId}/voices`, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
@@ -225,6 +226,19 @@ export function getAllLanguage(modelId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取克隆音色的临时播放ID
|
||||
* @param cloneId 克隆音色记录ID
|
||||
*/
|
||||
export function getVoiceCloneAudioId(cloneId: string) {
|
||||
return http.Post<string>(`/voiceClone/audio/${cloneId}`, {}, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取智能体历史版本列表
|
||||
export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) {
|
||||
return http.Get<PageData<AgentSnapshot>>(`/agent/${agentId}/snapshots`, {
|
||||
|
||||
@@ -114,6 +114,14 @@ export interface CorrectWordFile {
|
||||
wordCount?: number
|
||||
}
|
||||
|
||||
export interface TtsVoice {
|
||||
id: string
|
||||
name: string
|
||||
voiceDemo?: string | null
|
||||
languages?: string | null
|
||||
isClone?: boolean | null
|
||||
}
|
||||
|
||||
// 角色模板数据类型
|
||||
export interface RoleTemplate {
|
||||
id: string
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface Device {
|
||||
id: string
|
||||
userId: string
|
||||
macAddress: string
|
||||
lastConnectedAt: string
|
||||
lastConnectedAtTimestamp: string | null
|
||||
autoUpdate: number
|
||||
board: string
|
||||
alias?: string
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
- 劣势:只能使用 2 组图片来切换选中和非选中状态,修改颜色只能重新换图片(或者用 iconfont)。
|
||||
- 2 `有缓存自定义 tabbar`,使用 `switchTab` 切换 tabbar,`tabbar` 页面有缓存。使用了第三方 UI 库的 `tabbar` 组件,并隐藏了原生 `tabbar` 的显示。
|
||||
- 优势:可以随意配置自己想要的 `svg icon`,切换字体颜色方便。有缓存。可以实现各种花里胡哨的动效等。
|
||||
- 劣势:首次点击 tababr 会闪烁。
|
||||
- 劣势:首次点击 tabbar 会闪烁。
|
||||
- 3 `无缓存自定义 tabbar`,使用 `navigateTo` 切换 `tabbar`,`tabbar` 页面无缓存。使用了第三方 UI 库的 `tabbar` 组件。
|
||||
- 优势:可以随意配置自己想要的 svg icon,切换字体颜色方便。可以实现各种花里胡哨的动效等。
|
||||
- 劣势:首次点击 `tababr` 会闪烁,无缓存。
|
||||
- 劣势:首次点击 `tabbar` 会闪烁,无缓存。
|
||||
|
||||
|
||||
> 注意:花里胡哨的效果需要自己实现,本模版不提供。
|
||||
@@ -0,0 +1,42 @@
|
||||
/** @param {Record<string, any>} voice */
|
||||
export function hasVoicePreview(voice) {
|
||||
return Boolean(voice?.isClone || voice?.voiceDemo || voice?.voice_demo)
|
||||
}
|
||||
|
||||
export function createVoicePreviewRequestGate() {
|
||||
let sequence = 0
|
||||
|
||||
return {
|
||||
begin() {
|
||||
sequence += 1
|
||||
return sequence
|
||||
},
|
||||
invalidate() {
|
||||
sequence += 1
|
||||
},
|
||||
isCurrent(requestId) {
|
||||
return requestId === sequence
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ id: string, isClone?: boolean, voiceDemo?: string | null }} voice
|
||||
* @param {(cloneId: string) => Promise<string>} getCloneAudioId
|
||||
* @param {string} baseUrl
|
||||
*/
|
||||
export async function resolveVoicePreviewUrl(voice, getCloneAudioId, baseUrl) {
|
||||
if (!voice?.isClone) {
|
||||
return typeof voice?.voiceDemo === 'string' ? voice.voiceDemo : ''
|
||||
}
|
||||
if (!voice.id) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const uuid = await getCloneAudioId(voice.id)
|
||||
if (!uuid) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `${baseUrl.replace(/\/+$/, '')}/voiceClone/play/${encodeURIComponent(uuid)}`
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './voicePreviewUtils.mjs'
|
||||
|
||||
test('keeps normal voice previews on their direct URL', async () => {
|
||||
let cloneRequestCount = 0
|
||||
const url = await resolveVoicePreviewUrl({
|
||||
id: 'normal-voice',
|
||||
isClone: false,
|
||||
voiceDemo: 'https://cdn.example.test/normal.wav',
|
||||
}, async () => {
|
||||
cloneRequestCount += 1
|
||||
return 'unused'
|
||||
}, 'https://api.example.test')
|
||||
|
||||
assert.equal(url, 'https://cdn.example.test/normal.wav')
|
||||
assert.equal(cloneRequestCount, 0)
|
||||
})
|
||||
|
||||
test('uses the clone record id to obtain and construct a temporary play URL', async () => {
|
||||
let requestedCloneId = ''
|
||||
const url = await resolveVoicePreviewUrl({
|
||||
id: 'clone-record-id',
|
||||
isClone: true,
|
||||
voiceDemo: 'provider-speaker-id-must-not-be-played',
|
||||
}, async (cloneId) => {
|
||||
requestedCloneId = cloneId
|
||||
return 'temporary uuid'
|
||||
}, 'https://api.example.test/')
|
||||
|
||||
assert.equal(requestedCloneId, 'clone-record-id')
|
||||
assert.equal(url, 'https://api.example.test/voiceClone/play/temporary%20uuid')
|
||||
})
|
||||
|
||||
test('shows a preview control for cloned voices even without voiceDemo', () => {
|
||||
assert.equal(hasVoicePreview({ id: 'clone-record-id', isClone: true }), true)
|
||||
assert.equal(hasVoicePreview({ id: 'normal-voice', isClone: false, voiceDemo: '' }), false)
|
||||
})
|
||||
|
||||
test('invalidates an older request when the same voice is cancelled and retried', () => {
|
||||
const gate = createVoicePreviewRequestGate()
|
||||
const firstRequest = gate.begin()
|
||||
|
||||
gate.invalidate()
|
||||
const retryRequest = gate.begin()
|
||||
|
||||
assert.equal(gate.isCurrent(firstRequest), false)
|
||||
assert.equal(gate.isCurrent(retryRequest), true)
|
||||
})
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
|
||||
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate, TtsVoice } from '@/api/agent/types'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, updateAgent } from '@/api/agent/agent'
|
||||
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, getVoiceCloneAudioId, updateAgent } from '@/api/agent/agent'
|
||||
import { t } from '@/i18n'
|
||||
import { usePluginStore, useProvider, useSpeedPitch } from '@/store'
|
||||
import { getEnvBaseUrl } from '@/utils'
|
||||
import { toast } from '@/utils/toast'
|
||||
import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue'
|
||||
import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs'
|
||||
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './components/voicePreviewUtils.mjs'
|
||||
|
||||
defineOptions({
|
||||
name: 'AgentEdit',
|
||||
@@ -84,10 +86,20 @@ const modelOptions = ref<{
|
||||
TTS: [],
|
||||
})
|
||||
|
||||
interface VoiceOption {
|
||||
id?: string
|
||||
value: string
|
||||
name: string
|
||||
voiceDemo?: string | null
|
||||
voice_demo?: string | null
|
||||
isClone: boolean
|
||||
train_status?: number
|
||||
}
|
||||
|
||||
// 音色选项数据
|
||||
const voiceOptions = ref<any[]>([])
|
||||
const voiceOptions = ref<VoiceOption[]>([])
|
||||
// 保存完整的音色信息
|
||||
const voiceDetails = ref<Record<string, any>>({})
|
||||
const voiceDetails = ref<Record<string, TtsVoice>>({})
|
||||
|
||||
// 上报模式选项数据
|
||||
const reportOptions = [
|
||||
@@ -139,6 +151,7 @@ interface SnapshotRestoreContext {
|
||||
// 音频播放相关
|
||||
const audioRef = ref<UniApp.InnerAudioContext | null>(null)
|
||||
const playingVoiceId = ref<string>('')
|
||||
const voicePreviewRequestGate = createVoicePreviewRequestGate()
|
||||
|
||||
// 使用插件store
|
||||
const pluginStore = usePluginStore()
|
||||
@@ -513,8 +526,8 @@ interface TtsSelectionState {
|
||||
languageTouched: boolean
|
||||
voiceTouched: boolean
|
||||
optionsModelId: string
|
||||
voiceOptions: any[]
|
||||
voiceDetails: Record<string, any>
|
||||
voiceOptions: VoiceOption[]
|
||||
voiceDetails: Record<string, TtsVoice>
|
||||
languageOptions: any[]
|
||||
displayNames: {
|
||||
tts: string
|
||||
@@ -565,7 +578,7 @@ function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
const allVoices = Object.values(voiceDetails.value) as any[]
|
||||
const allVoices = Object.values(voiceDetails.value)
|
||||
|
||||
// 根据选中的语言筛选音色
|
||||
const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
|
||||
@@ -624,7 +637,7 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption
|
||||
throw new Error('No TTS voice metadata is available')
|
||||
}
|
||||
// 保存完整的音色信息
|
||||
voiceDetails.value = res.reduce((acc, voice) => {
|
||||
voiceDetails.value = res.reduce<Record<string, TtsVoice>>((acc, voice) => {
|
||||
acc[voice.id] = voice
|
||||
return acc
|
||||
}, {})
|
||||
@@ -863,44 +876,85 @@ function onPickerCancel(type: string) {
|
||||
}
|
||||
|
||||
// 播放音频
|
||||
function playAudio(voiceDemo: string, voiceId: string, event: Event) {
|
||||
async function playAudio(voice: VoiceOption, event: Event) {
|
||||
event.stopPropagation() // 阻止事件冒泡,防止关闭下拉框
|
||||
|
||||
if (!voiceDemo) {
|
||||
if (!hasVoicePreview(voice)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在播放同一个音频,则停止
|
||||
if (playingVoiceId.value === voiceId) {
|
||||
if (playingVoiceId.value === voice.value) {
|
||||
stopAudio()
|
||||
return
|
||||
}
|
||||
|
||||
// 停止之前的音频
|
||||
stopAudio()
|
||||
const requestId = voicePreviewRequestGate.begin()
|
||||
playingVoiceId.value = voice.value
|
||||
|
||||
// 创建新的音频实例
|
||||
audioRef.value = uni.createInnerAudioContext()
|
||||
audioRef.value.src = voiceDemo
|
||||
playingVoiceId.value = voiceId
|
||||
try {
|
||||
const audioUrl = await resolveVoicePreviewUrl({
|
||||
id: voice.value,
|
||||
isClone: voice.isClone,
|
||||
voiceDemo: voice.voiceDemo || voice.voice_demo,
|
||||
}, getVoiceCloneAudioId, getEnvBaseUrl())
|
||||
|
||||
// 监听播放结束
|
||||
audioRef.value.onEnded(() => {
|
||||
// 用户可能在等待克隆音色临时地址时取消或切换了音色。
|
||||
if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
|
||||
return
|
||||
}
|
||||
if (!audioUrl) {
|
||||
toast.error(t('voiceprint.getAudioFailed'))
|
||||
playingVoiceId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新的音频实例
|
||||
const audio = uni.createInnerAudioContext()
|
||||
audioRef.value = audio
|
||||
audio.src = audioUrl
|
||||
|
||||
// 监听播放结束
|
||||
audio.onEnded(() => {
|
||||
if (
|
||||
voicePreviewRequestGate.isCurrent(requestId)
|
||||
&& audioRef.value === audio
|
||||
&& playingVoiceId.value === voice.value
|
||||
) {
|
||||
playingVoiceId.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 监听播放错误
|
||||
audio.onError(() => {
|
||||
if (
|
||||
voicePreviewRequestGate.isCurrent(requestId)
|
||||
&& audioRef.value === audio
|
||||
&& playingVoiceId.value === voice.value
|
||||
) {
|
||||
toast.error(t('voiceprint.audioPlayFailed'))
|
||||
playingVoiceId.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 播放音频
|
||||
audio.play()
|
||||
}
|
||||
catch (error) {
|
||||
if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
|
||||
return
|
||||
}
|
||||
console.error('获取克隆音色试听地址失败:', error)
|
||||
toast.error(t('voiceprint.getAudioFailed'))
|
||||
playingVoiceId.value = ''
|
||||
})
|
||||
|
||||
// 监听播放错误
|
||||
audioRef.value.onError(() => {
|
||||
toast.error('音频播放失败')
|
||||
playingVoiceId.value = ''
|
||||
})
|
||||
|
||||
// 播放音频
|
||||
audioRef.value.play()
|
||||
}
|
||||
}
|
||||
|
||||
// 停止音频
|
||||
function stopAudio() {
|
||||
voicePreviewRequestGate.invalidate()
|
||||
if (audioRef.value) {
|
||||
audioRef.value.stop()
|
||||
audioRef.value.destroy()
|
||||
@@ -1592,10 +1646,10 @@ onMounted(async () => {
|
||||
class="flex items-center justify-between border-b border-[#f5f5f5] p-[32rpx] transition-all active:bg-[#f5f7fb]"
|
||||
@click="onPickerConfirm('voiceprint', voice.value, voice.name)"
|
||||
>
|
||||
<text :class="`flex-1 text-[28rpx] text-[#232338] ${(voice.voiceDemo || voice.voice_demo) ? '' : 'text-center'}`">
|
||||
<text :class="`flex-1 text-[28rpx] text-[#232338] ${hasVoicePreview(voice) ? '' : 'text-center'}`">
|
||||
{{ voice.name }}
|
||||
</text>
|
||||
<view v-if="voice.voiceDemo || voice.voice_demo" class="ml-[20rpx]" @click.stop="playAudio(voice.voiceDemo || voice.voice_demo, voice.value, $event)">
|
||||
<view v-if="hasVoicePreview(voice)" class="ml-[20rpx]" @click.stop="playAudio(voice, $event)">
|
||||
<wd-icon
|
||||
:name="playingVoiceId === voice.value ? 'pause-circle' : 'play-circle'"
|
||||
size="24px"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** @param {unknown} timestamp */
|
||||
export function parseDeviceLastConnectedAtTimestamp(timestamp) {
|
||||
if (typeof timestamp !== 'string' || !timestamp.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const milliseconds = Number(timestamp)
|
||||
if (!Number.isFinite(milliseconds)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = new Date(milliseconds)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
|
||||
|
||||
test('parses the backend Long timestamp serialized as a string', () => {
|
||||
const timestamp = '1783689702000'
|
||||
assert.equal(parseDeviceLastConnectedAtTimestamp(timestamp)?.getTime(), Number(timestamp))
|
||||
})
|
||||
|
||||
test('rejects missing and malformed device timestamps', () => {
|
||||
assert.equal(parseDeviceLastConnectedAtTimestamp(null), null)
|
||||
assert.equal(parseDeviceLastConnectedAtTimestamp(''), null)
|
||||
assert.equal(parseDeviceLastConnectedAtTimestamp('not-a-timestamp'), null)
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { useMessage } from 'wot-design-uni/components/wd-message-box'
|
||||
import { bindDevice, bindDeviceManual, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
|
||||
import { t } from '@/i18n'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
|
||||
|
||||
defineOptions({
|
||||
name: 'DeviceManage',
|
||||
@@ -131,10 +132,10 @@ function getDeviceTypeName(boardKey: string): string {
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timeStr: string) {
|
||||
if (!timeStr)
|
||||
function formatTime(timestamp: string | null) {
|
||||
const date = parseDeviceLastConnectedAtTimestamp(timestamp)
|
||||
if (!date)
|
||||
return t('device.neverConnected')
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
@@ -410,7 +411,7 @@ defineExpose({
|
||||
{{ t('device.firmwareVersion') }}:{{ device.appVersion }}
|
||||
</text>
|
||||
<text class="block text-[28rpx] text-[#65686f] leading-[1.4]">
|
||||
{{ t('device.lastConnection') }}:{{ formatTime(device.lastConnectedAt) }}
|
||||
{{ t('device.lastConnection') }}:{{ formatTime(device.lastConnectedAtTimestamp) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ function showAbout() {
|
||||
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
|
||||
content: t('settings.aboutContent', {
|
||||
appName: import.meta.env.VITE_APP_TITLE,
|
||||
version: '0.9.6',
|
||||
version: '0.9.8',
|
||||
}),
|
||||
showCancel: false,
|
||||
confirmText: t('common.confirm'),
|
||||
|
||||
Generated
+3025
-17
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,9 @@
|
||||
"check:i18n": "node scripts/check-i18n.js",
|
||||
"test:snapshot": "node --test src/components/agentSnapshotDisplayUtils.test.mjs src/apis/module/agentSnapshotApi.test.mjs",
|
||||
"build": "vue-cli-service build",
|
||||
"test:unit": "node --test tests/*.test.mjs",
|
||||
"test:contract": "node --test tests/*Contract.test.mjs",
|
||||
"test:unit": "vitest run",
|
||||
"test": "npm run test:contract && npm run test:unit",
|
||||
"analyze": "cross-env ANALYZE=true vue-cli-service build"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -16,10 +18,12 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"element-ui": "^2.15.14",
|
||||
"flyio": "^0.6.14",
|
||||
"highlight.js": "^11.12.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"opus-decoder": "^0.7.7",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"sm-crypto": "^0.3.13",
|
||||
"v-code-diff": "^1.16.0",
|
||||
"vue": "^2.6.14",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-i18n": "^8.28.2",
|
||||
@@ -30,12 +34,16 @@
|
||||
"devDependencies": {
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-runtime": "^7.26.10",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"@vue/cli-plugin-router": "~5.0.0",
|
||||
"@vue/cli-plugin-vuex": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"@vue/test-utils": "^1.3.6",
|
||||
"compression-webpack-plugin": "^11.1.0",
|
||||
"jsdom": "^24.1.0",
|
||||
"sass": "^1.32.7",
|
||||
"sass-loader": "^12.0.0",
|
||||
"vitest": "^1.6.0",
|
||||
"vue-template-compiler": "^2.6.14",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"workbox-webpack-plugin": "^7.3.0"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Fly from 'flyio/dist/npm/fly';
|
||||
import store from '../store/index';
|
||||
import Constant from '../utils/constant';
|
||||
import { getServiceUrl } from '../apis/api';
|
||||
import { goToPage, isNotNull, showDanger, showWarning } from '../utils/index';
|
||||
import i18n from '../i18n/index';
|
||||
|
||||
@@ -8,13 +9,98 @@ const fly = new Fly()
|
||||
// 设置超时
|
||||
fly.config.timeout = 30000
|
||||
|
||||
// 独立 fly 实例给 silent refresh 用,避免任何回调链回到 sendRequest
|
||||
const refreshFly = new Fly()
|
||||
refreshFly.config.timeout = 30000
|
||||
|
||||
// silent refresh:剩 1h 触发换 token,单飞锁避免并发
|
||||
const REFRESH_AHEAD_MS = 60 * 60 * 1000
|
||||
let refreshPromise = null
|
||||
|
||||
function getTokenExpireAt() {
|
||||
// 与 persistTokenExpireAt 同源读 localStorage(store 走的是 state.token 缓存,
|
||||
// 刚写入 localStorage 后再走 store 拿不到补写的 _localStoredAt)
|
||||
const raw = localStorage.getItem('token')
|
||||
if (!raw) return 0
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || !parsed.expire) return 0
|
||||
// login.vue 写入 store 时是 setToken(now),token 对象里没有 createdAt;
|
||||
// 用 _localStoredAt 作为本地落地时间基准
|
||||
return (parsed._localStoredAt || 0) + parsed.expire * 1000
|
||||
} catch (e) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 首次见到该 token 时记下落地时间;同步刷新 localStorage 与 store,保证两端一致
|
||||
function persistTokenExpireAt() {
|
||||
const raw = localStorage.getItem('token')
|
||||
if (!raw) return
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && parsed.expire && !parsed._localStoredAt) {
|
||||
parsed._localStoredAt = Date.now()
|
||||
const next = JSON.stringify(parsed)
|
||||
localStorage.setItem('token', next)
|
||||
store.commit('setToken', next)
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function shouldRefresh() {
|
||||
persistTokenExpireAt()
|
||||
const exp = getTokenExpireAt()
|
||||
if (!exp) return false
|
||||
return exp - Date.now() < REFRESH_AHEAD_MS
|
||||
}
|
||||
|
||||
function doRefresh() {
|
||||
// 直接走 fly.request,绕过 sendRequest,避免死循环触发 silent refresh
|
||||
const raw = localStorage.getItem('token')
|
||||
if (!raw) return Promise.reject(new Error('no token'))
|
||||
let bearer
|
||||
try {
|
||||
bearer = JSON.parse(raw).token
|
||||
} catch (e) {
|
||||
return Promise.reject(e)
|
||||
}
|
||||
return refreshFly.request(getServiceUrl() + '/user/refresh', {}, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + bearer }
|
||||
}).then((res) => {
|
||||
const body = res && res.data
|
||||
if (!body || (body.code !== 0 && body.code !== 'success')) {
|
||||
throw new Error('refresh failed: ' + (body && body.msg))
|
||||
}
|
||||
const newDto = body.data
|
||||
newDto._localStoredAt = Date.now()
|
||||
store.commit('setToken', JSON.stringify(newDto))
|
||||
return newDto
|
||||
})
|
||||
}
|
||||
|
||||
function triggerSilentRefresh() {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = doRefresh().catch((err) => {
|
||||
store.commit('clearAuth')
|
||||
goToPage(Constant.PAGE.LOGIN, true)
|
||||
throw err
|
||||
}).finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Request服务封装
|
||||
*/
|
||||
export default {
|
||||
sendRequest,
|
||||
reAjaxFun,
|
||||
clearRequestTime
|
||||
clearRequestTime,
|
||||
triggerSilentRefresh
|
||||
}
|
||||
|
||||
function sendRequest() {
|
||||
@@ -37,30 +123,41 @@ function sendRequest() {
|
||||
acceptLanguage = 'en-US';
|
||||
}
|
||||
this._header['Accept-Language'] = acceptLanguage;
|
||||
|
||||
if (isNotNull(store.getters.getToken)) {
|
||||
this._header.Authorization = 'Bearer ' + (JSON.parse(store.getters.getToken)).token
|
||||
|
||||
const doSend = () => {
|
||||
if (isNotNull(store.getters.getToken)) {
|
||||
this._header.Authorization = 'Bearer ' + (JSON.parse(store.getters.getToken)).token
|
||||
}
|
||||
|
||||
// 打印请求信息
|
||||
fly.request(this._url, this._data, {
|
||||
method: this._method,
|
||||
headers: this._header,
|
||||
responseType: this._responseType
|
||||
}).then((res) => {
|
||||
const error = httpHandlerError(res, this._failCallback, this._networkFailCallback);
|
||||
if (error) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this._sucCallback) {
|
||||
this._sucCallback(res)
|
||||
}
|
||||
}).catch((res) => {
|
||||
// 打印失败响应
|
||||
console.log('catch', res)
|
||||
httpHandlerError(res, this._failCallback, this._networkFailCallback)
|
||||
})
|
||||
}
|
||||
|
||||
// 打印请求信息
|
||||
fly.request(this._url, this._data, {
|
||||
method: this._method,
|
||||
headers: this._header,
|
||||
responseType: this._responseType
|
||||
}).then((res) => {
|
||||
const error = httpHandlerError(res, this._failCallback, this._networkFailCallback);
|
||||
if (error) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this._sucCallback) {
|
||||
this._sucCallback(res)
|
||||
}
|
||||
}).catch((res) => {
|
||||
// 打印失败响应
|
||||
console.log('catch', res)
|
||||
httpHandlerError(res, this._failCallback, this._networkFailCallback)
|
||||
})
|
||||
// 剩 1h 内过期就先 silent refresh 再发,确保请求带新 token
|
||||
if (shouldRefresh()) {
|
||||
triggerSilentRefresh().then(doSend).catch(() => {
|
||||
// 触发 refresh 后被拦截跳登录,这里什么都不用做
|
||||
})
|
||||
} else {
|
||||
doSend()
|
||||
}
|
||||
return this
|
||||
},
|
||||
'success'(callback) {
|
||||
|
||||
@@ -27,7 +27,7 @@ export default {
|
||||
getFileList(params, callback) {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize
|
||||
limit: params.pageSize
|
||||
}).toString();
|
||||
|
||||
RequestService.sendRequest()
|
||||
|
||||
@@ -3,7 +3,7 @@ import RequestService from '../httpRequest';
|
||||
|
||||
export default {
|
||||
// 已绑设备
|
||||
getAgentBindDevices(agentId, callback) {
|
||||
getAgentBindDevices(agentId, callback, failCallback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/device/bind/${agentId}`)
|
||||
.method('GET')
|
||||
@@ -11,15 +11,21 @@ export default {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
RequestService.clearRequestTime();
|
||||
if (failCallback) {
|
||||
failCallback(err);
|
||||
}
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('获取设备列表失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getAgentBindDevices(agentId, callback);
|
||||
this.getAgentBindDevices(agentId, callback, failCallback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 解绑设备
|
||||
unbindDevice(device_id, callback) {
|
||||
unbindDevice(device_id, callback, failCallback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/device/unbind`)
|
||||
.method('POST')
|
||||
@@ -28,15 +34,21 @@ export default {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
RequestService.clearRequestTime();
|
||||
if (failCallback) {
|
||||
failCallback(err);
|
||||
}
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('解绑设备失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.unbindDevice(device_id, callback);
|
||||
this.unbindDevice(device_id, callback, failCallback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 绑定设备
|
||||
bindDevice(agentId, deviceCode, callback) {
|
||||
bindDevice(agentId, deviceCode, callback, failCallback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/device/bind/${agentId}/${deviceCode}`)
|
||||
.method('POST')
|
||||
@@ -44,14 +56,20 @@ export default {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
RequestService.clearRequestTime();
|
||||
if (failCallback) {
|
||||
failCallback(err);
|
||||
}
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('绑定设备失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.bindDevice(agentId, deviceCode, callback);
|
||||
this.bindDevice(agentId, deviceCode, callback, failCallback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
updateDeviceInfo(id, payload, callback) {
|
||||
updateDeviceInfo(id, payload, callback, failCallback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/device/update/${id}`)
|
||||
.method('PUT')
|
||||
@@ -60,16 +78,22 @@ export default {
|
||||
RequestService.clearRequestTime()
|
||||
callback(res)
|
||||
})
|
||||
.fail((err) => {
|
||||
RequestService.clearRequestTime();
|
||||
if (failCallback) {
|
||||
failCallback(err);
|
||||
}
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('更新OTA状态失败:', err)
|
||||
this.$message.error(err.msg || '更新OTA状态失败')
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.updateDeviceInfo(id, payload, callback)
|
||||
this.updateDeviceInfo(id, payload, callback, failCallback)
|
||||
})
|
||||
}).send()
|
||||
},
|
||||
// 手动添加设备
|
||||
manualAddDevice(params, callback) {
|
||||
manualAddDevice(params, callback, failCallback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/device/manual-add`)
|
||||
.method('POST')
|
||||
@@ -78,15 +102,21 @@ export default {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
RequestService.clearRequestTime();
|
||||
if (failCallback) {
|
||||
failCallback(err);
|
||||
}
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('手动添加设备失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.manualAddDevice(params, callback);
|
||||
this.manualAddDevice(params, callback, failCallback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 获取设备状态
|
||||
getDeviceStatus(agentId, callback) {
|
||||
getDeviceStatus(agentId, callback, failCallback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/device/bind/${agentId}`)
|
||||
.method('POST')
|
||||
@@ -95,14 +125,17 @@ export default {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((res) => {
|
||||
callback(res);
|
||||
.fail((err) => {
|
||||
RequestService.clearRequestTime();
|
||||
if (failCallback) {
|
||||
failCallback(err);
|
||||
}
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('获取设备状态失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getDeviceStatus(agentId, callback);
|
||||
this.getDeviceStatus(agentId, callback, failCallback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ export default {
|
||||
remark: params.remark,
|
||||
referenceAudio: params.referenceAudio,
|
||||
referenceText: params.referenceText,
|
||||
sort: params.sort,
|
||||
ttsModelId: params.ttsModelId,
|
||||
ttsVoice: params.voiceCode,
|
||||
voiceDemo: params.voiceDemo || ''
|
||||
@@ -93,4 +94,4 @@ export default {
|
||||
});
|
||||
}).send();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export default {
|
||||
})
|
||||
.networkFail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.login(loginForm, callback)
|
||||
this.login(loginForm, callback, failCallback)
|
||||
})
|
||||
}).send()
|
||||
},
|
||||
|
||||
@@ -1,39 +1,37 @@
|
||||
<template>
|
||||
<el-dialog :visible="visible" @close="handleClose" width="24%" center>
|
||||
<div
|
||||
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||
<div
|
||||
style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
|
||||
<img src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
|
||||
<CustomDialog
|
||||
:title="$t('device.dialogTitle')"
|
||||
:visible.sync="visible"
|
||||
width="420px"
|
||||
:confirmLoading="loading"
|
||||
@confirm="confirm"
|
||||
@cancel="cancel"
|
||||
@close="handleClose"
|
||||
:confirmText="$t('device.confirmButton')"
|
||||
:cancelText="$t('device.cancelButton')"
|
||||
>
|
||||
<div class="add-device-body">
|
||||
<div class="add-device-label">
|
||||
<span class="required">*</span>
|
||||
{{ $t('device.verificationCode') }}
|
||||
</div>
|
||||
{{ $t('device.dialogTitle') }}
|
||||
<el-input
|
||||
:placeholder="$t('device.verificationCodePlaceholder')"
|
||||
v-model="deviceCode"
|
||||
class="add-device-input"
|
||||
@keyup.enter.native="confirm"
|
||||
/>
|
||||
</div>
|
||||
<div style="height: 1px;background: #e8f0ff;" />
|
||||
<div style="margin: 22px 15px;">
|
||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
|
||||
<div style="color: red;display: inline-block;">*</div>
|
||||
<span style="font-size: 11px"> {{ $t('device.verificationCode') }}</span>
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
<el-input :placeholder="$t('device.verificationCodePlaceholder')" v-model="deviceCode" @keyup.enter.native="confirm" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex;margin: 15px 15px;gap: 7px;">
|
||||
<div class="dialog-btn" @click="confirm">
|
||||
{{ $t('device.confirmButton') }}
|
||||
</div>
|
||||
<div class="dialog-btn" style="background: #e6ebff;border: 1px solid #adbdff;color: #5778ff;" @click="cancel">
|
||||
{{ $t('device.cancelButton') }}
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</CustomDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Api from '@/apis/api';
|
||||
import CustomDialog from './CustomDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'AddDeviceDialog',
|
||||
components: { CustomDialog },
|
||||
props: {
|
||||
visible: { type: Boolean, required: true },
|
||||
agentId: { type: String, required: true }
|
||||
@@ -53,7 +51,8 @@ export default {
|
||||
this.loading = true;
|
||||
Api.device.bindDevice(
|
||||
this.agentId,
|
||||
this.deviceCode, ({ data }) => {
|
||||
this.deviceCode,
|
||||
({ data }) => {
|
||||
this.loading = false;
|
||||
if (data.code === 0) {
|
||||
this.$emit('refresh');
|
||||
@@ -68,59 +67,53 @@ export default {
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
this.loading = false;
|
||||
const msg = err && err.data && err.data.msg;
|
||||
this.$message.error({
|
||||
message: msg || this.$t('device.bindFailed'),
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
closeDialog() {
|
||||
this.$emit('update:visible', false);
|
||||
this.loading = false;
|
||||
this.deviceCode = '';
|
||||
|
||||
this.$emit('update:visible', false);
|
||||
},
|
||||
cancel() {
|
||||
this.$emit('update:visible', false)
|
||||
this.deviceCode = ""
|
||||
this.closeDialog();
|
||||
},
|
||||
handleClose() {
|
||||
this.$emit('update:visible', false);
|
||||
},
|
||||
this.closeDialog();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-46 {
|
||||
border: 1px solid #e4e6ef;
|
||||
background: #f6f8fb;
|
||||
border-radius: 10px;
|
||||
.add-device-body {
|
||||
padding: 6px 4px 4px;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
border-radius: 23px;
|
||||
background: #5778ff;
|
||||
.add-device-label {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
text-align: left;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.add-device-label .required {
|
||||
color: #f56c6c;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.add-device-input ::v-deep(.el-input__inner) {
|
||||
height: 40px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog {
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__headerbtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__body {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__header {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border-color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,8 @@
|
||||
<CustomDialog
|
||||
:visible.sync="dialogVisible"
|
||||
:title="$t('modelConfigDialog.addModel')"
|
||||
width="57%"
|
||||
width="960px"
|
||||
size="large"
|
||||
class="add-model-dialog"
|
||||
:confirmLoading="saving"
|
||||
@close="handleClose"
|
||||
@@ -76,7 +77,14 @@
|
||||
<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-input v-model="formData.configJson[field.prop]" :placeholder="field.placeholder"
|
||||
<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"
|
||||
:rows="3" :placeholder="field.placeholder" class="custom-input-bg">
|
||||
</el-input>
|
||||
<el-input v-else v-model="formData.configJson[field.prop]" :placeholder="field.placeholder"
|
||||
:type="field.type || 'text'" class="custom-input-bg" :show-password="field.type === 'password'">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
@@ -160,7 +168,8 @@ export default {
|
||||
fields: JSON.parse(item.fields || '[]').map(f => ({
|
||||
label: f.label,
|
||||
prop: f.key,
|
||||
type: f.type === 'password' ? 'password' : 'text',
|
||||
type: f.type === 'dict' ? 'json-textarea' : f.type === 'password' ? 'password' : (f.type === 'select' ? 'select' : 'text'),
|
||||
options: f.options || [],
|
||||
placeholder: `请输入${f.key}`
|
||||
}))
|
||||
}))
|
||||
@@ -203,6 +212,24 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// dict 字段必须解析为 JSON 对象再提交,否则会以字符串入库
|
||||
for (const field of this.providerFields) {
|
||||
if (field.type !== 'json-textarea') continue;
|
||||
const raw = this.formData.configJson[field.prop];
|
||||
if (!raw || typeof raw === 'object') continue;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('not object');
|
||||
}
|
||||
this.formData.configJson[field.prop] = parsed;
|
||||
} catch (e) {
|
||||
this.$message.error('必须输入字典格式(如 {"key":"value"})');
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
id: this.formData.id || '',
|
||||
modelName: this.formData.modelName || '',
|
||||
|
||||
@@ -1,51 +1,53 @@
|
||||
<template>
|
||||
<el-dialog :visible="visible" @close="handleClose" width="25%" center @open="handleOpen">
|
||||
<div
|
||||
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||
<div
|
||||
style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
|
||||
<img loading="lazy" src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
|
||||
<CustomDialog
|
||||
:title="$t('addAgentDialog.title')"
|
||||
:visible.sync="visible"
|
||||
width="420px"
|
||||
@confirm="confirm"
|
||||
@cancel="cancel"
|
||||
@close="handleClose"
|
||||
:confirmText="$t('addAgentDialog.confirm')"
|
||||
:cancelText="$t('addAgentDialog.cancel')"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="add-agent-body">
|
||||
<div class="add-agent-label">
|
||||
<span class="required">*</span>
|
||||
{{ $t('addAgentDialog.agentName') }}
|
||||
</div>
|
||||
{{ $t('addAgentDialog.title') }}
|
||||
<el-input
|
||||
maxLength="64"
|
||||
ref="inputRef"
|
||||
:placeholder="$t('addAgentDialog.placeholder')"
|
||||
v-model="wisdomBodyName"
|
||||
class="add-agent-input"
|
||||
@keyup.enter.native="confirm"
|
||||
/>
|
||||
</div>
|
||||
<div style="height: 1px;background: #e8f0ff;" />
|
||||
<div style="margin: 22px 15px;">
|
||||
<div style="font-weight: 400;text-align: left;color: #3d4566;">
|
||||
<div style="color: red;display: inline-block;">*</div> {{ $t('addAgentDialog.agentName') }}:
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
<el-input maxLength="64" ref="inputRef" :placeholder="$t('addAgentDialog.placeholder')" v-model="wisdomBodyName" @keyup.enter.native="confirm" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex;margin: 15px 15px;gap: 7px;">
|
||||
<div class="dialog-btn" @click="confirm">
|
||||
{{ $t('addAgentDialog.confirm') }}
|
||||
</div>
|
||||
<div class="dialog-btn" style="background: #e6ebff;border: 1px solid #adbdff;color: #5778ff;" @click="cancel">
|
||||
{{ $t('addAgentDialog.cancel') }}
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</CustomDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Api from '@/apis/api';
|
||||
import CustomDialog from './CustomDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'AddWisdomBodyDialog',
|
||||
components: { CustomDialog },
|
||||
props: {
|
||||
visible: { type: Boolean, required: true }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
wisdomBodyName: "",
|
||||
inputRef: null
|
||||
wisdomBodyName: ""
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleOpen() {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.inputRef.focus();
|
||||
if (this.$refs.inputRef && this.$refs.inputRef.focus) {
|
||||
this.$refs.inputRef.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
confirm() {
|
||||
@@ -64,50 +66,37 @@ export default {
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
this.$emit('update:visible', false)
|
||||
this.wisdomBodyName = ""
|
||||
},
|
||||
handleClose() {
|
||||
this.wisdomBodyName = "";
|
||||
this.$emit('update:visible', false);
|
||||
},
|
||||
handleClose() {
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-46 {
|
||||
border: 1px solid #e4e6ef;
|
||||
background: #f6f8fb;
|
||||
border-radius: 15px;
|
||||
.add-agent-body {
|
||||
padding: 6px 4px 4px;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
border-radius: 23px;
|
||||
background: #5778ff;
|
||||
.add-agent-label {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
text-align: left;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.add-agent-label .required {
|
||||
color: #f56c6c;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.add-agent-input ::v-deep(.el-input__inner) {
|
||||
height: 40px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog {
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__headerbtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__body {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__header {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border-color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<el-dialog
|
||||
:title="$t('agentSnapshot.title')"
|
||||
:visible="visible"
|
||||
width="860px"
|
||||
width="960px"
|
||||
class="agent-snapshot-dialog"
|
||||
:before-close="guardRestoreInFlightClose"
|
||||
:close-on-click-modal="!restoring"
|
||||
@@ -126,7 +126,7 @@
|
||||
<el-dialog
|
||||
:title="detailDialogTitle"
|
||||
:visible.sync="detailVisible"
|
||||
width="860px"
|
||||
width="960px"
|
||||
class="snapshot-detail-dialog"
|
||||
>
|
||||
<template slot="title">
|
||||
@@ -269,8 +269,21 @@
|
||||
</div>
|
||||
<div v-else class="value-empty">{{ $t('agentSnapshot.noFunctionChange') }}</div>
|
||||
</div>
|
||||
<div v-else-if="shouldUseCodeDiff(item)" class="diff-code-wrapper">
|
||||
<CodeDiff
|
||||
:old-string="item.beforeText"
|
||||
:new-string="item.afterText"
|
||||
:language="codeDiffLanguage(item)"
|
||||
output-format="side-by-side"
|
||||
diff-style="word"
|
||||
:context="8"
|
||||
max-height="320px"
|
||||
:filename="item.beforeTitle"
|
||||
:new-filename="item.afterTitle"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="diff-values" :class="{ 'is-complex': item.complex }">
|
||||
<div class="diff-pane diff-before">
|
||||
<div class="diff-pane diff-before 1">
|
||||
<div class="diff-pane-title">{{ item.beforeTitle }}</div>
|
||||
<div class="diff-value" :class="valueClass(item)">
|
||||
<div
|
||||
@@ -306,7 +319,7 @@
|
||||
<el-dialog
|
||||
:title="restorePreviewTitle"
|
||||
:visible.sync="restorePreviewVisible"
|
||||
width="860px"
|
||||
width="960px"
|
||||
class="snapshot-detail-dialog"
|
||||
:before-close="guardRestoreInFlightClose"
|
||||
:close-on-click-modal="!restoring"
|
||||
@@ -401,8 +414,21 @@
|
||||
</div>
|
||||
<div v-else class="value-empty">{{ $t('agentSnapshot.noFunctionChange') }}</div>
|
||||
</div>
|
||||
<div v-else-if="shouldUseCodeDiff(item)" class="diff-code-wrapper">
|
||||
<CodeDiff
|
||||
:old-string="item.beforeText"
|
||||
:new-string="item.afterText"
|
||||
:language="codeDiffLanguage(item)"
|
||||
output-format="side-by-side"
|
||||
diff-style="word"
|
||||
:context="8"
|
||||
max-height="320px"
|
||||
:filename="item.beforeTitle"
|
||||
:new-filename="item.afterTitle"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="diff-values" :class="{ 'is-complex': item.complex }">
|
||||
<div class="diff-pane diff-before">
|
||||
<div class="diff-pane diff-before 2">
|
||||
<div class="diff-pane-title">{{ item.beforeTitle }}</div>
|
||||
<div class="diff-value" :class="valueClass(item)">
|
||||
<div
|
||||
@@ -485,6 +511,8 @@
|
||||
import Api from "@/apis/api";
|
||||
import correctWord from "@/apis/module/correctWord";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import { CodeDiff, hljs } from "v-code-diff";
|
||||
import markdown from "highlight.js/lib/languages/markdown";
|
||||
import {
|
||||
hasValidCurrentStateToken,
|
||||
normalizeSnapshotOrderedValue,
|
||||
@@ -492,13 +520,14 @@ import {
|
||||
SNAPSHOT_SECRET_REDACTED
|
||||
} from "./agentSnapshotDisplayUtils.mjs";
|
||||
|
||||
hljs.registerLanguage("markdown", markdown);
|
||||
|
||||
const FALLBACK_PLUGIN_NAME_KEYS = {
|
||||
SYSTEM_PLUGIN_WEATHER: "agentSnapshot.plugin.SYSTEM_PLUGIN_WEATHER",
|
||||
SYSTEM_PLUGIN_MUSIC: "agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC",
|
||||
SYSTEM_PLUGIN_NEWS_CHINANEWS: "agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS",
|
||||
SYSTEM_PLUGIN_NEWS_NEWSNOW: "agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW",
|
||||
SYSTEM_PLUGIN_HA_GET_STATE: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE",
|
||||
SYSTEM_PLUGIN_HA_SET_STATE: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE",
|
||||
SYSTEM_PLUGIN_HA_STATE: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_STATE",
|
||||
SYSTEM_PLUGIN_HA_PLAY_MUSIC: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC",
|
||||
SYSTEM_PLUGIN_WEB_SEARCH: "agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH",
|
||||
SYSTEM_PLUGIN_CALL_DEVICE: "agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE"
|
||||
@@ -560,6 +589,9 @@ const CHAT_HISTORY_CONF_LABEL_KEYS = {
|
||||
};
|
||||
export default {
|
||||
name: "AgentSnapshotDialog",
|
||||
components: {
|
||||
CodeDiff
|
||||
},
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
@@ -1544,6 +1576,18 @@ export default {
|
||||
}
|
||||
return "text";
|
||||
},
|
||||
shouldUseCodeDiff(item) {
|
||||
return Boolean(
|
||||
item &&
|
||||
!item.single &&
|
||||
(item.displayType === "markdown" || item.complex || item.field === "summaryMemory")
|
||||
);
|
||||
},
|
||||
codeDiffLanguage(item) {
|
||||
return item?.displayType === "markdown" || item?.field === "summaryMemory"
|
||||
? "markdown"
|
||||
: "json";
|
||||
},
|
||||
valueClass(item) {
|
||||
return [`is-${item.displayType || "text"}`];
|
||||
},
|
||||
@@ -2068,10 +2112,21 @@ export default {
|
||||
@import '@/styles/global.scss';
|
||||
|
||||
::v-deep .el-dialog {
|
||||
margin-top: 6vh !important;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin: 10px auto !important;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
height: calc(100vh - 20px);
|
||||
max-height: calc(100vh - 20px);
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__wrapper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__header {
|
||||
@@ -2140,6 +2195,7 @@ export default {
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__body {
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -2227,9 +2283,7 @@ export default {
|
||||
}
|
||||
|
||||
.snapshot-table-wrapper {
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
@include scrollbar-style;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.version-cell {
|
||||
@@ -2273,11 +2327,8 @@ export default {
|
||||
}
|
||||
|
||||
.snapshot-diff {
|
||||
min-height: 220px;
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
min-height: 420px;
|
||||
padding-right: 2px;
|
||||
@include scrollbar-style;
|
||||
}
|
||||
|
||||
.restore-risk-alert {
|
||||
@@ -2342,6 +2393,12 @@ export default {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.diff-code-wrapper {
|
||||
padding: 0 14px 1px;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.diff-pane-title {
|
||||
margin-bottom: 8px;
|
||||
color: #3d4566;
|
||||
@@ -2389,6 +2446,43 @@ export default {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
::v-deep .code-diff-view,
|
||||
::v-deep .code-diff-view .blob-num,
|
||||
::v-deep .code-diff-view .blob-code-inner,
|
||||
::v-deep .code-diff-view .file-header {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
::v-deep .code-diff-view::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::v-deep .code-diff-view::-webkit-scrollbar-thumb {
|
||||
background: #a1c9fd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::v-deep .code-diff-view::-webkit-scrollbar-track {
|
||||
background: #f0f3fe;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::v-deep .code-diff-view .blob-code-inner {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
::v-deep .code-diff-view .hljs-emphasis,
|
||||
::v-deep .code-diff-view em {
|
||||
font-style: normal !important;
|
||||
}
|
||||
|
||||
::v-deep .code-diff-view .diff-table .blob-code-deletion .x,
|
||||
::v-deep .code-diff-view .diff-table .blob-code-addition .x {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.function-change-view {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<el-dialog
|
||||
:title="$t('cache.dialogTitle')"
|
||||
:visible.sync="visible"
|
||||
width="70%"
|
||||
width="80%"
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
@@ -204,4 +204,24 @@ h3 {
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog {
|
||||
margin: 10px auto !important;
|
||||
height: calc(100vh - 20px);
|
||||
max-height: calc(100vh - 20px);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__wrapper {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__body {
|
||||
flex: 1;
|
||||
max-height: calc(100vh - 20px - 130px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
:title="$t('chatHistory.with') + agentName + $t('chatHistory.dialogTitle')"
|
||||
:visible.sync="dialogVisible"
|
||||
width="80%"
|
||||
size="large"
|
||||
:footer="false"
|
||||
:close-on-click-modal="false"
|
||||
custom-class="chat-history-dialog">
|
||||
@@ -413,7 +414,8 @@ export default {
|
||||
<style scoped>
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
@@ -610,10 +612,6 @@ export default {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
border-top: 1px solid #eee;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@@ -624,24 +622,18 @@ export default {
|
||||
|
||||
<style>
|
||||
.chat-history-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 700px;
|
||||
margin: 0 !important;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 98vh;
|
||||
max-width: 85vw;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
/* 兜底,防止 size="large" 那个 height 没有生效时弹框缩成内容高度 */
|
||||
height: calc(100vh - 20px) !important;
|
||||
}
|
||||
|
||||
.chat-history-dialog .el-dialog__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
height: calc(90vh - 54px);
|
||||
/* 减去标题栏的高度 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 顶开 size="large" 默认的 100vh - 130px,让 body 真正撑到弹框底部,flex: 1 会自动填满 */
|
||||
max-height: calc(100vh - 74px) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:visible.sync="dialogVisible"
|
||||
width="900px"
|
||||
<CustomDialog
|
||||
:title="$t('contextProviderDialog.title')"
|
||||
:close-on-click-modal="false"
|
||||
custom-class="context-provider-dialog"
|
||||
append-to-body
|
||||
:visible.sync="dialogVisible"
|
||||
width="960px"
|
||||
size="large"
|
||||
class="context-provider-dialog"
|
||||
:closeOnClickModal="false"
|
||||
:confirmText="$t('contextProviderDialog.confirm')"
|
||||
:cancelText="$t('contextProviderDialog.cancel')"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<el-empty v-if="localProviders.length === 0" :description="$t('contextProviderDialog.noContextApi')">
|
||||
@@ -51,7 +54,7 @@
|
||||
size="small"
|
||||
class="flex-1"
|
||||
></el-input>
|
||||
|
||||
|
||||
<div class="row-controls">
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -104,17 +107,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">{{ $t('contextProviderDialog.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">{{ $t('contextProviderDialog.confirm') }}</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
</CustomDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CustomDialog from './CustomDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'ContextProviderDialog',
|
||||
components: { CustomDialog },
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
@@ -157,7 +158,7 @@ export default {
|
||||
headers: Object.entries(headers).map(([key, value]) => ({ key, value }))
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// 如果为空,添加一个默认块
|
||||
if (this.localProviders.length === 0) {
|
||||
this.localProviders.push({ url: '', headers: [{ key: '', value: '' }] });
|
||||
@@ -193,19 +194,22 @@ export default {
|
||||
headers: headersObj
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
this.$emit('confirm', result);
|
||||
this.dialogVisible = false;
|
||||
this.$emit('update:visible', false);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-provider-dialog ::v-deep .el-dialog__body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.dialog-content {
|
||||
max-height: 60vh;
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
padding: 20px 25px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.dialog-content::-webkit-scrollbar {
|
||||
@@ -230,7 +234,7 @@ export default {
|
||||
flex: 1;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-left: 4px solid #409EFF; /* 左侧强调色 */
|
||||
border-left: 4px solid #409EFF;
|
||||
background-color: #fff;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
@@ -261,7 +265,7 @@ export default {
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
line-height: 32px; /* 垂直居中对齐 */
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
@@ -327,3 +331,4 @@ export default {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:close-on-press-escape="closeOnPressEscape"
|
||||
:show-close="showClose"
|
||||
:destroy-on-close="destroyOnClose"
|
||||
:custom-class="customClass"
|
||||
:custom-class="dialogCustomClass"
|
||||
class="custom-dialog"
|
||||
@close="handleClose"
|
||||
@open="handleOpen"
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: "600px"
|
||||
default: "720px"
|
||||
},
|
||||
footer: {
|
||||
type: Boolean,
|
||||
@@ -80,6 +80,11 @@ export default {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "small",
|
||||
validator: (val) => ["small", "large"].includes(val),
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: "取消"
|
||||
@@ -94,6 +99,15 @@ export default {
|
||||
dialogVisible: this.visible
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dialogCustomClass() {
|
||||
const classes = ["custom-dialog"];
|
||||
if (this.customClass) classes.push(this.customClass);
|
||||
if (this.size === "large") classes.push("custom-dialog--large");
|
||||
if (String(this.width).includes("%")) classes.push("custom-dialog--fluid");
|
||||
return classes.join(" ");
|
||||
},
|
||||
},
|
||||
components: {
|
||||
CustomButton
|
||||
},
|
||||
@@ -124,110 +138,134 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-dialog {
|
||||
::v-deep .el-dialog {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
/* 大档与小档通过 .custom-dialog--large 区分 */
|
||||
::v-deep .el-dialog.custom-dialog {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin: 5vh auto !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 10vh);
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__header {
|
||||
padding: 16px 20px 12px;
|
||||
background: linear-gradient(135deg, #e2eeff, #edeafe);
|
||||
text-align: left;
|
||||
}
|
||||
::v-deep .el-dialog__body {
|
||||
max-height: calc(100vh - 220px);
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__title {
|
||||
font-size: 16px;
|
||||
/* .v-modal 样式由 global.scss 全局定义 */
|
||||
::v-deep .el-dialog__header {
|
||||
padding: 16px 20px 12px;
|
||||
background: linear-gradient(135deg, #e2eeff, #edeafe);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
::v-deep .dialog-title {
|
||||
font-size: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
> span {
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
::v-deep .title-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__headerbtn {
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.el-dialog__close {
|
||||
font-size: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
> span {
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
color: #666;
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__headerbtn {
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
&:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
|
||||
.el-dialog__close {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
|
||||
.el-dialog__close {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__footer {
|
||||
padding: 12px 20px 16px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.el-button {
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: linear-gradient(to right, #4a7cfd, #8154fc);
|
||||
border: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: linear-gradient(to right, #4a7cfd, #8154fc);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__footer {
|
||||
padding: 12px 20px 16px;
|
||||
}
|
||||
|
||||
::v-deep .dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.el-button {
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: linear-gradient(to right, #4a7cfd, #8154fc);
|
||||
border: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: linear-gradient(to right, #4a7cfd, #8154fc);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大档:上下各留 10px 让边框和圆角可见 */
|
||||
::v-deep .el-dialog.custom-dialog.custom-dialog--large {
|
||||
margin: 10px auto !important;
|
||||
height: calc(100vh - 20px);
|
||||
max-height: calc(100vh - 20px);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog.custom-dialog.custom-dialog--large.custom-dialog--fluid {
|
||||
max-width: var(--dialog-table-max);
|
||||
}
|
||||
|
||||
::v-deep .el-dialog.custom-dialog--large .el-dialog__body {
|
||||
max-height: calc(100vh - 20px - 110px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user