Compare commits
162
Commits
v0.9.6
...
py-test-mcp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bf619e2dd | ||
|
|
1120be2f60 | ||
|
|
9880ce998f | ||
|
|
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 | ||
|
|
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
|
||||
@@ -353,11 +353,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>
|
||||
+2
-2
@@ -70,7 +70,7 @@ xiaozhi-server
|
||||
|
||||
## 2. 配置项目文件
|
||||
|
||||
接下里,程序还不能直接运行,你需要配置一下,你到底使用的是什么模型。你可以看这个教程:
|
||||
接下来,程序还不能直接运行,你需要配置一下,你到底使用的是什么模型。你可以看这个教程:
|
||||
[跳转到配置项目文件](#配置项目)
|
||||
|
||||
配置完项目文件后,回到本教程继续往下。
|
||||
@@ -119,7 +119,7 @@ docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
|
||||
如果确定使用`conda`,则安装好后,开始执行以下命令。
|
||||
|
||||
重要提示!windows 用户,可以通过安装`Anaconda`来管理环境。安装好`Anaconda`后,在`开始`那里搜索`anaconda`相关的关键词,
|
||||
找到`Anaconda Prpmpt`,使用管理员身份运行它。如下图。
|
||||
找到`Anaconda Prompt`,使用管理员身份运行它。如下图。
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
保存好后执行赋权命令
|
||||
|
||||
@@ -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`)保持一致。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
声纹识别需要依赖`mysql`数据库。如果你之前已经部署`智控台`,说明你已经安装了`mysql`。你可以共用它。
|
||||
|
||||
你可以你试一下在宿主机使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
|
||||
你可以试一下在宿主机使用`telnet`命令,看看能不能正常访问`mysql`的`3306`端口。
|
||||
```
|
||||
telnet 127.0.0.1 3306
|
||||
```
|
||||
|
||||
@@ -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获取激活码
|
||||
*
|
||||
|
||||
+103
-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,16 @@ 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();
|
||||
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(callerMac);
|
||||
if (callerDevice != null) {
|
||||
callerNickname = callerDevice.getAlias();
|
||||
}
|
||||
if (StringUtils.isBlank(callerNickname)) {
|
||||
callerNickname = formatMacAsDeviceName(callerMac);
|
||||
}
|
||||
@@ -100,32 +112,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 +142,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 +208,22 @@ 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 为空,就默认使用设备名称
|
||||
// 如果 alias 为空,就默认使用设备名称;对方设备尚未登记时退回尾号称呼
|
||||
if (StringUtils.isBlank(alias)) {
|
||||
alias = deviceService.getDeviceByMacAddress(targetMac).getAlias();
|
||||
DeviceEntity targetDevice = deviceService.getDeviceByMacAddress(targetMac);
|
||||
if (targetDevice != null) {
|
||||
alias = targetDevice.getAlias();
|
||||
}
|
||||
// 设备登记了但用户没起过名,alias 建表就是 null,这是默认状态
|
||||
// 不是异常。这时候落个 null 进去,缓存刷新会把整条跳过,
|
||||
// 接口照样返回成功,对方却永远呼不过来
|
||||
if (StringUtils.isBlank(alias)) {
|
||||
alias = formatMacAsDeviceName(targetMac);
|
||||
}
|
||||
}
|
||||
// 检查重名
|
||||
alias = generateUniqueAlias(macAddress, targetMac, alias);
|
||||
@@ -229,11 +291,27 @@ 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();
|
||||
}
|
||||
|
||||
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,4 @@
|
||||
-- 添加MCP接入点附带说话人身份的开关参数配置
|
||||
delete from `sys_params` where param_code = 'mcp_forward_speaker';
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark)
|
||||
VALUES (313, 'mcp_forward_speaker', 'true', 'boolean', 1, '是否随MCP调用附带说话人身份');
|
||||
@@ -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: 202610261030
|
||||
author: RanChen
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202610261030.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));
|
||||
}
|
||||
|
||||
+372
-3
@@ -1,26 +1,47 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
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.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 +53,363 @@ 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.getDeviceByMacAddress(OTHER_MAC)).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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankAliasOnRegisteredTargetWithoutNicknameFallsBackToMacSuffix() {
|
||||
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);
|
||||
|
||||
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.getDeviceByMacAddress(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,10 @@
|
||||
<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-input v-if="field.type === 'json-textarea'" v-model="formData.configJson[field.prop]" type="textarea"
|
||||
:rows="3" :placeholder="field.placeholder" class="custom-input-bg">
|
||||
</el-input>
|
||||
<el-input v-else v-model="formData.configJson[field.prop]" :placeholder="field.placeholder"
|
||||
:type="field.type || 'text'" class="custom-input-bg" :show-password="field.type === 'password'">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
@@ -160,7 +164,7 @@ 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' : 'text',
|
||||
placeholder: `请输入${f.key}`
|
||||
}))
|
||||
}))
|
||||
@@ -203,6 +207,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>
|
||||
@@ -111,6 +111,10 @@ export default {
|
||||
this.saving = true
|
||||
// 提交成功后将关闭对话框的逻辑交给父组件处理
|
||||
this.$emit('submit', this.form)
|
||||
// 兜底:父组件若未在回调内重置 saving,3 秒后强制释放加载状态
|
||||
setTimeout(() => {
|
||||
this.saving = false
|
||||
}, 3000)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -656,6 +656,7 @@ export default {
|
||||
.drawer-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
padding: 10px 16px;
|
||||
@@ -894,4 +895,4 @@ export default {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
>
|
||||
<el-form :model="deviceForm" :rules="rules" ref="deviceForm" label-width="auto">
|
||||
<el-form-item :label="$t('manualAddDeviceDialog.deviceType')" prop="board">
|
||||
<el-select v-model="deviceForm.board" :placeholder="$t('manualAddDeviceDialog.deviceTypePlaceholder')" style="width: 100%">
|
||||
<el-select v-model="deviceForm.board" :placeholder="$t('manualAddDeviceDialog.deviceTypePlaceholder')" style="width: 100%" filterable>
|
||||
<el-option
|
||||
v-for="item in firmwareTypes"
|
||||
:key="item.key"
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<CustomDialog
|
||||
:title="$t('modelConfigDialog.editModel')"
|
||||
:visible.sync="dialogVisible"
|
||||
width="57%"
|
||||
width="960px"
|
||||
size="large"
|
||||
class="model-edit-dialog"
|
||||
:confirmLoading="saving"
|
||||
@confirm="handleSave"
|
||||
@close="handleClose"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="dialog-scroll-body">
|
||||
<div class="header-row">
|
||||
<div class="section-title">{{ $t("modelConfigDialog.modelInfo") }}</div>
|
||||
<div class="switch-group">
|
||||
@@ -93,7 +93,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
</CustomDialog>
|
||||
</template>
|
||||
|
||||
@@ -331,6 +330,17 @@ export default {
|
||||
if (!configJson.hasOwnProperty(field.prop)) {
|
||||
configJson[field.prop] = "";
|
||||
} else if (field.type === "json-textarea") {
|
||||
// 兼容已保存为 JSON 字符串的字典,避免再次序列化后无法编辑。
|
||||
if (typeof configJson[field.prop] === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(configJson[field.prop]);
|
||||
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
configJson[field.prop] = parsed;
|
||||
}
|
||||
} catch {
|
||||
// 保留非法值的显示,由现有校验提示用户修正。
|
||||
}
|
||||
}
|
||||
this.$set(
|
||||
this.fieldJsonMap,
|
||||
field.prop,
|
||||
@@ -466,19 +476,16 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/global.scss';
|
||||
|
||||
::v-deep .el-dialog {
|
||||
margin-top: 6vh !important;
|
||||
}
|
||||
::v-deep .el-dialog__body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
@include scrollbar-style;
|
||||
}
|
||||
.model-edit-dialog {
|
||||
::v-deep .el-dialog__body {
|
||||
padding: 24px 40px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -511,7 +518,7 @@ export default {
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: #e9e9e9;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@@ -521,10 +528,13 @@ export default {
|
||||
}
|
||||
|
||||
::v-deep .el-input__inner {
|
||||
height: 32px;
|
||||
height: 38px;
|
||||
}
|
||||
::v-deep .el-form-item {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
::v-deep .el-form-item__label {
|
||||
line-height: 38px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,11 +2,13 @@
|
||||
<CustomDialog
|
||||
:title="title"
|
||||
:visible.sync="visible"
|
||||
width="600px"
|
||||
width="720px"
|
||||
class="param-dialog-wrapper"
|
||||
@confirm="submit"
|
||||
@close="cancel"
|
||||
:confirmLoading="saving"
|
||||
:confirmText="$t('button.ok')"
|
||||
:cancelText="$t('button.cancel')"
|
||||
>
|
||||
<div class="dialog-container">
|
||||
<el-form :model="form" :rules="rules" ref="form" label-width="auto" label-position="left" class="param-form">
|
||||
@@ -147,6 +149,7 @@ export default {
|
||||
cancel() {
|
||||
this.saving = false; // 取消时重置状态
|
||||
this.dialogKey = Date.now();
|
||||
this.$emit('update:visible', false);
|
||||
this.$emit('cancel');
|
||||
},
|
||||
|
||||
@@ -183,36 +186,19 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.custom-param-dialog {
|
||||
border-radius: 16px !important;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15) !important;
|
||||
border: none !important;
|
||||
|
||||
.el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 0 !important;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.param-dialog-wrapper {
|
||||
.param-form {
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 28px;
|
||||
:deep(.el-form-item__label) {
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
padding-right: 12px;
|
||||
padding-right: 14px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.2px;
|
||||
line-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +207,7 @@ export default {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
height: 42px;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 14px;
|
||||
@@ -248,7 +234,7 @@ export default {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
height: 42px;
|
||||
height: 44px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
@@ -272,7 +258,7 @@ export default {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 12px 14px;
|
||||
padding: 10px 14px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
@@ -291,10 +277,6 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remark-item :deep(.el-form-item__label) {
|
||||
margin-top: -4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,8 @@
|
||||
<CustomDialog
|
||||
:title="title"
|
||||
:visible.sync="visible"
|
||||
width="57%"
|
||||
width="960px"
|
||||
size="large"
|
||||
class="provider-dialog-wrapper"
|
||||
@confirm="submit"
|
||||
@close="handleClose"
|
||||
@@ -308,6 +309,11 @@ export default {
|
||||
this.resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
// 兜底:父组件若未在回调内清 saving,3 秒后强制释放加载状态
|
||||
setTimeout(() => {
|
||||
this.saving = false;
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
<el-select
|
||||
v-model="replacementWordIds"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
:placeholder="$t('replacementDialog.selectPlaceholder')"
|
||||
class="replacement-word-select"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<CustomDialog :visible.sync="localVisible" :title="$t('modelConfig.voiceManagement')" width="90%"
|
||||
<CustomDialog :visible.sync="localVisible" :title="$t('modelConfig.voiceManagement')" width="80%" size="large"
|
||||
:close-on-click-modal="true" :destroy-on-close="false" :footer="false" :append-to-body="true"
|
||||
@close="handleClose">
|
||||
<div class="scroll-wrapper">
|
||||
|
||||
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user