mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-23 07:33:53 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e1a29145 | ||
|
|
e59df53c99 | ||
|
|
cdf0f91251 | ||
|
|
32ecbe55fc | ||
|
|
cc9d5306e6 | ||
|
|
5149a8049a | ||
|
|
cde093dd20 | ||
|
|
455b3df5ae | ||
|
|
4d14caf0d4 | ||
|
|
47b44bb124 | ||
|
|
eb743ab577 | ||
|
|
d6344bde5e | ||
|
|
7ce1672d98 | ||
|
|
4e3701e62c | ||
|
|
a035ed525a | ||
|
|
dbea7ae11e | ||
|
|
26127e9b4a | ||
|
|
7035a57cf1 | ||
|
|
aee9bdc514 | ||
|
|
9018422ecb | ||
|
|
da8d8c53c0 | ||
|
|
2c428a380e | ||
|
|
bde50e661d | ||
|
|
2beeb825cf | ||
|
|
371dab1282 | ||
|
|
3ba437c937 | ||
|
|
4b2d7da4e5 | ||
|
|
d00592020e | ||
|
|
e0b24f4e59 | ||
|
|
4b7be99837 | ||
|
|
99db948b96 | ||
|
|
2b3e205cda | ||
|
|
c9b7d77094 | ||
|
|
f3818167f3 | ||
|
|
55d6c2a193 | ||
|
|
6cfb6a8f43 | ||
|
|
396c3eb18b | ||
|
|
d8c4c1f207 | ||
|
|
268ce9fff3 | ||
|
|
bbc3b44336 | ||
|
|
fb223984d3 | ||
|
|
9f21711cae | ||
|
|
c139701468 | ||
|
|
66d3bcfa13 | ||
|
|
ff048797a1 | ||
|
|
ad6607b7f5 | ||
|
|
c992ed48da | ||
|
|
bdf9cda4c5 | ||
|
|
c85ffcc181 | ||
|
|
c53e3ed965 | ||
|
|
d374b77baa | ||
|
|
f3b2339803 | ||
|
|
27ed540113 | ||
|
|
434fba55f7 | ||
|
|
280c04e753 | ||
|
|
24832822a5 | ||
|
|
a357f0818d | ||
|
|
e4bf5d2d21 | ||
|
|
8b261d473a | ||
|
|
bba84382ec | ||
|
|
d081803434 | ||
|
|
9194ffa6b7 | ||
|
|
c06a7b1db6 | ||
|
|
1736e10189 | ||
|
|
43b79c50a0 | ||
|
|
2e6aced1bd | ||
|
|
2dc80c8432 | ||
|
|
01e7a7d3b8 | ||
|
|
3b8bbb5c5f |
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: pip
|
||||
directory: /main/xiaozhi-server
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Build Base Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'main/xiaozhi-server/requirements.txt'
|
||||
- 'Dockerfile-server-base'
|
||||
- '.github/workflows/build-base-image.yml'
|
||||
|
||||
jobs:
|
||||
build-base:
|
||||
name: Build and push server base image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
|
||||
- name: Build and push server-base
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile-server-base
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository }}:server-base
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=server-base
|
||||
cache-to: type=gha,mode=max,scope=server-base
|
||||
build-args: |
|
||||
BUILDKIT_PROGRESS=plain
|
||||
|
||||
- name: Output image info
|
||||
run: |
|
||||
echo "✅ Base image built and pushed successfully!"
|
||||
echo "📦 Tag: ghcr.io/${{ github.repository }}:server-base"
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
tags:
|
||||
- 'v*.*.*' # 只在以 v 开头的标签推送时触发,例如 v1.0.0
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["Build Base Image"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
||||
+2
-32
@@ -1,36 +1,6 @@
|
||||
# 第一阶段:构建Python依赖
|
||||
FROM python:3.10-slim AS builder
|
||||
# 生产镜像,仅包含应用代码
|
||||
FROM ghcr.io/xinnan-tech/xiaozhi-esp32-server:server-base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 配置pip使用国内镜像源(阿里云)并设置超时和重试
|
||||
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||
pip config set global.trusted-host mirrors.aliyun.com && \
|
||||
pip config set global.timeout 120 && \
|
||||
pip config set install.retries 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
|
||||
|
||||
# 第二阶段:生产镜像
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /opt/xiaozhi-esp32-server
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends libopus0 ffmpeg && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从构建阶段复制Python包和前端构建产物
|
||||
COPY --from=builder /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
|
||||
COPY --from=builder /usr/local/bin/mcp-proxy /usr/local/bin/mcp-proxy
|
||||
|
||||
# 复制应用代码
|
||||
COPY main/xiaozhi-server .
|
||||
|
||||
# 启动应用
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Dockerfile-server-base
|
||||
# 基础镜像,包含系统依赖和Python包
|
||||
FROM python:3.10-slim
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends libopus0 ffmpeg && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 配置pip使用国内镜像源(阿里云)并设置超时和重试
|
||||
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||
pip config set global.trusted-host mirrors.aliyun.com && \
|
||||
pip config set global.timeout 120 && \
|
||||
pip config set install.retries 5
|
||||
|
||||
WORKDIR /opt/xiaozhi-esp32-server
|
||||
|
||||
# 复制requirements.txt
|
||||
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
|
||||
+11
-5
@@ -139,6 +139,9 @@ conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/
|
||||
|
||||
conda install libopus -y
|
||||
conda install ffmpeg -y
|
||||
|
||||
# 在 Linux 环境下进行部署时,如出现类似缺失 libiconv.so.2 动态库的报错 请通过以下命令进行安装
|
||||
conda install libiconv -y
|
||||
```
|
||||
|
||||
请注意,以上命令,不是一股脑执行就成功的,你需要一步步执行,每一步执行完后,都检查一下输出的日志,查看是否成功。
|
||||
@@ -267,7 +270,8 @@ LLM:
|
||||
6、[我说话很慢,停顿时小智老是抢话](./FAQ.md)<br/>
|
||||
## 部署相关教程
|
||||
1、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
|
||||
2、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||
2、[如何部署MQTT网关开启MQTT+UDP协议](./mqtt-gateway-integration.md)<br/>
|
||||
3、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||
## 拓展相关教程
|
||||
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/>
|
||||
2、[如何集成HomeAssistant实现智能家居控制](./homeassistant-integration.md)<br/>
|
||||
@@ -275,11 +279,13 @@ LLM:
|
||||
4、[如何部署MCP接入点](./mcp-endpoint-enable.md)<br/>
|
||||
5、[如何接入MCP接入点](./mcp-endpoint-integration.md)<br/>
|
||||
6、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
||||
10、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||
7、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||
8、[天气插件使用指南](./weather-integration.md)<br/>
|
||||
## 语音克隆、本地语音部署相关教程
|
||||
1、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||
2、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||
3、[如何部署集成PaddleSpeech本地语音](./paddlespeech-deploy.md)<br/>
|
||||
1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/>
|
||||
2、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||
3、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||
4、[如何部署集成PaddleSpeech本地语音](./paddlespeech-deploy.md)<br/>
|
||||
## 性能测试教程
|
||||
1、[各组件速度测试指南](./performance_tester.md)<br/>
|
||||
2、[定期公开测试结果](https://github.com/xinnan-tech/xiaozhi-performance-research)<br/>
|
||||
|
||||
@@ -355,6 +355,9 @@ conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/
|
||||
|
||||
conda install libopus -y
|
||||
conda install ffmpeg -y
|
||||
|
||||
# 在 Linux 环境下进行部署时,如出现类似缺失 libiconv.so.2 动态库的报错 请通过以下命令进行安装
|
||||
conda install libiconv -y
|
||||
```
|
||||
|
||||
请注意,以上命令,不是一股脑执行就成功的,你需要一步步执行,每一步执行完后,都检查一下输出的日志,查看是否成功。
|
||||
@@ -468,7 +471,8 @@ ws://你电脑局域网的ip:8000/xiaozhi/v1/
|
||||
6、[我说话很慢,停顿时小智老是抢话](./FAQ.md)<br/>
|
||||
## 部署相关教程
|
||||
1、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
|
||||
2、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||
2、[如何部署MQTT网关开启MQTT+UDP协议](./mqtt-gateway-integration.md)<br/>
|
||||
3、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||
## 拓展相关教程
|
||||
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/>
|
||||
2、[如何集成HomeAssistant实现智能家居控制](./homeassistant-integration.md)<br/>
|
||||
@@ -479,9 +483,10 @@ ws://你电脑局域网的ip:8000/xiaozhi/v1/
|
||||
7、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||
8、[天气插件使用指南](./weather-integration.md)<br/>
|
||||
## 语音克隆、本地语音部署相关教程
|
||||
1、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||
2、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||
3、[如何部署集成PaddleSpeech本地语音](./paddlespeech-deploy.md)<br/>
|
||||
1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/>
|
||||
2、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||
3、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||
4、[如何部署集成PaddleSpeech本地语音](./paddlespeech-deploy.md)<br/>
|
||||
## 性能测试教程
|
||||
1、[各组件速度测试指南](./performance_tester.md)<br/>
|
||||
2、[定期公开测试结果](https://github.com/xinnan-tech/xiaozhi-performance-research)<br/>
|
||||
|
||||
+10
-9
@@ -62,30 +62,31 @@ VAD:
|
||||
### 7、部署相关教程
|
||||
1、[如何进行最简化部署](./Deployment.md)<br/>
|
||||
2、[如何进行全模块部署](./Deployment_all.md)<br/>
|
||||
2、[如何部署MQTT网关开启MQTT+UDP协议](./mqtt-gateway-integration.md)<br/>
|
||||
3、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
|
||||
4、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||
3、[如何部署MQTT网关开启MQTT+UDP协议](./mqtt-gateway-integration.md)<br/>
|
||||
4、[如何自动拉取本项目最新代码自动编译和启动](./dev-ops-integration.md)<br/>
|
||||
5、[如何与Nginx集成](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)<br/>
|
||||
|
||||
### 8、编译固件相关教程
|
||||
### 9、编译固件相关教程
|
||||
1、[如何自己编译小智固件](./firmware-build.md)<br/>
|
||||
2、[如何基于虾哥编译好的固件修改OTA地址](./firmware-setting.md)<br/>
|
||||
|
||||
### 8、拓展相关教程
|
||||
### 10、拓展相关教程
|
||||
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)<br/>
|
||||
2、[如何集成HomeAssistant实现智能家居控制](./homeassistant-integration.md)<br/>
|
||||
3、[如何开启视觉模型实现拍照识物](./mcp-vision-integration.md)<br/>
|
||||
4、[如何部署MCP接入点](./mcp-endpoint-enable.md)<br/>
|
||||
5、[如何接入MCP接入点](./mcp-endpoint-integration.md)<br/>
|
||||
6、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
||||
10、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||
6、[MCP方法如何获取设备信息](./mcp-get-device-info.md)<br/>
|
||||
7、[如何开启声纹识别](./voiceprint-integration.md)<br/>
|
||||
8、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
|
||||
|
||||
### 9、语音克隆、本地语音部署相关教程
|
||||
### 11、语音克隆、本地语音部署相关教程
|
||||
1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/>
|
||||
2、[如何部署集成index-tts本地语音](./index-stream-integration.md)<br/>
|
||||
3、[如何部署集成fish-speech本地语音](./fish-speech-integration.md)<br/>
|
||||
4、[如何部署集成PaddleSpeech本地语音](./paddlespeech-deploy.md)<br/>
|
||||
|
||||
### 10、性能测试教程
|
||||
### 12、性能测试教程
|
||||
1、[各组件速度测试指南](./performance_tester.md)<br/>
|
||||
2、[定期公开测试结果](https://github.com/xinnan-tech/xiaozhi-performance-research)<br/>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 智控台 火山双流式语音合成+音色克隆配置教程
|
||||
|
||||
本教程分为3个阶段:准备阶段、配置阶段、克隆阶段、使用阶段。主要是介绍通过智控台配置火山双流式语音合成+音色克隆的过程。
|
||||
本教程分为4个阶段:准备阶段、配置阶段、克隆阶段、使用阶段。主要是介绍通过智控台配置火山双流式语音合成+音色克隆的过程。
|
||||
|
||||
## 第一阶段:准备阶段
|
||||
超级管理员先预先把火山引擎服务开通好,获取到App Id,Access Token。默认火上引擎会赠送一个音色资源。这个音色资源需要把它复制到本项目里。
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
如果复刻成功,在列表里会看到对应的音色会变成“训练成功”状态。此时你可以点击【声音名称】栏的修改按钮,修改音色资源的名称,方便后期选择使用。
|
||||
|
||||
## 第三阶段:使用阶段
|
||||
## 第四阶段:使用阶段
|
||||
|
||||
点击顶部【智能体管理】,选择任意一个智能体,点击【配置角色】按钮。
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# MCP 方法如何获取设备信息
|
||||
|
||||
本教程将指导你如何使用MCP方法获取设备信息。
|
||||
|
||||
第一步:自定义你的`agent-base-prompt.txt`文件
|
||||
|
||||
把xiaozhi-server目录的`agent-base-prompt.txt`文件内容复制到你的`data`目录下,并重命名为`.agent-base-prompt.txt`。
|
||||
|
||||
第二步:修改`data/.agent-base-prompt.txt`文件,找到`<context>`标签,在标签内容中添加以下代码内容:
|
||||
```
|
||||
- **设备ID:** {{device_id}}
|
||||
```
|
||||
|
||||
添加完成后,你的`data/.agent-base-prompt.txt`文件的`<context>`标签内容大致如下:
|
||||
```
|
||||
<context>
|
||||
【重要!以下信息已实时提供,无需调用工具查询,请直接使用:】
|
||||
- **设备ID:** {{device_id}}
|
||||
- **当前时间:** {{current_time}}
|
||||
- **今天日期:** {{today_date}} ({{today_weekday}})
|
||||
- **今天农历:** {{lunar_date}}
|
||||
- **用户所在城市:** {{local_address}}
|
||||
- **当地未来7天天气:** {{weather_info}}
|
||||
</context>
|
||||
```
|
||||
|
||||
第三步:修改`data/.config.yaml`文件,找到`agent-base-prompt`配置,修改前内容如下:
|
||||
```
|
||||
prompt_template: agent-base-prompt.txt
|
||||
```
|
||||
修改成
|
||||
```
|
||||
prompt_template: data/.agent-base-prompt.txt
|
||||
```
|
||||
|
||||
第四步:重启你的xiaozhi-server服务。
|
||||
|
||||
第五步:在你的mcp方法增加名称为`device_id`,类型为`string`,描述为`设备ID`的参数。
|
||||
|
||||
第六步:重新唤醒小智,让他调用mcp方法,查看你的mcp方法是否可以获取`设备ID`。
|
||||
@@ -294,7 +294,7 @@ public interface Constant {
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
public static final String VERSION = "0.8.5";
|
||||
public static final String VERSION = "0.8.6";
|
||||
|
||||
/**
|
||||
* 无效固件URL
|
||||
|
||||
@@ -82,14 +82,6 @@ public interface ErrorCode {
|
||||
int DEVICE_ALREADY_ACTIVATED = 10063;
|
||||
// 默认模型删除错误
|
||||
int DEFAULT_MODEL_DELETE_ERROR = 10064;
|
||||
// 设备相关错误码
|
||||
int MAC_ADDRESS_ALREADY_EXISTS = 10090; // Mac地址已存在
|
||||
// 模型相关错误码
|
||||
int MODEL_PROVIDER_NOT_EXIST = 10091; // 供应器不存在
|
||||
int LLM_NOT_EXIST = 10092; // 设置的LLM不存在
|
||||
int MODEL_REFERENCED_BY_AGENT = 10093; // 该模型配置已被智能体引用,无法删除
|
||||
int LLM_REFERENCED_BY_INTENT = 10094; // 该LLM模型已被意图识别配置引用,无法删除
|
||||
|
||||
// 登录相关错误码
|
||||
int ADD_DATA_FAILED = 10065; // 新增数据失败
|
||||
int UPDATE_DATA_FAILED = 10066; // 修改数据失败
|
||||
@@ -127,6 +119,14 @@ public interface ErrorCode {
|
||||
int VOICEPRINT_UNREGISTER_PROCESS_ERROR = 10090; // 声纹注销处理失败
|
||||
int VOICEPRINT_IDENTIFY_REQUEST_ERROR = 10091; // 声纹识别请求失败
|
||||
|
||||
// 设备相关错误码
|
||||
int MAC_ADDRESS_ALREADY_EXISTS = 10161; // Mac地址已存在
|
||||
// 模型相关错误码
|
||||
int MODEL_PROVIDER_NOT_EXIST = 10162; // 供应器不存在
|
||||
int LLM_NOT_EXIST = 10092; // 设置的LLM不存在
|
||||
int MODEL_REFERENCED_BY_AGENT = 10093; // 该模型配置已被智能体引用,无法删除
|
||||
int LLM_REFERENCED_BY_INTENT = 10094; // 该LLM模型已被意图识别配置引用,无法删除
|
||||
|
||||
// 服务端管理相关错误码
|
||||
int INVALID_SERVER_ACTION = 10095; // 无效服务端操作
|
||||
int SERVER_WEBSOCKET_NOT_CONFIGURED = 10096; // 未配置服务端WebSocket地址
|
||||
|
||||
+1
-60
@@ -93,7 +93,7 @@ public class DeviceController {
|
||||
}
|
||||
|
||||
@PostMapping("/bind/{agentId}")
|
||||
@Operation(summary = "转发POST请求到MQTT网关")
|
||||
@Operation(summary = "设备在线接口")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<String> forwardToMqttGateway(@PathVariable String agentId, @RequestBody String requestBody) {
|
||||
try {
|
||||
@@ -208,63 +208,4 @@ public class DeviceController {
|
||||
deviceService.manualAddDevice(user.getId(), dto);
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
@PostMapping("/commands/{deviceId}")
|
||||
@Operation(summary = "发送设备指令")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<String> sendDeviceCommand(@PathVariable String deviceId, @RequestBody String command) {
|
||||
try {
|
||||
// 从系统参数中获取MQTT网关地址
|
||||
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
|
||||
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
|
||||
return new Result<String>().error("MQTT网关地址未配置");
|
||||
}
|
||||
|
||||
// 构建完整的URL
|
||||
// 获取设备信息以构建mqttClientId
|
||||
DeviceEntity deviceById = deviceService.selectById(deviceId);
|
||||
|
||||
if (!deviceById.getUserId().equals(SecurityUser.getUser().getId())) {
|
||||
return new Result<String>().error("设备不存在");
|
||||
}
|
||||
String macAddress = deviceById != null ? deviceById.getMacAddress() : "unknown";
|
||||
String groupId = deviceById != null ? deviceById.getBoard() : null;
|
||||
if (groupId == null) {
|
||||
groupId = "GID_default";
|
||||
}
|
||||
groupId = groupId.replace(":", "_");
|
||||
macAddress = macAddress.replace(":", "_");
|
||||
|
||||
// 拼接为groupId@@@macAddress@@@deviceId格式
|
||||
String mqttClientId = groupId + "@@@" + macAddress + "@@@" + macAddress;
|
||||
|
||||
String url = "http://" + mqttGatewayUrl + "/api/commands/" + mqttClientId;
|
||||
|
||||
// 设置请求头
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
// 生成Bearer令牌
|
||||
String dateStr = java.time.LocalDate.now()
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||
String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", false);
|
||||
if (StringUtils.isBlank(signatureKey)) {
|
||||
return new Result<String>().error("MQTT签名密钥未配置");
|
||||
}
|
||||
String tokenContent = dateStr + signatureKey;
|
||||
String token = org.apache.commons.codec.digest.DigestUtils.sha256Hex(tokenContent);
|
||||
headers.set("Authorization", "Bearer " + token);
|
||||
|
||||
// 构建请求体
|
||||
HttpEntity<String> requestEntity = new HttpEntity<>(command, headers);
|
||||
|
||||
// 发送POST请求
|
||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class);
|
||||
|
||||
// 返回响应
|
||||
return new Result<String>().ok(response.getBody());
|
||||
} catch (Exception e) {
|
||||
return new Result<String>().error("发送指令失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,4 +166,6 @@
|
||||
10157=\u8BF7\u6C42\u5931\u8D25
|
||||
10158=\u8BF7\u6C42\u5931\u8D25\uFF0C\u72B6\u6001\u7801{0}
|
||||
10159=\u97F3\u8272ID\u5DF2\u5B58\u5728
|
||||
10160=\u706B\u5C71\u5F15\u64CE\u97F3\u8272ID\u683C\u5F0F\u9519\u8BEF\uFF0C\u5FC5\u987B\u4EE5S_\u5F00\u5934
|
||||
10160=\u706B\u5C71\u5F15\u64CE\u97F3\u8272ID\u683C\u5F0F\u9519\u8BEF\uFF0C\u5FC5\u987B\u4EE5S_\u5F00\u5934
|
||||
10161=Mac\u5730\u5740\u5DF2\u5B58\u5728
|
||||
10162=\u6A21\u578B\u4F9B\u5E94\u5668\u4E0D\u5B58\u5728
|
||||
@@ -166,4 +166,6 @@
|
||||
10157=Request failed
|
||||
10158=Clone Voice:
|
||||
10159=Voice ID already exists
|
||||
10160=Huoshan Engine voice ID format error, must start with S_
|
||||
10160=Huoshan Engine voice ID format error, must start with S_
|
||||
10161=Mac address already exists
|
||||
10162=Model provider does not exist
|
||||
@@ -166,4 +166,6 @@
|
||||
10157=\u8BF7\u6C42\u5931\u8D25
|
||||
10158=\u514B\u9686\u8272\u97F3:
|
||||
10159=\u97F3\u8272ID\u5DF2\u5B58\u5728
|
||||
10160=\u706B\u5C71\u5F15\u64CE\u97F3\u8272ID\u683C\u5F0F\u9519\u8BEF\uFF0C\u5FC5\u987B\u4EE5S_\u5F00\u5934
|
||||
10160=\u706B\u5C71\u5F15\u64CE\u97F3\u8272ID\u683C\u5F0F\u9519\u8BEF\uFF0C\u5FC5\u987B\u4EE5S_\u5F00\u5934
|
||||
10161=Mac\u5730\u5740\u5DF2\u5B58\u5728
|
||||
10162=\u6A21\u578B\u4F9B\u5E94\u5668\u4E0D\u5B58\u5728
|
||||
@@ -166,4 +166,7 @@
|
||||
10157=\u8ACB\u6C42\u5931\u6557
|
||||
10158=\u514B\u9686\u8A9E\u97F3:
|
||||
10159=\u97F3\u8272ID\u5DF2\u5B58\u5728
|
||||
10160=\u706B\u5C71\u5F15\u64CE\u97F3\u8272ID\u683C\u5F0F\u932F\u8AA4\uFF0C\u5FC5\u9808\u4EE5S_\u958B\u982D
|
||||
10160=\u706B\u5C71\u5F15\u64CE\u97F3\u8272ID\u683C\u5F0F\u932F\u8AA4\uFF0C\u5FC5\u9808\u4EE5S_\u958B\u982D
|
||||
10161=Mac\u5730\u5740\u5DF2\u5B58\u5728
|
||||
10162=\u6A21\u578B\u63D0\u4F9B\u5546\u4E0D\u5B58\u5728
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
}</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { changeLanguage, getCurrentLanguage, getSupportedLanguages, t } from '@/i18n'
|
||||
import type { Language } from '@/store/lang'
|
||||
import { clearServerBaseUrlOverride, getEnvBaseUrl, getServerBaseUrlOverride, setServerBaseUrlOverride } from '@/utils'
|
||||
import { isMp } from '@/utils/platform'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { t, changeLanguage, getSupportedLanguages, getCurrentLanguage } from '@/i18n'
|
||||
import type { Language } from '@/store/lang'
|
||||
|
||||
defineOptions({
|
||||
name: 'SettingsPage',
|
||||
@@ -233,9 +233,9 @@ async function clearCache() {
|
||||
function showAbout() {
|
||||
uni.showModal({
|
||||
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
|
||||
content: t('settings.aboutContent', {
|
||||
content: t('settings.aboutContent', {
|
||||
appName: import.meta.env.VITE_APP_TITLE,
|
||||
version: '0.8.5'
|
||||
version: '0.8.6'
|
||||
}),
|
||||
showCancel: false,
|
||||
confirmText: t('common.confirm'),
|
||||
@@ -248,7 +248,7 @@ onMounted(async () => {
|
||||
loadServerBaseUrl()
|
||||
}
|
||||
getCacheInfo()
|
||||
|
||||
|
||||
// 动态设置导航栏标题为国际化文本
|
||||
uni.setNavigationBarTitle({
|
||||
title: t('settings.title')
|
||||
@@ -283,9 +283,9 @@ onMounted(async () => {
|
||||
<view class="mb-[24rpx]">
|
||||
<view class="w-full rounded-[16rpx] border border-[#eeeeee] bg-[#f5f7fb] overflow-hidden">
|
||||
<wd-input v-model="baseUrlInput" type="text" clearable :maxlength="200"
|
||||
:placeholder="t('settings.enterServerUrl')"
|
||||
custom-class="!border-none !bg-transparent h-[64rpx] px-[24rpx] items-center"
|
||||
input-class="text-[28rpx] text-[#232338]" @input="validateUrl" @blur="validateUrl" />
|
||||
:placeholder="t('settings.enterServerUrl')"
|
||||
custom-class="!border-none !bg-transparent h-[64rpx] px-[24rpx] items-center"
|
||||
input-class="text-[28rpx] text-[#232338]" @input="validateUrl" @blur="validateUrl" />
|
||||
</view>
|
||||
<text v-if="urlError" class="mt-[8rpx] block text-[24rpx] text-[#ff4d4f]">
|
||||
{{ urlError }}
|
||||
@@ -323,11 +323,11 @@ onMounted(async () => {
|
||||
class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]">
|
||||
<view>
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
{{ t('settings.totalCacheSize') }}
|
||||
</text>
|
||||
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
{{ t('settings.appDataSize') }}
|
||||
</text>
|
||||
{{ t('settings.totalCacheSize') }}
|
||||
</text>
|
||||
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
{{ t('settings.appDataSize') }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="text-[28rpx] text-[#65686f] font-semibold">
|
||||
{{ cacheInfo.storageSize }}
|
||||
@@ -339,11 +339,11 @@ onMounted(async () => {
|
||||
class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx]">
|
||||
<view>
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
{{ t('settings.cacheClear') }}
|
||||
</text>
|
||||
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
{{ t('settings.clearAllCache') }}
|
||||
</text>
|
||||
{{ t('settings.cacheClear') }}
|
||||
</text>
|
||||
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
{{ t('settings.clearAllCache') }}
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="cursor-pointer rounded-[24rpx] bg-[rgba(255,107,107,0.1)] px-[28rpx] py-[16rpx] text-[24rpx] text-[#ff6b6b] font-semibold transition-all duration-300 active:scale-95 active:bg-[#ff6b6b] active:text-white"
|
||||
@@ -359,8 +359,8 @@ onMounted(async () => {
|
||||
<view class="mb-[32rpx]">
|
||||
<view class="mb-[24rpx] flex items-center">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
{{ t('settings.appInfo') }}
|
||||
</text>
|
||||
{{ t('settings.appInfo') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]"
|
||||
@@ -370,11 +370,11 @@ onMounted(async () => {
|
||||
@click="showAbout">
|
||||
<view>
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
{{ t('settings.aboutUs') }}
|
||||
</text>
|
||||
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
{{ t('settings.appVersion') }}
|
||||
</text>
|
||||
{{ t('settings.aboutUs') }}
|
||||
</text>
|
||||
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
{{ t('settings.appVersion') }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-icon name="arrow-right" custom-class="text-[32rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
@@ -385,24 +385,26 @@ onMounted(async () => {
|
||||
<view class="mb-[32rpx]">
|
||||
<view class="mb-[24rpx] flex items-center">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
{{ t('settings.languageSettings') }}
|
||||
</text>
|
||||
{{ t('settings.languageSettings') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]"
|
||||
style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]" @click="showLanguageSheet = true">
|
||||
<view
|
||||
class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]"
|
||||
@click="showLanguageSheet = true">
|
||||
<view>
|
||||
<text class="text-[32rpx] text-[#232338] font-medium">
|
||||
{{ t('settings.language') }}
|
||||
</text>
|
||||
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
{{ t('settings.selectLanguage') }}
|
||||
</text>
|
||||
{{ t('settings.language') }}
|
||||
</text>
|
||||
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
{{ t('settings.selectLanguage') }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="flex items-center">
|
||||
<text class="text-[32rpx] text-[#9d9ea3] font-semibold mr-[16rpx]">
|
||||
{{ supportedLanguages.find(lang => lang.code === currentLanguage)?.name }}
|
||||
{{supportedLanguages.find(lang => lang.code === currentLanguage)?.name}}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[32rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
@@ -411,19 +413,11 @@ onMounted(async () => {
|
||||
</view>
|
||||
|
||||
<!-- 语言选择弹窗 -->
|
||||
<wd-action-sheet
|
||||
v-model="showLanguageSheet"
|
||||
:title="t('settings.selectLanguage')"
|
||||
:close-on-click-modal="true"
|
||||
>
|
||||
<wd-action-sheet v-model="showLanguageSheet" :title="t('settings.selectLanguage')" :close-on-click-modal="true">
|
||||
<view class="language-sheet">
|
||||
<scroll-view scroll-y class="language-list">
|
||||
<view
|
||||
v-for="lang in supportedLanguages"
|
||||
:key="lang.code"
|
||||
class="language-item"
|
||||
@click="handleLanguageChange(lang.code)"
|
||||
>
|
||||
<view v-for="lang in supportedLanguages" :key="lang.code" class="language-item"
|
||||
@click="handleLanguageChange(lang.code)">
|
||||
<text class="language-name">
|
||||
{{ lang.name }}
|
||||
</text>
|
||||
@@ -446,17 +440,21 @@ onMounted(async () => {
|
||||
.language-sheet {
|
||||
.language-list {
|
||||
max-height: 50vh;
|
||||
|
||||
.language-item {
|
||||
padding: 30rpx 0;
|
||||
text-align: center;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
.language-name {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #f5f7fb;
|
||||
}
|
||||
|
||||
@@ -102,21 +102,4 @@ export default {
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 发送设备指令
|
||||
sendDeviceCommand(deviceId, mcpData, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/device/commands/${deviceId}`)
|
||||
.method('POST')
|
||||
.data(mcpData)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('发送设备指令失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.sendDeviceCommand(deviceId, mcpData, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,9 +64,6 @@
|
||||
<el-button size="mini" type="text" @click="handleUnbind(scope.row.device_id)">
|
||||
{{ $t('device.unbind') }}
|
||||
</el-button>
|
||||
<el-button v-if="scope.row.deviceStatus === 'online'" size="mini" type="text" @click="handleMcpToolCall(scope.row.device_id)">
|
||||
{{ $t('device.toolCall') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -82,9 +79,9 @@
|
||||
<el-button type="success" size="mini" class="add-device-btn" @click="handleManualAddDevice">
|
||||
{{ $t('device.manualAdd') }}
|
||||
</el-button>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteSelected">{{
|
||||
$t('device.unbind')
|
||||
}}</el-button>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteSelected">
|
||||
{{ $t('device.unbind') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="custom-pagination">
|
||||
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
||||
@@ -92,20 +89,22 @@
|
||||
:label="$t('dictManagement.itemsPerPage').replace('{items}', item)" :value="item">
|
||||
</el-option>
|
||||
</el-select>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">{{
|
||||
$t('dictManagement.firstPage')
|
||||
}}</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">{{
|
||||
$t('dictManagement.prevPage')
|
||||
}}</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
|
||||
{{ $t('dictManagement.firstPage') }}
|
||||
</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
|
||||
{{ $t('dictManagement.prevPage') }}
|
||||
</button>
|
||||
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
|
||||
:class="{ active: page === currentPage }" @click="goToPage(page)">
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">{{
|
||||
$t('dictManagement.nextPage') }}</button>
|
||||
<span class="total-text">{{ $t('dictManagement.totalRecords').replace('{total}', deviceList.length)
|
||||
}}</span>
|
||||
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
|
||||
{{ $t('dictManagement.nextPage') }}
|
||||
</button>
|
||||
<span class="total-text">
|
||||
{{ $t('dictManagement.totalRecords').replace('{total}', deviceList.length) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -117,7 +116,6 @@
|
||||
@refresh="fetchBindDevices(currentAgentId)" />
|
||||
<ManualAddDeviceDialog :visible.sync="manualAddDeviceDialogVisible" :agent-id="currentAgentId"
|
||||
@refresh="fetchBindDevices(currentAgentId)" />
|
||||
<McpToolCallDialog :visible.sync="mcpToolCallDialogVisible" :device-id="selectedDeviceId" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -127,20 +125,17 @@ import Api from '@/apis/api';
|
||||
import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
import ManualAddDeviceDialog from "@/components/ManualAddDeviceDialog.vue";
|
||||
import McpToolCallDialog from "@/components/McpToolCallDialog.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
HeaderBar,
|
||||
AddDeviceDialog,
|
||||
ManualAddDeviceDialog,
|
||||
McpToolCallDialog
|
||||
ManualAddDeviceDialog
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
addDeviceDialogVisible: false,
|
||||
manualAddDeviceDialogVisible: false,
|
||||
mcpToolCallDialogVisible: false,
|
||||
selectedDeviceId: '',
|
||||
searchKeyword: "",
|
||||
activeSearchKeyword: "",
|
||||
@@ -280,11 +275,6 @@ export default {
|
||||
handleManualAddDevice() {
|
||||
this.manualAddDeviceDialogVisible = true;
|
||||
},
|
||||
|
||||
handleMcpToolCall(deviceId) {
|
||||
this.selectedDeviceId = deviceId;
|
||||
this.mcpToolCallDialogVisible = true;
|
||||
},
|
||||
submitRemark(row) {
|
||||
if (row._submitting) return;
|
||||
|
||||
@@ -383,7 +373,7 @@ export default {
|
||||
.sort((a, b) => a.rawBindTime - b.rawBindTime);
|
||||
this.activeSearchKeyword = "";
|
||||
this.searchKeyword = "";
|
||||
|
||||
|
||||
// 获取设备列表后,立即获取设备状态
|
||||
this.fetchDeviceStatus(agentId);
|
||||
} else {
|
||||
@@ -391,7 +381,7 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// 获取设备状态
|
||||
fetchDeviceStatus(agentId) {
|
||||
Api.device.getDeviceStatus(agentId, ({ data }) => {
|
||||
@@ -399,7 +389,7 @@ export default {
|
||||
try {
|
||||
// 解析后端返回的设备状态JSON
|
||||
const statusData = JSON.parse(data.data);
|
||||
|
||||
|
||||
// 直接使用解析后的数据作为设备状态映射(不需要devices字段包装)
|
||||
if (statusData && typeof statusData === 'object') {
|
||||
// 更新设备状态
|
||||
@@ -411,7 +401,7 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// 根据API响应更新设备状态
|
||||
updateDeviceStatusFromResponse(deviceStatusMap) {
|
||||
this.deviceList.forEach(device => {
|
||||
@@ -419,11 +409,11 @@ export default {
|
||||
const macAddress = device.macAddress ? device.macAddress.replace(/:/g, '_') : 'unknown';
|
||||
const groupId = device.model ? device.model.replace(/:/g, '_') : 'GID_default';
|
||||
const mqttClientId = `${groupId}@@@${macAddress}@@@${macAddress}`;
|
||||
|
||||
|
||||
// 从状态映射中获取设备状态
|
||||
if (deviceStatusMap[mqttClientId]) {
|
||||
const statusInfo = deviceStatusMap[mqttClientId];
|
||||
|
||||
|
||||
let isOnline = false;
|
||||
if (statusInfo.isAlive === true) {
|
||||
isOnline = true;
|
||||
@@ -434,7 +424,7 @@ export default {
|
||||
} else {
|
||||
isOnline = false;
|
||||
}
|
||||
|
||||
|
||||
device.deviceStatus = isOnline ? 'online' : 'offline';
|
||||
} else {
|
||||
// 如果没有找到对应的状态信息,默认为离线
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
|
||||
<context>
|
||||
【重要!以下信息已实时提供,无需调用工具查询,请直接使用:】
|
||||
- **设备ID:** {{device_id}}
|
||||
- **当前时间:** {{current_time}}
|
||||
- **今天日期:** {{today_date}} ({{today_weekday}})
|
||||
- **今天农历:** {{lunar_date}}
|
||||
|
||||
@@ -46,12 +46,18 @@ async def main():
|
||||
check_ffmpeg_installed()
|
||||
config = load_config()
|
||||
|
||||
# 默认使用manager-api的secret作为auth_key
|
||||
# 如果secret为空,则生成随机密钥
|
||||
# auth_key用于jwt认证,比如视觉分析接口的jwt认证
|
||||
auth_key = config.get("manager-api", {}).get("secret", "")
|
||||
# auth_key优先级:配置文件server.auth_key > manager-api.secret > 自动生成
|
||||
# auth_key用于jwt认证,比如视觉分析接口的jwt认证、ota接口的token生成与websocket认证
|
||||
# 获取配置文件中的auth_key
|
||||
auth_key = config["server"].get("auth_key", "")
|
||||
|
||||
# 验证auth_key,无效则尝试使用manager-api.secret
|
||||
if not auth_key or len(auth_key) == 0 or "你" in auth_key:
|
||||
auth_key = str(uuid.uuid4().hex)
|
||||
auth_key = config.get("manager-api", {}).get("secret", "")
|
||||
# 验证secret,无效则生成随机密钥
|
||||
if not auth_key or len(auth_key) == 0 or "你" in auth_key:
|
||||
auth_key = str(uuid.uuid4().hex)
|
||||
|
||||
config["server"]["auth_key"] = auth_key
|
||||
|
||||
# 添加 stdin 监控任务
|
||||
|
||||
@@ -70,6 +70,12 @@ enable_stop_tts_notify: false
|
||||
# 说完话是否开启提示音,音效地址
|
||||
stop_tts_notify_voice: "config/assets/tts_notify.mp3"
|
||||
|
||||
# TTS音频发送延迟配置
|
||||
# tts_audio_send_delay: 控制音频包发送间隔
|
||||
# 0: 使用精确时间控制,严格匹配音频帧率(默认,运行时按音频帧率计算)
|
||||
# > 0: 使用固定延迟(毫秒)发送,例如: 60
|
||||
tts_audio_send_delay: 0
|
||||
|
||||
exit_commands:
|
||||
- "退出"
|
||||
- "关闭"
|
||||
@@ -173,6 +179,9 @@ prompt: |
|
||||
- 长篇大论,叽叽歪歪
|
||||
- 长时间严肃对话
|
||||
|
||||
# 默认系统提示词模板文件
|
||||
prompt_template: agent-base-prompt.txt
|
||||
|
||||
# 结束语prompt
|
||||
end_prompt:
|
||||
enable: true # 是否开启结束语
|
||||
@@ -987,4 +996,4 @@ TTS:
|
||||
# sample_rate: 24000 # 采样率:16000, 8000, 24000
|
||||
# volume: 50 # 音量:0-100
|
||||
# speed: 50 # 语速:0-100
|
||||
# pitch: 50 # 语调:0-100
|
||||
# pitch: 50 # 语调:0-100
|
||||
|
||||
@@ -68,6 +68,9 @@ def get_config_from_api(config):
|
||||
"vision_explain": config["server"].get("vision_explain", ""),
|
||||
"auth_key": config["server"].get("auth_key", ""),
|
||||
}
|
||||
# 如果服务器没有prompt_template,则从本地配置读取
|
||||
if not config_data.get("prompt_template"):
|
||||
config_data["prompt_template"] = config.get("prompt_template")
|
||||
return config_data
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from config.config_loader import load_config
|
||||
from config.settings import check_config_file
|
||||
from datetime import datetime
|
||||
|
||||
SERVER_VERSION = "0.8.5"
|
||||
SERVER_VERSION = "0.8.6"
|
||||
_logger_initialized = False
|
||||
|
||||
|
||||
|
||||
@@ -22,4 +22,6 @@ manager-api:
|
||||
# 如果使用docker部署,请使用填写成 http://xiaozhi-esp32-server-web:8002/xiaozhi
|
||||
url: http://127.0.0.1:8002/xiaozhi
|
||||
# 你的manager-api的token,就是刚才复制出来的server.secret
|
||||
secret: 你的server.secret值
|
||||
secret: 你的server.secret值
|
||||
# 默认系统提示词模板文件
|
||||
prompt_template: agent-base-prompt.txt
|
||||
@@ -140,7 +140,7 @@ class OTAHandler(BaseHandler):
|
||||
self.logger.bind(tag=TAG).warning("缺少MQTT签名密钥,密码留空")
|
||||
|
||||
# 构建MQTT配置(直接使用 mqtt_gateway 字符串)
|
||||
return_json["mqtt_gateway"] = {
|
||||
return_json["mqtt"] = {
|
||||
"endpoint": mqtt_gateway_endpoint,
|
||||
"client_id": mqtt_client_id,
|
||||
"username": username,
|
||||
|
||||
@@ -115,9 +115,9 @@ class ConnectionHandler:
|
||||
# vad相关变量
|
||||
self.client_audio_buffer = bytearray()
|
||||
self.client_have_voice = False
|
||||
self.client_voice_window = deque(maxlen=5)
|
||||
self.last_activity_time = 0.0 # 统一的活动时间戳(毫秒)
|
||||
self.client_voice_stop = False
|
||||
self.client_voice_window = deque(maxlen=5)
|
||||
self.last_is_voice = False
|
||||
|
||||
# asr相关变量
|
||||
|
||||
@@ -33,7 +33,7 @@ async def handleAudioMessage(conn, audio):
|
||||
|
||||
async def resume_vad_detection(conn):
|
||||
# 等待2秒后恢复VAD检测
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(2)
|
||||
conn.just_woken_up = False
|
||||
|
||||
|
||||
|
||||
@@ -90,6 +90,9 @@ async def sendAudio(conn, audios, frame_duration=60):
|
||||
if audios is None or len(audios) == 0:
|
||||
return
|
||||
|
||||
# 获取发送延迟配置
|
||||
send_delay = conn.config.get("tts_audio_send_delay", -1) / 1000.0
|
||||
|
||||
if isinstance(audios, bytes):
|
||||
if conn.client_abort:
|
||||
return
|
||||
@@ -107,16 +110,21 @@ async def sendAudio(conn, audios, frame_duration=60):
|
||||
|
||||
flow_control = conn.audio_flow_control
|
||||
current_time = time.perf_counter()
|
||||
# 计算预期发送时间
|
||||
expected_time = flow_control["start_time"] + (
|
||||
flow_control["packet_count"] * frame_duration / 1000
|
||||
)
|
||||
delay = expected_time - current_time
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
if send_delay > 0:
|
||||
# 使用固定延迟
|
||||
await asyncio.sleep(send_delay)
|
||||
else:
|
||||
# 纠正误差
|
||||
flow_control["start_time"] += abs(delay)
|
||||
# 计算预期发送时间
|
||||
expected_time = flow_control["start_time"] + (
|
||||
flow_control["packet_count"] * frame_duration / 1000
|
||||
)
|
||||
delay = expected_time - current_time
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
# 纠正误差
|
||||
flow_control["start_time"] += abs(delay)
|
||||
|
||||
if conn.conn_from_mqtt_gateway:
|
||||
# 计算时间戳和序列号
|
||||
@@ -164,12 +172,16 @@ async def sendAudio(conn, audios, frame_duration=60):
|
||||
# 重置没有声音的状态
|
||||
conn.last_activity_time = time.time() * 1000
|
||||
|
||||
# 计算预期发送时间
|
||||
expected_time = start_time + (play_position / 1000)
|
||||
current_time = time.perf_counter()
|
||||
delay = expected_time - current_time
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
if send_delay > 0:
|
||||
# 固定延迟模式
|
||||
await asyncio.sleep(send_delay)
|
||||
else:
|
||||
# 计算预期发送时间
|
||||
expected_time = start_time + (play_position / 1000)
|
||||
current_time = time.perf_counter()
|
||||
delay = expected_time - current_time
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
if conn.conn_from_mqtt_gateway:
|
||||
# 计算时间戳和序列号(使用当前的数据包索引确保连续性)
|
||||
|
||||
@@ -96,6 +96,8 @@ class ASRProvider(ASRProviderBase):
|
||||
self.delete_audio_file = delete_audio_file
|
||||
self.expire_time = None
|
||||
|
||||
self.task_id = uuid.uuid4().hex
|
||||
|
||||
# Token管理
|
||||
if self.access_key_id and self.access_key_secret:
|
||||
self._refresh_token()
|
||||
@@ -169,19 +171,23 @@ class ASRProvider(ASRProviderBase):
|
||||
ping_timeout=None,
|
||||
close_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
self.task_id = uuid.uuid4().hex
|
||||
|
||||
logger.bind(tag=TAG).info(f"WebSocket连接建立成功, task_id: {self.task_id}")
|
||||
|
||||
self.is_processing = True
|
||||
self.server_ready = False # 重置服务器准备状态
|
||||
self.forward_task = asyncio.create_task(self._forward_results(conn))
|
||||
|
||||
|
||||
# 发送开始请求
|
||||
start_request = {
|
||||
"header": {
|
||||
"namespace": "SpeechTranscriber",
|
||||
"name": "StartTranscription",
|
||||
"status": 20000000,
|
||||
"message_id": ''.join(random.choices('0123456789abcdef', k=32)),
|
||||
"task_id": ''.join(random.choices('0123456789abcdef', k=32)),
|
||||
"message_id": uuid.uuid4().hex,
|
||||
"task_id": self.task_id,
|
||||
"status_text": "Gateway:SUCCESS:Success.",
|
||||
"appkey": self.appkey
|
||||
},
|
||||
@@ -292,7 +298,8 @@ class ASRProvider(ASRProviderBase):
|
||||
"namespace": "SpeechTranscriber",
|
||||
"name": "StopTranscription",
|
||||
"status": 20000000,
|
||||
"message_id": ''.join(random.choices('0123456789abcdef', k=32)),
|
||||
"message_id": uuid.uuid4().hex,
|
||||
"task_id": self.task_id,
|
||||
"status_text": "Client:Stop",
|
||||
"appkey": self.appkey
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class ASRProviderBase(ABC):
|
||||
conn.asr_audio.clear()
|
||||
conn.reset_vad_states()
|
||||
|
||||
if len(asr_audio_task) > 15:
|
||||
if len(asr_audio_task) > 15 or conn.client_listen_mode == "manual":
|
||||
await self.handle_voice_stop(conn, asr_audio_task)
|
||||
|
||||
# 处理语音停止
|
||||
|
||||
@@ -22,6 +22,7 @@ STATUS_FIRST_FRAME = 0 # 第一帧的标识
|
||||
STATUS_CONTINUE_FRAME = 1 # 中间帧标识
|
||||
STATUS_LAST_FRAME = 2 # 最后一帧的标识
|
||||
|
||||
|
||||
class ASRProvider(ASRProviderBase):
|
||||
def __init__(self, config, delete_audio_file):
|
||||
super().__init__()
|
||||
@@ -35,6 +36,7 @@ class ASRProvider(ASRProviderBase):
|
||||
self.server_ready = False
|
||||
self.last_frame_sent = False # 标记是否已发送最终帧
|
||||
self.best_text = "" # 保存最佳识别结果
|
||||
self.has_final_result = False # 标记是否收到最终识别结果
|
||||
|
||||
# 讯飞配置
|
||||
self.app_id = config.get("app_id")
|
||||
@@ -50,19 +52,15 @@ class ASRProvider(ASRProviderBase):
|
||||
"language": config.get("language", "zh_cn"),
|
||||
"accent": config.get("accent", "mandarin"),
|
||||
"dwa": config.get("dwa", "wpgs"),
|
||||
"result": {
|
||||
"encoding": "utf8",
|
||||
"compress": "raw",
|
||||
"format": "plain"
|
||||
}
|
||||
"result": {"encoding": "utf8", "compress": "raw", "format": "plain"},
|
||||
}
|
||||
|
||||
self.output_dir = config.get("output_dir", "tmp/")
|
||||
self.delete_audio_file = delete_audio_file
|
||||
|
||||
|
||||
def create_url(self) -> str:
|
||||
"""生成认证URL"""
|
||||
url = 'ws://iat.cn-huabei-1.xf-yun.com/v1'
|
||||
url = "ws://iat.cn-huabei-1.xf-yun.com/v1"
|
||||
# 生成RFC1123格式的时间戳
|
||||
now = datetime.now()
|
||||
date = format_date_time(mktime(now.timetuple()))
|
||||
@@ -73,23 +71,30 @@ class ASRProvider(ASRProviderBase):
|
||||
signature_origin += "GET " + "/v1 " + "HTTP/1.1"
|
||||
|
||||
# 进行hmac-sha256进行加密
|
||||
signature_sha = hmac.new(self.api_secret.encode('utf-8'), signature_origin.encode('utf-8'),
|
||||
digestmod=hashlib.sha256).digest()
|
||||
signature_sha = base64.b64encode(signature_sha).decode(encoding='utf-8')
|
||||
signature_sha = hmac.new(
|
||||
self.api_secret.encode("utf-8"),
|
||||
signature_origin.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
signature_sha = base64.b64encode(signature_sha).decode(encoding="utf-8")
|
||||
|
||||
authorization_origin = "api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"" % (
|
||||
self.api_key, "hmac-sha256", "host date request-line", signature_sha)
|
||||
authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8')
|
||||
authorization_origin = (
|
||||
'api_key="%s", algorithm="%s", headers="%s", signature="%s"'
|
||||
% (self.api_key, "hmac-sha256", "host date request-line", signature_sha)
|
||||
)
|
||||
authorization = base64.b64encode(authorization_origin.encode("utf-8")).decode(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# 将请求的鉴权参数组合为字典
|
||||
v = {
|
||||
"authorization": authorization,
|
||||
"date": date,
|
||||
"host": "iat.cn-huabei-1.xf-yun.com"
|
||||
"host": "iat.cn-huabei-1.xf-yun.com",
|
||||
}
|
||||
|
||||
# 拼接鉴权参数,生成url
|
||||
url = url + '?' + urlencode(v)
|
||||
url = url + "?" + urlencode(v)
|
||||
return url
|
||||
|
||||
async def open_audio_channels(self, conn):
|
||||
@@ -100,7 +105,7 @@ class ASRProvider(ASRProviderBase):
|
||||
await super().receive_audio(conn, audio, audio_have_voice)
|
||||
|
||||
# 存储音频数据用于声纹识别
|
||||
if not hasattr(conn, 'asr_audio_for_voiceprint'):
|
||||
if not hasattr(conn, "asr_audio_for_voiceprint"):
|
||||
conn.asr_audio_for_voiceprint = []
|
||||
conn.asr_audio_for_voiceprint.append(audio)
|
||||
|
||||
@@ -146,8 +151,10 @@ class ASRProvider(ASRProviderBase):
|
||||
|
||||
# 发送首帧音频
|
||||
if conn.asr_audio and len(conn.asr_audio) > 0:
|
||||
first_audio = conn.asr_audio[-1] if conn.asr_audio else b''
|
||||
pcm_frame = self.decoder.decode(first_audio, 960) if first_audio else b''
|
||||
first_audio = conn.asr_audio[-1] if conn.asr_audio else b""
|
||||
pcm_frame = (
|
||||
self.decoder.decode(first_audio, 960) if first_audio else b""
|
||||
)
|
||||
await self._send_audio_frame(pcm_frame, STATUS_FIRST_FRAME)
|
||||
self.server_ready = True
|
||||
logger.bind(tag=TAG).info("已发送首帧,开始识别")
|
||||
@@ -176,23 +183,14 @@ class ASRProvider(ASRProviderBase):
|
||||
if not self.asr_ws:
|
||||
return
|
||||
|
||||
audio_b64 = base64.b64encode(audio_data).decode('utf-8')
|
||||
audio_b64 = base64.b64encode(audio_data).decode("utf-8")
|
||||
|
||||
frame_data = {
|
||||
"header": {
|
||||
"status": status,
|
||||
"app_id": self.app_id
|
||||
},
|
||||
"parameter": {
|
||||
"iat": self.iat_params
|
||||
},
|
||||
"header": {"status": status, "app_id": self.app_id},
|
||||
"parameter": {"iat": self.iat_params},
|
||||
"payload": {
|
||||
"audio": {
|
||||
"audio": audio_b64,
|
||||
"sample_rate": 16000,
|
||||
"encoding": "raw"
|
||||
}
|
||||
}
|
||||
"audio": {"audio": audio_b64, "sample_rate": 16000, "encoding": "raw"}
|
||||
},
|
||||
}
|
||||
|
||||
await self.asr_ws.send(json.dumps(frame_data, ensure_ascii=False))
|
||||
@@ -207,11 +205,13 @@ class ASRProvider(ASRProviderBase):
|
||||
try:
|
||||
while self.asr_ws and not conn.stop_event.is_set():
|
||||
# 获取当前连接的音频数据
|
||||
audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
|
||||
audio_data = getattr(conn, "asr_audio_for_voiceprint", [])
|
||||
try:
|
||||
# 如果已发送最终帧,增加超时时间等待完整结果
|
||||
timeout = 3.0 if self.last_frame_sent else 30.0
|
||||
response = await asyncio.wait_for(self.asr_ws.recv(), timeout=timeout)
|
||||
response = await asyncio.wait_for(
|
||||
self.asr_ws.recv(), timeout=timeout
|
||||
)
|
||||
result = json.loads(response)
|
||||
logger.bind(tag=TAG).debug(f"收到ASR结果: {result}")
|
||||
|
||||
@@ -221,7 +221,9 @@ class ASRProvider(ASRProviderBase):
|
||||
status = header.get("status", 0)
|
||||
|
||||
if code != 0:
|
||||
logger.bind(tag=TAG).error(f"识别错误,错误码: {code}, 消息: {header.get('message', '')}")
|
||||
logger.bind(tag=TAG).error(
|
||||
f"识别错误,错误码: {code}, 消息: {header.get('message', '')}"
|
||||
)
|
||||
if code in [10114, 10160]: # 连接问题
|
||||
break
|
||||
continue
|
||||
@@ -231,52 +233,124 @@ class ASRProvider(ASRProviderBase):
|
||||
text_data = payload["result"]["text"]
|
||||
if text_data:
|
||||
# 解码base64文本
|
||||
decoded_text = base64.b64decode(text_data).decode('utf-8')
|
||||
decoded_text = base64.b64decode(text_data).decode("utf-8")
|
||||
text_json = json.loads(decoded_text)
|
||||
|
||||
# 提取文本内容
|
||||
text_ws = text_json.get('ws', [])
|
||||
result_text = ''
|
||||
text_ws = text_json.get("ws", [])
|
||||
result_text = ""
|
||||
for i in text_ws:
|
||||
for j in i.get("cw", []):
|
||||
w = j.get("w", "")
|
||||
result_text += w
|
||||
|
||||
# 更新识别文本 - 实时更新策略
|
||||
if result_text and result_text.strip() not in ['', '。', '.', ',', ',']:
|
||||
# 只检查是否为空字符串,不再过滤任何标点符号
|
||||
# 这样可以确保所有识别到的内容,包括标点符号都能被实时更新
|
||||
if result_text and result_text.strip():
|
||||
# 实时更新:正常情况下都更新,提高响应速度
|
||||
should_update = True
|
||||
|
||||
# 保存最佳文本(最长的有意义文本)
|
||||
if (len(result_text) > len(self.best_text) and
|
||||
result_text.strip() not in ['?', '?', '。', '.']):
|
||||
self.best_text = result_text
|
||||
logger.bind(tag=TAG).debug(f"保存最佳文本: {self.best_text}")
|
||||
# 保存最佳文本
|
||||
# 1. 如果是识别完成状态或最终帧后收到的结果,优先保存
|
||||
# 2. 否则保存最长的有意义文本
|
||||
# 取消对标点符号的过滤,只检查是否为空
|
||||
# 这样可以保留所有识别到的内容,包括各种标点符号
|
||||
is_valid_text = len(result_text.strip()) > 0
|
||||
|
||||
# 如果已发送最终帧,只过滤明显的无效结果
|
||||
if (
|
||||
self.last_frame_sent or status == 2
|
||||
) and is_valid_text:
|
||||
self.best_text = result_text
|
||||
self.has_final_result = True # 标记已收到最终结果
|
||||
logger.bind(tag=TAG).debug(
|
||||
f"保存最终识别结果: {self.best_text}"
|
||||
)
|
||||
elif (
|
||||
len(result_text) > len(self.best_text)
|
||||
and is_valid_text
|
||||
and not self.has_final_result
|
||||
):
|
||||
self.best_text = result_text
|
||||
logger.bind(tag=TAG).debug(
|
||||
f"保存中间最佳文本: {self.best_text}"
|
||||
)
|
||||
|
||||
# 如果已发送最终帧,只过滤空文本
|
||||
if self.last_frame_sent:
|
||||
# 最终帧后拒绝问号等明显错误的结果
|
||||
if result_text.strip() in ['?', '?', '。', '.']:
|
||||
# 只拒绝完全空的结果
|
||||
if not result_text.strip():
|
||||
should_update = False
|
||||
logger.bind(tag=TAG).warning(f"最终帧后拒绝无效文本: {result_text}")
|
||||
logger.bind(tag=TAG).warning(
|
||||
f"最终帧后拒绝空文本"
|
||||
)
|
||||
|
||||
if should_update:
|
||||
self.text = result_text
|
||||
logger.bind(tag=TAG).info(f"实时更新识别文本: {self.text} (最终帧已发送: {self.last_frame_sent})")
|
||||
# 处理流式识别结果,避免简单替换导致内容丢失
|
||||
# 1. 如果是中间状态(非最终帧后),可能需要替换为更完整的识别
|
||||
# 2. 如果是最终帧后收到的结果,可能是对前面文本的补充
|
||||
if self.last_frame_sent:
|
||||
# 最终帧后收到的结果可能是标点符号等补充内容
|
||||
# 检查是否需要合并文本而不是替换
|
||||
# 如果当前文本是纯标点而前面已有内容,应该追加而不是替换
|
||||
if len(
|
||||
self.text
|
||||
) > 0 and result_text.strip() in [
|
||||
"。",
|
||||
".",
|
||||
"?",
|
||||
"?",
|
||||
"!",
|
||||
"!",
|
||||
",",
|
||||
",",
|
||||
";",
|
||||
";",
|
||||
]:
|
||||
# 对于标点符号,追加到现有文本后
|
||||
self.text = (
|
||||
self.text.rstrip().rstrip("。.")
|
||||
+ result_text
|
||||
)
|
||||
else:
|
||||
# 其他情况保持替换逻辑
|
||||
self.text = result_text
|
||||
else:
|
||||
# 中间状态替换为新的识别结果
|
||||
self.text = result_text
|
||||
|
||||
logger.bind(tag=TAG).info(
|
||||
f"实时更新识别文本: {self.text} (最终帧已发送: {self.last_frame_sent})"
|
||||
)
|
||||
|
||||
# 识别完成,但如果还没发送最终帧,继续等待
|
||||
if status == 2:
|
||||
logger.bind(tag=TAG).info(f"识别完成状态已到达,当前识别文本: {self.text}")
|
||||
logger.bind(tag=TAG).info(
|
||||
f"识别完成状态已到达,当前识别文本: {self.text}"
|
||||
)
|
||||
|
||||
# 如果还没发送最终帧,继续等待
|
||||
if not self.last_frame_sent:
|
||||
logger.bind(tag=TAG).info("识别完成但最终帧未发送,继续等待...")
|
||||
logger.bind(tag=TAG).info(
|
||||
"识别完成但最终帧未发送,继续等待..."
|
||||
)
|
||||
continue
|
||||
|
||||
# 已发送最终帧且收到完成状态,使用最佳文本作为最终结果
|
||||
if self.best_text and len(self.best_text) > len(self.text):
|
||||
logger.bind(tag=TAG).info(f"使用最佳文本作为最终结果: {self.text} -> {self.best_text}")
|
||||
self.text = self.best_text
|
||||
# 已发送最终帧且收到完成状态,使用最佳策略选择最终结果
|
||||
# 优先使用识别完成状态下的最新结果,而不是仅仅基于长度
|
||||
if self.best_text:
|
||||
# 如果当前文本是在最终帧发送后或识别完成状态下收到的,优先使用
|
||||
if (
|
||||
self.last_frame_sent or status == 2
|
||||
) and self.text.strip():
|
||||
logger.bind(tag=TAG).info(
|
||||
f"使用完成状态下的最新识别结果: {self.text}"
|
||||
)
|
||||
elif len(self.best_text) > len(self.text):
|
||||
logger.bind(tag=TAG).info(
|
||||
f"使用更长的最佳文本作为最终结果: {self.text} -> {self.best_text}"
|
||||
)
|
||||
self.text = self.best_text
|
||||
|
||||
logger.bind(tag=TAG).info(f"获取到最终完整文本: {self.text}")
|
||||
conn.reset_vad_states()
|
||||
@@ -289,9 +363,13 @@ class ASRProvider(ASRProviderBase):
|
||||
if self.last_frame_sent:
|
||||
# 超时时也使用最佳文本
|
||||
if self.best_text and len(self.best_text) > len(self.text):
|
||||
logger.bind(tag=TAG).info(f"超时,使用最佳文本: {self.text} -> {self.best_text}")
|
||||
logger.bind(tag=TAG).info(
|
||||
f"超时,使用最佳文本: {self.text} -> {self.best_text}"
|
||||
)
|
||||
self.text = self.best_text
|
||||
logger.bind(tag=TAG).info(f"最终帧后超时,使用结果: {self.text}")
|
||||
logger.bind(tag=TAG).info(
|
||||
f"最终帧后超时,使用结果: {self.text}"
|
||||
)
|
||||
break
|
||||
# 如果还没发送最终帧,继续等待
|
||||
continue
|
||||
@@ -316,11 +394,11 @@ class ASRProvider(ASRProviderBase):
|
||||
self.asr_ws = None
|
||||
self.is_processing = False
|
||||
if conn:
|
||||
if hasattr(conn, 'asr_audio_for_voiceprint'):
|
||||
if hasattr(conn, "asr_audio_for_voiceprint"):
|
||||
conn.asr_audio_for_voiceprint = []
|
||||
if hasattr(conn, 'asr_audio'):
|
||||
if hasattr(conn, "asr_audio"):
|
||||
conn.asr_audio = []
|
||||
if hasattr(conn, 'has_valid_voice'):
|
||||
if hasattr(conn, "has_valid_voice"):
|
||||
conn.has_valid_voice = False
|
||||
|
||||
async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]):
|
||||
@@ -330,7 +408,7 @@ class ASRProvider(ASRProviderBase):
|
||||
if self.asr_ws and self.is_processing:
|
||||
try:
|
||||
# 取最后一个有效的音频帧作为最后一帧数据
|
||||
last_frame = b''
|
||||
last_frame = b""
|
||||
if asr_audio_task:
|
||||
last_audio = asr_audio_task[-1]
|
||||
last_frame = self.decoder.decode(last_audio, 960)
|
||||
@@ -338,8 +416,7 @@ class ASRProvider(ASRProviderBase):
|
||||
logger.bind(tag=TAG).info("已发送最后一帧")
|
||||
|
||||
# 发送最终帧后,给_forward_results适当时间处理最终结果
|
||||
# 减少等待时间,提高响应速度
|
||||
await asyncio.sleep(0.1) # 从3秒减少到1秒
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
logger.bind(tag=TAG).info(f"准备处理最终识别结果: {self.text}")
|
||||
except Exception as e:
|
||||
@@ -350,8 +427,9 @@ class ASRProvider(ASRProviderBase):
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
|
||||
import traceback
|
||||
|
||||
logger.bind(tag=TAG).debug(f"异常详情: {traceback.format_exc()}")
|
||||
|
||||
|
||||
def stop_ws_connection(self):
|
||||
if self.asr_ws:
|
||||
asyncio.create_task(self.asr_ws.close())
|
||||
@@ -360,12 +438,14 @@ class ASRProvider(ASRProviderBase):
|
||||
|
||||
async def _cleanup(self, conn):
|
||||
"""清理资源"""
|
||||
logger.bind(tag=TAG).info(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}")
|
||||
logger.bind(tag=TAG).info(
|
||||
f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}"
|
||||
)
|
||||
|
||||
# 发送最后一帧
|
||||
if self.asr_ws and self.is_processing:
|
||||
try:
|
||||
await self._send_audio_frame(b'', STATUS_LAST_FRAME)
|
||||
await self._send_audio_frame(b"", STATUS_LAST_FRAME)
|
||||
await asyncio.sleep(0.1)
|
||||
logger.bind(tag=TAG).info("已发送最后一帧")
|
||||
except Exception as e:
|
||||
@@ -376,6 +456,7 @@ class ASRProvider(ASRProviderBase):
|
||||
self.server_ready = False
|
||||
self.last_frame_sent = False
|
||||
self.best_text = ""
|
||||
self.has_final_result = False
|
||||
logger.bind(tag=TAG).info("ASR状态已重置")
|
||||
|
||||
# 清理任务
|
||||
@@ -403,11 +484,11 @@ class ASRProvider(ASRProviderBase):
|
||||
|
||||
# 清理连接的音频缓存
|
||||
if conn:
|
||||
if hasattr(conn, 'asr_audio_for_voiceprint'):
|
||||
if hasattr(conn, "asr_audio_for_voiceprint"):
|
||||
conn.asr_audio_for_voiceprint = []
|
||||
if hasattr(conn, 'asr_audio'):
|
||||
if hasattr(conn, "asr_audio"):
|
||||
conn.asr_audio = []
|
||||
if hasattr(conn, 'has_valid_voice'):
|
||||
if hasattr(conn, "has_valid_voice"):
|
||||
conn.has_valid_voice = False
|
||||
|
||||
logger.bind(tag=TAG).info("ASR会话清理完成")
|
||||
@@ -432,11 +513,11 @@ class ASRProvider(ASRProviderBase):
|
||||
self.forward_task = None
|
||||
self.is_processing = False
|
||||
# 清理所有连接的音频缓冲区
|
||||
if hasattr(self, '_connections'):
|
||||
if hasattr(self, "_connections"):
|
||||
for conn in self._connections.values():
|
||||
if hasattr(conn, 'asr_audio_for_voiceprint'):
|
||||
if hasattr(conn, "asr_audio_for_voiceprint"):
|
||||
conn.asr_audio_for_voiceprint = []
|
||||
if hasattr(conn, 'asr_audio'):
|
||||
if hasattr(conn, "asr_audio"):
|
||||
conn.asr_audio = []
|
||||
if hasattr(conn, 'has_valid_voice'):
|
||||
conn.has_valid_voice = False
|
||||
if hasattr(conn, "has_valid_voice"):
|
||||
conn.has_valid_voice = False
|
||||
|
||||
@@ -349,11 +349,6 @@ async def call_mcp_endpoint_tool(
|
||||
raise ValueError(f"参数处理失败: {str(e)}")
|
||||
raise e
|
||||
|
||||
# 加入MAC地址
|
||||
if mcp_client.conn and mcp_client.conn.device_id:
|
||||
arguments['mac_address'] = mcp_client.conn.device_id
|
||||
logger.bind(tag=TAG).info(f"已将设备MAC地址 {mcp_client.conn.device_id} 加入到MCP接入点工具调用参数中")
|
||||
|
||||
actual_name = mcp_client.name_mapping.get(tool_name, tool_name)
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
@@ -363,7 +358,9 @@ async def call_mcp_endpoint_tool(
|
||||
}
|
||||
|
||||
message = json.dumps(payload)
|
||||
logger.bind(tag=TAG).info(f"发送MCP接入点工具调用请求: {actual_name},参数: {json.dumps(arguments, ensure_ascii=False)}")
|
||||
logger.bind(tag=TAG).info(
|
||||
f"发送MCP接入点工具调用请求: {actual_name},参数: {json.dumps(arguments, ensure_ascii=False)}"
|
||||
)
|
||||
await mcp_client.send_message(message)
|
||||
|
||||
try:
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Optional, List, Dict, Any
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from config.logger import setup_logging
|
||||
from core.utils.util import sanitize_tool_name
|
||||
|
||||
@@ -172,10 +173,33 @@ class ServerMCPClient:
|
||||
if "API_ACCESS_TOKEN" in self.config:
|
||||
headers["Authorization"] = f"Bearer {self.config['API_ACCESS_TOKEN']}"
|
||||
self.logger.bind(tag=TAG).warning(f"你正在使用旧过时的配置 API_ACCESS_TOKEN ,请在.mcp_server_settings.json中将API_ACCESS_TOKEN直接设置在headers中,例如 'Authorization': 'Bearer API_ACCESS_TOKEN'")
|
||||
sse_r, sse_w = await stack.enter_async_context(
|
||||
sse_client(self.config["url"], headers=headers, timeout=self.config.get("timeout", 5), sse_read_timeout=self.config.get("sse_read_timeout", 60 * 5))
|
||||
)
|
||||
read_stream, write_stream = sse_r, sse_w
|
||||
|
||||
# 根据transport类型选择不同的客户端,默认为SSE
|
||||
transport_type = self.config.get("transport", "sse")
|
||||
|
||||
if transport_type == "streamable-http" or transport_type == "http":
|
||||
# 使用 Streamable HTTP 传输
|
||||
http_r, http_w, get_session_id = await stack.enter_async_context(
|
||||
streamablehttp_client(
|
||||
url=self.config["url"],
|
||||
headers=headers,
|
||||
timeout=self.config.get("timeout", 30),
|
||||
sse_read_timeout=self.config.get("sse_read_timeout", 60 * 5),
|
||||
terminate_on_close=self.config.get("terminate_on_close", True)
|
||||
)
|
||||
)
|
||||
read_stream, write_stream = http_r, http_w
|
||||
else:
|
||||
# 使用传统的 SSE 传输
|
||||
sse_r, sse_w = await stack.enter_async_context(
|
||||
sse_client(
|
||||
url=self.config["url"],
|
||||
headers=headers,
|
||||
timeout=self.config.get("timeout", 5),
|
||||
sse_read_timeout=self.config.get("sse_read_timeout", 60 * 5)
|
||||
)
|
||||
)
|
||||
read_stream, write_stream = sse_r, sse_w
|
||||
|
||||
else:
|
||||
raise ValueError("MCP客户端配置必须包含'command'或'url'")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import random
|
||||
import uuid
|
||||
import json
|
||||
import hmac
|
||||
@@ -131,7 +132,7 @@ class TTSProvider(TTSProviderBase):
|
||||
self.last_active_time = None
|
||||
|
||||
# 专属tts设置
|
||||
self.message_id = ""
|
||||
self.task_id = uuid.uuid4().hex
|
||||
|
||||
# 创建Opus编码器
|
||||
self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
|
||||
@@ -185,7 +186,8 @@ class TTSProvider(TTSProviderBase):
|
||||
current_time = time.time()
|
||||
if self.ws and current_time - self.last_active_time < 10:
|
||||
# 10秒内才可以复用链接进行连续对话
|
||||
logger.bind(tag=TAG).info(f"使用已有链接...")
|
||||
self.task_id = uuid.uuid4().hex
|
||||
logger.bind(tag=TAG).info(f"使用已有链接..., task_id: {self.task_id}")
|
||||
return self.ws
|
||||
logger.bind(tag=TAG).info("开始建立新连接...")
|
||||
|
||||
@@ -196,7 +198,8 @@ class TTSProvider(TTSProviderBase):
|
||||
ping_timeout=10,
|
||||
close_timeout=10,
|
||||
)
|
||||
logger.bind(tag=TAG).info("WebSocket连接建立成功")
|
||||
self.task_id = uuid.uuid4().hex
|
||||
logger.bind(tag=TAG).info(f"WebSocket连接建立成功, task_id: {self.task_id}")
|
||||
self.last_active_time = time.time()
|
||||
return self.ws
|
||||
except Exception as e:
|
||||
@@ -224,18 +227,9 @@ class TTSProvider(TTSProviderBase):
|
||||
if message.sentence_type == SentenceType.FIRST:
|
||||
# 初始化参数
|
||||
try:
|
||||
if not getattr(self.conn, "sentence_id", None):
|
||||
self.conn.sentence_id = uuid.uuid4().hex
|
||||
logger.bind(tag=TAG).info(
|
||||
f"自动生成新的 会话ID: {self.conn.sentence_id}"
|
||||
)
|
||||
|
||||
# aliyunStream独有的参数生成
|
||||
self.message_id = str(uuid.uuid4().hex)
|
||||
|
||||
logger.bind(tag=TAG).info("开始启动TTS会话...")
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.start_session(self.conn.sentence_id),
|
||||
self.start_session(self.task_id),
|
||||
loop=self.conn.loop,
|
||||
)
|
||||
future.result()
|
||||
@@ -273,7 +267,7 @@ class TTSProvider(TTSProviderBase):
|
||||
try:
|
||||
logger.bind(tag=TAG).info("开始结束TTS会话...")
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.finish_session(self.conn.sentence_id),
|
||||
self.finish_session(self.task_id),
|
||||
loop=self.conn.loop,
|
||||
)
|
||||
future.result()
|
||||
@@ -296,8 +290,8 @@ class TTSProvider(TTSProviderBase):
|
||||
filtered_text = MarkdownCleaner.clean_markdown(text)
|
||||
run_request = {
|
||||
"header": {
|
||||
"message_id": self.message_id,
|
||||
"task_id": self.conn.sentence_id,
|
||||
"message_id": uuid.uuid4().hex,
|
||||
"task_id": self.task_id,
|
||||
"namespace": "FlowingSpeechSynthesizer",
|
||||
"name": "RunSynthesis",
|
||||
"appkey": self.appkey,
|
||||
@@ -318,8 +312,8 @@ class TTSProvider(TTSProviderBase):
|
||||
self.ws = None
|
||||
raise
|
||||
|
||||
async def start_session(self, session_id):
|
||||
logger.bind(tag=TAG).info(f"开始会话~~{session_id}")
|
||||
async def start_session(self, task_id):
|
||||
logger.bind(tag=TAG).info("开始会话~~")
|
||||
try:
|
||||
# 会话开始时检测上个会话的监听状态
|
||||
if (
|
||||
@@ -340,8 +334,8 @@ class TTSProvider(TTSProviderBase):
|
||||
|
||||
start_request = {
|
||||
"header": {
|
||||
"message_id": self.message_id,
|
||||
"task_id": self.conn.sentence_id,
|
||||
"message_id": uuid.uuid4().hex,
|
||||
"task_id": self.task_id,
|
||||
"namespace": "FlowingSpeechSynthesizer",
|
||||
"name": "StartSynthesis",
|
||||
"appkey": self.appkey,
|
||||
@@ -365,14 +359,14 @@ class TTSProvider(TTSProviderBase):
|
||||
await self.close()
|
||||
raise
|
||||
|
||||
async def finish_session(self, session_id):
|
||||
logger.bind(tag=TAG).info(f"关闭会话~~{session_id}")
|
||||
async def finish_session(self, task_id):
|
||||
logger.bind(tag=TAG).info(f"关闭会话~~{task_id}")
|
||||
try:
|
||||
if self.ws:
|
||||
stop_request = {
|
||||
"header": {
|
||||
"message_id": self.message_id,
|
||||
"task_id": self.conn.sentence_id,
|
||||
"message_id": uuid.uuid4().hex,
|
||||
"task_id": self.task_id,
|
||||
"namespace": "FlowingSpeechSynthesizer",
|
||||
"name": "StopSynthesis",
|
||||
"appkey": self.appkey,
|
||||
@@ -484,8 +478,6 @@ class TTSProvider(TTSProviderBase):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
# 生成会话ID
|
||||
session_id = uuid.uuid4().hex
|
||||
# 存储音频数据
|
||||
audio_data = []
|
||||
|
||||
@@ -504,11 +496,10 @@ class TTSProvider(TTSProviderBase):
|
||||
)
|
||||
try:
|
||||
# 发送StartSynthesis请求
|
||||
start_message_id = str(uuid.uuid4().hex)
|
||||
start_request = {
|
||||
"header": {
|
||||
"message_id": start_message_id,
|
||||
"task_id": session_id,
|
||||
"message_id": uuid.uuid4().hex,
|
||||
"task_id": self.task_id,
|
||||
"namespace": "FlowingSpeechSynthesizer",
|
||||
"name": "StartSynthesis",
|
||||
"appkey": self.appkey,
|
||||
@@ -549,11 +540,10 @@ class TTSProvider(TTSProviderBase):
|
||||
|
||||
# 发送文本合成请求
|
||||
filtered_text = MarkdownCleaner.clean_markdown(text)
|
||||
run_message_id = str(uuid.uuid4().hex)
|
||||
run_request = {
|
||||
"header": {
|
||||
"message_id": run_message_id,
|
||||
"task_id": session_id,
|
||||
"message_id": uuid.uuid4().hex,
|
||||
"task_id": self.task_id,
|
||||
"namespace": "FlowingSpeechSynthesizer",
|
||||
"name": "RunSynthesis",
|
||||
"appkey": self.appkey,
|
||||
@@ -563,11 +553,10 @@ class TTSProvider(TTSProviderBase):
|
||||
await ws.send(json.dumps(run_request))
|
||||
|
||||
# 发送停止合成请求
|
||||
stop_message_id = str(uuid.uuid4().hex)
|
||||
stop_request = {
|
||||
"header": {
|
||||
"message_id": stop_message_id,
|
||||
"task_id": session_id,
|
||||
"message_id": uuid.uuid4().hex,
|
||||
"task_id": self.task_id,
|
||||
"namespace": "FlowingSpeechSynthesizer",
|
||||
"name": "StopSynthesis",
|
||||
"appkey": self.appkey,
|
||||
|
||||
@@ -149,6 +149,7 @@ class TTSProvider(TTSProviderBase):
|
||||
self.access_token = config.get("access_token")
|
||||
self.cluster = config.get("cluster")
|
||||
self.resource_id = config.get("resource_id")
|
||||
self.activate_session = False
|
||||
if config.get("private_voice"):
|
||||
self.voice = config.get("private_voice")
|
||||
else:
|
||||
@@ -180,7 +181,7 @@ class TTSProvider(TTSProviderBase):
|
||||
raise
|
||||
|
||||
async def _ensure_connection(self):
|
||||
"""建立新的WebSocket连接"""
|
||||
"""建立新的WebSocket连接,并启动监听任务(仅第一次)"""
|
||||
try:
|
||||
if self.ws:
|
||||
logger.bind(tag=TAG).info(f"使用已有链接...")
|
||||
@@ -196,6 +197,12 @@ class TTSProvider(TTSProviderBase):
|
||||
self.ws_url, additional_headers=ws_header, max_size=1000000000
|
||||
)
|
||||
logger.bind(tag=TAG).info("WebSocket连接建立成功")
|
||||
|
||||
# 连接建立成功后,启动监听任务
|
||||
if self._monitor_task is None or self._monitor_task.done():
|
||||
logger.bind(tag=TAG).info("启动监听任务...")
|
||||
self._monitor_task = asyncio.create_task(self._start_monitor_tts_response())
|
||||
|
||||
return self.ws
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"建立连接失败: {str(e)}")
|
||||
@@ -314,22 +321,24 @@ class TTSProvider(TTSProviderBase):
|
||||
|
||||
async def start_session(self, session_id):
|
||||
logger.bind(tag=TAG).info(f"开始会话~~{session_id}")
|
||||
try:
|
||||
# 会话开始时检测上个会话的监听状态
|
||||
if (
|
||||
self._monitor_task is not None
|
||||
and isinstance(self._monitor_task, Task)
|
||||
and not self._monitor_task.done()
|
||||
):
|
||||
logger.bind(tag=TAG).info("检测到未完成的上个会话,关闭监听任务和连接...")
|
||||
try:
|
||||
# 等待上一个会话结束,最多等待3次
|
||||
for _ in range(3):
|
||||
if not self.activate_session:
|
||||
break
|
||||
logger.bind(tag=TAG).debug(f"等待上一个会话结束...")
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
# 等待超时,强制清除连接状态
|
||||
logger.bind(tag=TAG).debug("等待上一个会话超时,清除连接状态...")
|
||||
await self.close()
|
||||
|
||||
# 建立新连接
|
||||
|
||||
# 设置会话激活标志
|
||||
self.activate_session = True
|
||||
|
||||
# 确保连接建立
|
||||
await self._ensure_connection()
|
||||
|
||||
# 启动监听任务
|
||||
self._monitor_task = asyncio.create_task(self._start_monitor_tts_response())
|
||||
|
||||
header = Header(
|
||||
message_type=FULL_CLIENT_REQUEST,
|
||||
message_type_specific_flags=MsgTypeFlagWithEvent,
|
||||
@@ -365,17 +374,6 @@ class TTSProvider(TTSProviderBase):
|
||||
await self.send_event(self.ws, header, optional, payload)
|
||||
logger.bind(tag=TAG).info("会话结束请求已发送")
|
||||
|
||||
# 等待监听任务完成
|
||||
if self._monitor_task:
|
||||
try:
|
||||
await self._monitor_task
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(
|
||||
f"等待监听任务完成时发生错误: {str(e)}"
|
||||
)
|
||||
finally:
|
||||
self._monitor_task = None
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"关闭会话失败: {str(e)}")
|
||||
# 确保清理资源
|
||||
@@ -405,6 +403,7 @@ class TTSProvider(TTSProviderBase):
|
||||
|
||||
async def close(self):
|
||||
"""资源清理方法"""
|
||||
self.activate_session = False
|
||||
# 取消监听任务
|
||||
if self._monitor_task:
|
||||
try:
|
||||
@@ -424,9 +423,8 @@ class TTSProvider(TTSProviderBase):
|
||||
self.ws = None
|
||||
|
||||
async def _start_monitor_tts_response(self):
|
||||
"""监听TTS响应"""
|
||||
"""监听TTS响应 - 长期运行"""
|
||||
try:
|
||||
session_finished = False # 标记会话是否正常结束
|
||||
while not self.conn.stop_event.is_set():
|
||||
try:
|
||||
# 确保 `recv()` 运行在同一个 event loop
|
||||
@@ -434,10 +432,17 @@ class TTSProvider(TTSProviderBase):
|
||||
res = self.parser_response(msg)
|
||||
self.print_response(res, "send_text res:")
|
||||
|
||||
# 只处理当前活跃会话的响应
|
||||
if res.optional.sessionId and self.conn.sentence_id != res.optional.sessionId:
|
||||
# 如果是会话结束相关事件,即使会话ID不匹配也要重置状态
|
||||
if res.optional.event in [EVENT_SessionCanceled, EVENT_SessionFailed, EVENT_SessionFinished]:
|
||||
logger.bind(tag=TAG).debug(f"收到残余下行结束响应重置会话状态~~")
|
||||
self.activate_session = False
|
||||
continue
|
||||
|
||||
if res.optional.event == EVENT_SessionCanceled:
|
||||
logger.bind(tag=TAG).debug(f"释放服务端资源成功~~")
|
||||
session_finished = True
|
||||
break
|
||||
self.activate_session = False
|
||||
elif res.optional.event == EVENT_TTSSentenceStart:
|
||||
json_data = json.loads(res.payload.decode("utf-8"))
|
||||
self.tts_text = json_data.get("text", "")
|
||||
@@ -454,9 +459,8 @@ class TTSProvider(TTSProviderBase):
|
||||
logger.bind(tag=TAG).info(f"句子语音生成成功:{self.tts_text}")
|
||||
elif res.optional.event == EVENT_SessionFinished:
|
||||
logger.bind(tag=TAG).debug(f"会话结束~~")
|
||||
self.activate_session = False
|
||||
self._process_before_stop_play_files()
|
||||
session_finished = True
|
||||
break
|
||||
except websockets.ConnectionClosed:
|
||||
logger.bind(tag=TAG).warning("WebSocket连接已关闭")
|
||||
break
|
||||
@@ -466,8 +470,8 @@ class TTSProvider(TTSProviderBase):
|
||||
)
|
||||
traceback.print_exc()
|
||||
break
|
||||
# 仅在连接异常时才关闭
|
||||
if not session_finished and self.ws:
|
||||
# 连接异常时关闭WebSocket
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except:
|
||||
@@ -513,7 +517,7 @@ class TTSProvider(TTSProviderBase):
|
||||
def read_res_content(self, res: bytes, offset: int):
|
||||
content_size = int.from_bytes(res[offset : offset + 4], "big", signed=True)
|
||||
offset += 4
|
||||
content = str(res[offset : offset + content_size])
|
||||
content = res[offset : offset + content_size].decode('utf-8')
|
||||
offset += content_size
|
||||
return content, offset
|
||||
|
||||
|
||||
@@ -70,7 +70,9 @@ class VADProvider(VADProviderBase):
|
||||
|
||||
# 更新滑动窗口
|
||||
conn.client_voice_window.append(is_voice)
|
||||
client_have_voice = (conn.client_voice_window.count(True) >= self.frame_window_threshold)
|
||||
client_have_voice = (
|
||||
conn.client_voice_window.count(True) >= self.frame_window_threshold
|
||||
)
|
||||
|
||||
# 如果之前有声音,但本次没有声音,且与上次有声音的时间差已经超过了静默阈值,则认为已经说完一句话
|
||||
if conn.client_have_voice and not client_have_voice:
|
||||
|
||||
@@ -66,7 +66,7 @@ class PromptManager:
|
||||
def _load_base_template(self):
|
||||
"""加载基础提示词模板"""
|
||||
try:
|
||||
template_path = "agent-base-prompt.txt"
|
||||
template_path = self.config.get("prompt_template", "agent-base-prompt.txt")
|
||||
cache_key = f"prompt_template:{template_path}"
|
||||
|
||||
# 先从缓存获取
|
||||
@@ -88,7 +88,7 @@ class PromptManager:
|
||||
self.base_prompt_template = template_content
|
||||
self.logger.bind(tag=TAG).debug("成功加载基础提示词模板并缓存")
|
||||
else:
|
||||
self.logger.bind(tag=TAG).warning("未找到agent-base-prompt.txt文件")
|
||||
self.logger.bind(tag=TAG).warning(f"未找到{template_path}文件")
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"加载提示词模板失败: {e}")
|
||||
|
||||
@@ -225,6 +225,7 @@ class PromptManager:
|
||||
weather_info=weather_info,
|
||||
emojiList=EMOJI_List,
|
||||
device_id=device_id,
|
||||
client_ip=client_ip,
|
||||
*args, **kwargs
|
||||
)
|
||||
device_cache_key = f"device_prompt:{device_id}"
|
||||
|
||||
@@ -176,31 +176,64 @@ def parse_string_to_list(value, separator=";"):
|
||||
return []
|
||||
|
||||
|
||||
def check_ffmpeg_installed():
|
||||
ffmpeg_installed = False
|
||||
def check_ffmpeg_installed() -> bool:
|
||||
"""
|
||||
检查当前环境中是否已正确安装并可执行 ffmpeg。
|
||||
|
||||
Returns:
|
||||
bool: 如果 ffmpeg 正常可用,返回 True;否则抛出 ValueError 异常。
|
||||
|
||||
Raises:
|
||||
ValueError: 当检测到 ffmpeg 未安装或依赖缺失时,抛出详细的提示信息。
|
||||
"""
|
||||
try:
|
||||
# 执行ffmpeg -version命令,并捕获输出
|
||||
# 尝试执行 ffmpeg 命令
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-version"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=True, # 如果返回码非零则抛出异常
|
||||
check=True, # 非零退出码会触发 CalledProcessError
|
||||
)
|
||||
# 检查输出中是否包含版本信息(可选)
|
||||
output = result.stdout + result.stderr
|
||||
if "ffmpeg version" in output.lower():
|
||||
ffmpeg_installed = True
|
||||
return False
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
# 命令执行失败或未找到
|
||||
ffmpeg_installed = False
|
||||
if not ffmpeg_installed:
|
||||
error_msg = "您的电脑还没正确安装ffmpeg\n"
|
||||
error_msg += "\n建议您:\n"
|
||||
error_msg += "1、按照项目的安装文档,正确进入conda环境\n"
|
||||
error_msg += "2、查阅安装文档,如何在conda环境中安装ffmpeg\n"
|
||||
raise ValueError(error_msg)
|
||||
|
||||
output = (result.stdout + result.stderr).lower()
|
||||
if "ffmpeg version" in output:
|
||||
return True
|
||||
|
||||
# 如果未检测到版本信息,也视为异常情况
|
||||
raise ValueError("未检测到有效的 ffmpeg 版本输出。")
|
||||
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
# 提取错误输出
|
||||
stderr_output = ""
|
||||
if isinstance(e, subprocess.CalledProcessError):
|
||||
stderr_output = (e.stderr or "").strip()
|
||||
else:
|
||||
stderr_output = str(e).strip()
|
||||
|
||||
# 构建基础错误提示
|
||||
error_msg = [
|
||||
"❌ 检测到 ffmpeg 无法正常运行。\n",
|
||||
"建议您:",
|
||||
"1. 确认已正确激活 conda 环境;",
|
||||
"2. 查阅项目安装文档,了解如何在 conda 环境中安装 ffmpeg。\n",
|
||||
]
|
||||
|
||||
# 🎯 针对具体错误信息提供额外提示
|
||||
if "libiconv.so.2" in stderr_output:
|
||||
error_msg.append("⚠️ 发现缺少依赖库:libiconv.so.2")
|
||||
error_msg.append("解决方法:在当前 conda 环境中执行:")
|
||||
error_msg.append(" conda install -c conda-forge libiconv\n")
|
||||
elif "no such file or directory" in stderr_output and "ffmpeg" in stderr_output.lower():
|
||||
error_msg.append("⚠️ 系统未找到 ffmpeg 可执行文件。")
|
||||
error_msg.append("解决方法:在当前 conda 环境中执行:")
|
||||
error_msg.append(" conda install -c conda-forge ffmpeg\n")
|
||||
else:
|
||||
error_msg.append("错误详情:")
|
||||
error_msg.append(stderr_output or "未知错误。")
|
||||
|
||||
# 抛出详细异常信息
|
||||
raise ValueError("\n".join(error_msg)) from e
|
||||
|
||||
|
||||
def extract_json_from_string(input_string):
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"后面不断测试补充好用的mcp服务,欢迎大家一起补充。",
|
||||
"记得删除注释行,des属性仅为说明,不会被解析。",
|
||||
"des和link属性,仅为说明安装方式,方便大家查看原始链接,不是必须项。",
|
||||
"当前支持stdio/sse两种模式。"
|
||||
"当前支持三种传输模式:stdio(标准输入输出), sse(Server-Sent Events), streamable-http(流式HTTP)。"
|
||||
],
|
||||
"mcpServers": {
|
||||
"Home Assistant": {
|
||||
@@ -41,7 +41,16 @@
|
||||
"url": "http://localhost:8080/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR TOKEN"
|
||||
}
|
||||
},
|
||||
"des": "使用SSE传输模式(默认)"
|
||||
},
|
||||
"streamable-http-mcp-server": {
|
||||
"url": "http://localhost:8000/mcp",
|
||||
"transport": "streamable-http",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR TOKEN"
|
||||
},
|
||||
"des": "使用Streamable HTTP传输模式,适用于生产环境的Web部署"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class LLMPerformanceTester:
|
||||
"""加载系统提示词"""
|
||||
try:
|
||||
prompt_file = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "agent-base-prompt.txt"
|
||||
os.path.dirname(os.path.dirname(__file__)), self.config.get("prompt_template", "agent-base-prompt.txt")
|
||||
)
|
||||
with open(prompt_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
Executable → Regular
+5
-5
@@ -7,25 +7,25 @@ numpy==1.26.4
|
||||
pydub==0.25.1
|
||||
funasr==1.2.3
|
||||
torchaudio==2.2.2
|
||||
openai==1.107.0
|
||||
openai==2.5.0
|
||||
google-generativeai==0.8.5
|
||||
edge_tts==7.0.0
|
||||
httpx==0.27.2
|
||||
aiohttp==3.12.15
|
||||
aiohttp_cors==0.7.0
|
||||
ormsgpack==1.7.0
|
||||
ormsgpack==1.11.0
|
||||
ruamel.yaml==0.18.15
|
||||
loguru==0.7.3
|
||||
requests==2.32.5
|
||||
cozepy==0.19.0
|
||||
mem0ai==0.1.62
|
||||
mem0ai==1.0.0
|
||||
bs4==0.0.2
|
||||
modelscope==1.23.2
|
||||
sherpa_onnx==1.12.11
|
||||
mcp==1.13.1
|
||||
cnlunar==0.2.0
|
||||
PySocks==1.7.1
|
||||
dashscope==1.23.1
|
||||
dashscope==1.24.6
|
||||
baidu-aip==4.16.13
|
||||
chardet==5.2.0
|
||||
aioconsole==0.8.1
|
||||
@@ -35,4 +35,4 @@ PyJWT==2.8.0
|
||||
psutil==7.0.0
|
||||
portalocker==3.2.0
|
||||
Jinja2==3.1.6
|
||||
vosk==0.3.45
|
||||
vosk==0.3.44
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
[
|
||||
{
|
||||
"name": "self.get_device_status",
|
||||
"description": "Provides the real-time information of the device, including the current status of the audio speaker, screen, battery, network, etc.\nUse this tool for: \n1. Answering questions about current condition (e.g. what is the current volume of the audio speaker?)\n2. As the first step to control the device (e.g. turn up / down the volume of the audio speaker, etc.)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"mockResponse": {
|
||||
"audio_speaker": {
|
||||
"volume": 50,
|
||||
"muted": false
|
||||
},
|
||||
"screen": {
|
||||
"brightness": 80,
|
||||
"theme": "light"
|
||||
},
|
||||
"battery": {
|
||||
"level": 85,
|
||||
"charging": false
|
||||
},
|
||||
"network": {
|
||||
"connected": true,
|
||||
"type": "wifi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "self.audio_speaker.set_volume",
|
||||
"description": "Set the volume of the audio speaker. If the current volume is unknown, you must call `self.get_device_status` tool first and then call this tool.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"volume": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"volume"
|
||||
]
|
||||
},
|
||||
"mockResponse": {
|
||||
"success": true,
|
||||
"volume": "${volume}",
|
||||
"message": "音量已设置为 ${volume}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "self.screen.set_brightness",
|
||||
"description": "Set the brightness of the screen.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"brightness": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"brightness"
|
||||
]
|
||||
},
|
||||
"mockResponse": {
|
||||
"success": true,
|
||||
"brightness": "${brightness}",
|
||||
"message": "亮度已设置为 ${brightness}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -21,9 +21,9 @@ h1 {
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 5px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,163 @@
|
||||
#fileProtocolWarning button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
/* MCP 工具管理样式 */
|
||||
.mcp-tools-container {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mcp-tool-card {
|
||||
background-color: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mcp-tool-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mcp-tool-card.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mcp-tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mcp-tool-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mcp-tool-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mcp-tool-description {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mcp-tool-info {
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mcp-tool-info-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mcp-tool-info-label {
|
||||
color: #999;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.mcp-tool-info-value {
|
||||
color: #333;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.mcp-property-item {
|
||||
background-color: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 5px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mcp-property-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mcp-property-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mcp-property-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mcp-property-row-full {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mcp-small-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.mcp-small-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mcp-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mcp-error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mcp-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.mcp-badge-required {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.mcp-badge-optional {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// 检测是否使用file://协议打开
|
||||
@@ -168,6 +325,83 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP 工具管理区域 -->
|
||||
<div class="section">
|
||||
<h2>
|
||||
MCP 工具管理
|
||||
<span class="connection-status">
|
||||
<span id="mcpToolsCount">0 个工具</span>
|
||||
</span>
|
||||
<button class="toggle-button" id="toggleMcpTools">展开</button>
|
||||
</h2>
|
||||
<div class="config-panel" id="mcpToolsPanel">
|
||||
<div id="mcpToolsContainer" class="mcp-tools-container">
|
||||
<!-- 工具列表将动态插入这里 -->
|
||||
</div>
|
||||
<div style="text-align: center; padding: 15px 0;">
|
||||
<button class="btn" id="addMcpToolBtn" style="background-color: #4caf50;">
|
||||
➕ 添加新工具
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP 工具编辑模态框 -->
|
||||
<div id="mcpToolModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 9999; overflow-y: auto;">
|
||||
<div style="background-color: white; border-radius: 10px; padding: 25px; width: 90%; max-width: 700px; margin: 50px auto; max-height: 85vh; overflow-y: auto;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #e0e0e0;">
|
||||
<h2 id="mcpModalTitle" style="font-size: 20px; font-weight: bold; color: #333; margin: 0;">添加工具</h2>
|
||||
<button id="closeMcpModalBtn" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #666; padding: 0; width: 30px; height: 30px;">×</button>
|
||||
</div>
|
||||
|
||||
<div id="mcpErrorContainer"></div>
|
||||
|
||||
<form id="mcpToolForm">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">工具名称 *</label>
|
||||
<input type="text" id="mcpToolName" placeholder="例如: self.get_device_status" required
|
||||
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">工具描述 *</label>
|
||||
<textarea id="mcpToolDescription" placeholder="详细描述工具的功能和使用场景..." required
|
||||
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; min-height: 80px; resize: vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">输入参数</label>
|
||||
<div style="background-color: #f9f9f9; border: 1px solid #ddd; border-radius: 5px; padding: 15px;">
|
||||
<div id="mcpPropertiesContainer">
|
||||
<div style="text-align: center; padding: 20px; color: #999; font-size: 14px;">暂无参数,点击下方按钮添加参数</div>
|
||||
</div>
|
||||
<button type="button" id="addMcpPropertyBtn"
|
||||
style="width: 100%; margin-top: 10px; padding: 8px 15px; border: none; border-radius: 5px; background-color: #2196f3; color: white; cursor: pointer; font-size: 14px;">
|
||||
➕ 添加参数
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">
|
||||
模拟返回结果 (JSON 格式,可选)
|
||||
<span style="font-size: 12px; color: #999; font-weight: normal;">- 留空则返回默认成功消息</span>
|
||||
</label>
|
||||
<textarea id="mcpMockResponse" placeholder='{"success": true, "data": "执行成功"}'
|
||||
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 13px; font-family: 'Courier New', monospace; min-height: 100px; resize: vertical;"></textarea>
|
||||
<div style="font-size: 12px; color: #666; margin-top: 4px;">
|
||||
💡 提示:如果设置了模拟返回结果,工具调用时将返回这个 JSON 对象
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 25px;">
|
||||
<button type="button" id="cancelMcpBtn" style="padding: 8px 15px; border: none; border-radius: 5px; background-color: #9e9e9e; color: white; cursor: pointer; font-size: 14px;">取消</button>
|
||||
<button type="submit" style="padding: 8px 15px; border: none; border-radius: 5px; background-color: #4caf50; color: white; cursor: pointer; font-size: 14px;">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opus解码库 -->
|
||||
@@ -810,74 +1044,63 @@
|
||||
} else if (message.type === 'mcp') {
|
||||
const payload = message.payload || {};
|
||||
log(`服务器下发: ${JSON.stringify(message)}`, 'info');
|
||||
if (payload) {
|
||||
// 模拟小智客户端行为
|
||||
if (payload.method === 'tools/list') {
|
||||
const replay_message = JSON.stringify({
|
||||
"session_id": "", "type": "mcp", "payload": {
|
||||
"jsonrpc": "2.0", "id": 2, "result": {
|
||||
"tools": [{
|
||||
"name": "self.get_device_status",
|
||||
"description": "Provides the real-time information of the device, including the current status of the audio speaker, screen, battery, network, etc.\nUse this tool for: \n1. Answering questions about current condition (e.g. what is the current volume of the audio speaker?)\n2. As the first step to control the device (e.g. turn up / down the volume of the audio speaker, etc.)",
|
||||
"inputSchema": { "type": "object", "properties": {} }
|
||||
}, {
|
||||
"name": "self.audio_speaker.set_volume",
|
||||
"description": "Set the volume of the audio speaker. If the current volume is unknown, you must call `self.get_device_status` tool first and then call this tool.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"volume": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
}
|
||||
},
|
||||
"required": ["volume"]
|
||||
}
|
||||
}, {
|
||||
"name": "self.screen.set_brightness",
|
||||
"description": "Set the brightness of the screen.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"brightness": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
}
|
||||
},
|
||||
"required": ["brightness"]
|
||||
}
|
||||
}, {
|
||||
"name": "self.screen.set_theme",
|
||||
"description": "Set the theme of the screen. The theme can be 'light' or 'dark'.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "theme": { "type": "string" } },
|
||||
"required": ["theme"]
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
if (payload.method === 'tools/list') {
|
||||
// 返回工具列表
|
||||
const tools = getMcpTools();
|
||||
|
||||
const replyMessage = JSON.stringify({
|
||||
"session_id": message.session_id || "",
|
||||
"type": "mcp",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": payload.id,
|
||||
"result": {
|
||||
"tools": tools
|
||||
}
|
||||
})
|
||||
websocket.send(replay_message);
|
||||
log(`回复MCP消息: ${replay_message}`, 'info');
|
||||
} else if (payload.method === 'tools/call') {
|
||||
// 模拟回复
|
||||
const replay_message = JSON.stringify({
|
||||
"session_id": "9f261599",
|
||||
"type": "mcp",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": payload.id,
|
||||
"result": { "content": [{ "type": "text", "text": "true" }], "isError": false }
|
||||
}
|
||||
});
|
||||
log(`客户端上报: ${replyMessage}`, 'info');
|
||||
websocket.send(replyMessage);
|
||||
log(`回复MCP工具列表: ${tools.length} 个工具`, 'info');
|
||||
|
||||
} else if (payload.method === 'tools/call') {
|
||||
// 调用工具
|
||||
const toolName = payload.params?.name;
|
||||
const toolArgs = payload.params?.arguments;
|
||||
|
||||
log(`调用工具: ${toolName} 参数: ${JSON.stringify(toolArgs)}`, 'info');
|
||||
|
||||
// 执行工具
|
||||
const result = executeMcpTool(toolName, toolArgs);
|
||||
|
||||
const replyMessage = JSON.stringify({
|
||||
"session_id": message.session_id || "",
|
||||
"type": "mcp",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": payload.id,
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": JSON.stringify(result)
|
||||
}
|
||||
],
|
||||
"isError": false
|
||||
}
|
||||
})
|
||||
websocket.send(replay_message);
|
||||
log(`回复MCP消息: ${replay_message}`, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
log(`客户端上报: ${replyMessage}`, 'info');
|
||||
|
||||
websocket.send(replyMessage);
|
||||
} else if(payload.method === 'initialize') {
|
||||
log(`收到工具初始化请求: ${JSON.stringify(payload.params)}`, 'info');
|
||||
// 目前仅记录日志
|
||||
} else {
|
||||
log(`未知的MCP方法: ${payload.method}`, 'warning');
|
||||
}
|
||||
} else {
|
||||
// 未知消息类型
|
||||
log(`未知消息类型: ${message.type}`, 'info');
|
||||
@@ -1566,7 +1789,435 @@
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MCP 工具管理逻辑
|
||||
// ==========================================
|
||||
|
||||
// 默认工具数据
|
||||
const defaultMcpTools = await fetch("default-mcp-tools.json").then(res => res.json());
|
||||
|
||||
// 全局变量
|
||||
let mcpTools = [];
|
||||
let mcpEditingIndex = null;
|
||||
let mcpProperties = [];
|
||||
|
||||
// 初始化 MCP 工具
|
||||
function initMcpTools() {
|
||||
const savedTools = localStorage.getItem('mcpTools');
|
||||
if (savedTools) {
|
||||
try {
|
||||
mcpTools = JSON.parse(savedTools);
|
||||
} catch (e) {
|
||||
log('加载MCP工具失败,使用默认工具', 'warning');
|
||||
mcpTools = [...defaultMcpTools];
|
||||
}
|
||||
} else {
|
||||
mcpTools = [...defaultMcpTools];
|
||||
}
|
||||
|
||||
renderMcpTools();
|
||||
setupMcpEventListeners();
|
||||
}
|
||||
|
||||
// 渲染工具列表
|
||||
function renderMcpTools() {
|
||||
const container = document.getElementById('mcpToolsContainer');
|
||||
const countSpan = document.getElementById('mcpToolsCount');
|
||||
|
||||
countSpan.textContent = `${mcpTools.length} 个工具`;
|
||||
|
||||
if (mcpTools.length === 0) {
|
||||
container.innerHTML = '<div style="text-align: center; padding: 30px; color: #999;">暂无工具,点击下方按钮添加新工具</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = mcpTools.map((tool, index) => {
|
||||
const paramCount = tool.inputSchema.properties ? Object.keys(tool.inputSchema.properties).length : 0;
|
||||
const requiredCount = tool.inputSchema.required ? tool.inputSchema.required.length : 0;
|
||||
const hasMockResponse = tool.mockResponse && Object.keys(tool.mockResponse).length > 0;
|
||||
|
||||
return `
|
||||
<div class="mcp-tool-card">
|
||||
<div class="mcp-tool-header">
|
||||
<div class="mcp-tool-name">${tool.name}</div>
|
||||
<div class="mcp-tool-actions">
|
||||
<button onclick="editMcpTool(${index})"
|
||||
style="padding: 4px 10px; border: none; border-radius: 4px; background-color: #2196f3; color: white; cursor: pointer; font-size: 12px;">
|
||||
✏️ 编辑
|
||||
</button>
|
||||
<button onclick="deleteMcpTool(${index})"
|
||||
style="padding: 4px 10px; border: none; border-radius: 4px; background-color: #f44336; color: white; cursor: pointer; font-size: 12px;">
|
||||
🗑️ 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mcp-tool-description">${tool.description}</div>
|
||||
<div class="mcp-tool-info">
|
||||
<div class="mcp-tool-info-row">
|
||||
<span class="mcp-tool-info-label">参数数量:</span>
|
||||
<span class="mcp-tool-info-value">${paramCount} 个 ${requiredCount > 0 ? `(${requiredCount} 个必填)` : ''}</span>
|
||||
</div>
|
||||
<div class="mcp-tool-info-row">
|
||||
<span class="mcp-tool-info-label">模拟返回:</span>
|
||||
<span class="mcp-tool-info-value">${hasMockResponse ? '✅ 已配置: ' + JSON.stringify(tool.mockResponse) : '⚪ 使用默认'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 渲染参数列表
|
||||
function renderMcpProperties() {
|
||||
const container = document.getElementById('mcpPropertiesContainer');
|
||||
|
||||
if (mcpProperties.length === 0) {
|
||||
container.innerHTML = '<div style="text-align: center; padding: 20px; color: #999; font-size: 14px;">暂无参数,点击下方按钮添加参数</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = mcpProperties.map((prop, index) => `
|
||||
<div class="mcp-property-item">
|
||||
<div class="mcp-property-header">
|
||||
<span class="mcp-property-name">${prop.name}</span>
|
||||
<button type="button" onclick="deleteMcpProperty(${index})"
|
||||
style="padding: 3px 8px; border: none; border-radius: 3px; background-color: #f44336; color: white; cursor: pointer; font-size: 11px;">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<div class="mcp-property-row">
|
||||
<div>
|
||||
<label class="mcp-small-label">参数名称 *</label>
|
||||
<input type="text" class="mcp-small-input" value="${prop.name}"
|
||||
onchange="updateMcpProperty(${index}, 'name', this.value)" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mcp-small-label">数据类型 *</label>
|
||||
<select class="mcp-small-input" onchange="updateMcpProperty(${index}, 'type', this.value)">
|
||||
<option value="string" ${prop.type === 'string' ? 'selected' : ''}>字符串</option>
|
||||
<option value="integer" ${prop.type === 'integer' ? 'selected' : ''}>整数</option>
|
||||
<option value="number" ${prop.type === 'number' ? 'selected' : ''}>数字</option>
|
||||
<option value="boolean" ${prop.type === 'boolean' ? 'selected' : ''}>布尔值</option>
|
||||
<option value="array" ${prop.type === 'array' ? 'selected' : ''}>数组</option>
|
||||
<option value="object" ${prop.type === 'object' ? 'selected' : ''}>对象</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
${(prop.type === 'integer' || prop.type === 'number') ? `
|
||||
<div class="mcp-property-row">
|
||||
<div>
|
||||
<label class="mcp-small-label">最小值</label>
|
||||
<input type="number" class="mcp-small-input" value="${prop.minimum !== undefined ? prop.minimum : ''}"
|
||||
placeholder="可选" onchange="updateMcpProperty(${index}, 'minimum', this.value ? parseFloat(this.value) : undefined)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mcp-small-label">最大值</label>
|
||||
<input type="number" class="mcp-small-input" value="${prop.maximum !== undefined ? prop.maximum : ''}"
|
||||
placeholder="可选" onchange="updateMcpProperty(${index}, 'maximum', this.value ? parseFloat(this.value) : undefined)">
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="mcp-property-row-full">
|
||||
<label class="mcp-small-label">参数描述</label>
|
||||
<input type="text" class="mcp-small-input" value="${prop.description || ''}"
|
||||
placeholder="可选" onchange="updateMcpProperty(${index}, 'description', this.value)">
|
||||
</div>
|
||||
<label class="mcp-checkbox-label">
|
||||
<input type="checkbox" ${prop.required ? 'checked' : ''}
|
||||
onchange="updateMcpProperty(${index}, 'required', this.checked)">
|
||||
必填参数
|
||||
</label>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 添加参数
|
||||
function addMcpProperty() {
|
||||
mcpProperties.push({
|
||||
name: `param_${mcpProperties.length + 1}`,
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: ''
|
||||
});
|
||||
renderMcpProperties();
|
||||
}
|
||||
|
||||
// 更新参数
|
||||
window.updateMcpProperty = function(index, field, value) {
|
||||
if (field === 'name') {
|
||||
const isDuplicate = mcpProperties.some((p, i) => i !== index && p.name === value);
|
||||
if (isDuplicate) {
|
||||
alert('参数名称已存在,请使用不同的名称');
|
||||
renderMcpProperties();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
mcpProperties[index][field] = value;
|
||||
|
||||
if (field === 'type' && value !== 'integer' && value !== 'number') {
|
||||
delete mcpProperties[index].minimum;
|
||||
delete mcpProperties[index].maximum;
|
||||
renderMcpProperties();
|
||||
}
|
||||
};
|
||||
|
||||
// 删除参数
|
||||
window.deleteMcpProperty = function(index) {
|
||||
mcpProperties.splice(index, 1);
|
||||
renderMcpProperties();
|
||||
};
|
||||
|
||||
// 设置事件监听
|
||||
function setupMcpEventListeners() {
|
||||
const toggleBtn = document.getElementById('toggleMcpTools');
|
||||
const panel = document.getElementById('mcpToolsPanel');
|
||||
const addBtn = document.getElementById('addMcpToolBtn');
|
||||
const modal = document.getElementById('mcpToolModal');
|
||||
const closeBtn = document.getElementById('closeMcpModalBtn');
|
||||
const cancelBtn = document.getElementById('cancelMcpBtn');
|
||||
const form = document.getElementById('mcpToolForm');
|
||||
const addPropertyBtn = document.getElementById('addMcpPropertyBtn');
|
||||
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const isExpanded = panel.classList.contains('expanded');
|
||||
panel.classList.toggle('expanded');
|
||||
toggleBtn.textContent = isExpanded ? '展开' : '收起';
|
||||
});
|
||||
|
||||
addBtn.addEventListener('click', () => openMcpModal());
|
||||
closeBtn.addEventListener('click', closeMcpModal);
|
||||
cancelBtn.addEventListener('click', closeMcpModal);
|
||||
addPropertyBtn.addEventListener('click', addMcpProperty);
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeMcpModal();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', handleMcpSubmit);
|
||||
}
|
||||
|
||||
// 打开模态框
|
||||
function openMcpModal(index = null) {
|
||||
const isConnected = websocket && websocket.readyState === WebSocket.OPEN;
|
||||
if (isConnected) {
|
||||
alert('WebSocket 已连接,无法编辑工具');
|
||||
return;
|
||||
}
|
||||
|
||||
mcpEditingIndex = index;
|
||||
const errorContainer = document.getElementById('mcpErrorContainer');
|
||||
errorContainer.innerHTML = '';
|
||||
|
||||
if (index !== null) {
|
||||
document.getElementById('mcpModalTitle').textContent = '编辑工具';
|
||||
const tool = mcpTools[index];
|
||||
document.getElementById('mcpToolName').value = tool.name;
|
||||
document.getElementById('mcpToolDescription').value = tool.description;
|
||||
document.getElementById('mcpMockResponse').value = tool.mockResponse ? JSON.stringify(tool.mockResponse, null, 2) : '';
|
||||
|
||||
mcpProperties = [];
|
||||
const schema = tool.inputSchema;
|
||||
if (schema.properties) {
|
||||
Object.keys(schema.properties).forEach(key => {
|
||||
const prop = schema.properties[key];
|
||||
mcpProperties.push({
|
||||
name: key,
|
||||
type: prop.type || 'string',
|
||||
minimum: prop.minimum,
|
||||
maximum: prop.maximum,
|
||||
description: prop.description || '',
|
||||
required: schema.required && schema.required.includes(key)
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
document.getElementById('mcpModalTitle').textContent = '添加工具';
|
||||
document.getElementById('mcpToolForm').reset();
|
||||
mcpProperties = [];
|
||||
}
|
||||
|
||||
renderMcpProperties();
|
||||
document.getElementById('mcpToolModal').style.display = 'block';
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
function closeMcpModal() {
|
||||
document.getElementById('mcpToolModal').style.display = 'none';
|
||||
mcpEditingIndex = null;
|
||||
document.getElementById('mcpToolForm').reset();
|
||||
mcpProperties = [];
|
||||
document.getElementById('mcpErrorContainer').innerHTML = '';
|
||||
}
|
||||
|
||||
// 处理表单提交
|
||||
function handleMcpSubmit(e) {
|
||||
e.preventDefault();
|
||||
const errorContainer = document.getElementById('mcpErrorContainer');
|
||||
errorContainer.innerHTML = '';
|
||||
|
||||
const name = document.getElementById('mcpToolName').value.trim();
|
||||
const description = document.getElementById('mcpToolDescription').value.trim();
|
||||
const mockResponseText = document.getElementById('mcpMockResponse').value.trim();
|
||||
|
||||
// 检查名称重复
|
||||
const isDuplicate = mcpTools.some((tool, index) =>
|
||||
tool.name === name && index !== mcpEditingIndex
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
showMcpError('工具名称已存在,请使用不同的名称');
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析模拟返回结果
|
||||
let mockResponse = null;
|
||||
if (mockResponseText) {
|
||||
try {
|
||||
mockResponse = JSON.parse(mockResponseText);
|
||||
} catch (e) {
|
||||
showMcpError('模拟返回结果不是有效的 JSON 格式: ' + e.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 inputSchema
|
||||
const inputSchema = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
|
||||
mcpProperties.forEach(prop => {
|
||||
const propSchema = { type: prop.type };
|
||||
|
||||
if (prop.description) {
|
||||
propSchema.description = prop.description;
|
||||
}
|
||||
|
||||
if ((prop.type === 'integer' || prop.type === 'number')) {
|
||||
if (prop.minimum !== undefined && prop.minimum !== '') {
|
||||
propSchema.minimum = prop.minimum;
|
||||
}
|
||||
if (prop.maximum !== undefined && prop.maximum !== '') {
|
||||
propSchema.maximum = prop.maximum;
|
||||
}
|
||||
}
|
||||
|
||||
inputSchema.properties[prop.name] = propSchema;
|
||||
|
||||
if (prop.required) {
|
||||
inputSchema.required.push(prop.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (inputSchema.required.length === 0) {
|
||||
delete inputSchema.required;
|
||||
}
|
||||
|
||||
const tool = { name, description, inputSchema, mockResponse };
|
||||
|
||||
if (mcpEditingIndex !== null) {
|
||||
mcpTools[mcpEditingIndex] = tool;
|
||||
log(`已更新工具: ${name}`, 'success');
|
||||
} else {
|
||||
mcpTools.push(tool);
|
||||
log(`已添加工具: ${name}`, 'success');
|
||||
}
|
||||
|
||||
saveMcpTools();
|
||||
renderMcpTools();
|
||||
closeMcpModal();
|
||||
}
|
||||
|
||||
// 显示错误
|
||||
function showMcpError(message) {
|
||||
const errorContainer = document.getElementById('mcpErrorContainer');
|
||||
errorContainer.innerHTML = `<div class="mcp-error">${message}</div>`;
|
||||
}
|
||||
|
||||
// 编辑工具
|
||||
window.editMcpTool = function(index) {
|
||||
openMcpModal(index);
|
||||
};
|
||||
|
||||
// 删除工具
|
||||
window.deleteMcpTool = function(index) {
|
||||
const isConnected = websocket && websocket.readyState === WebSocket.OPEN;
|
||||
if (isConnected) {
|
||||
alert('WebSocket 已连接,无法编辑工具');
|
||||
return;
|
||||
}
|
||||
if (confirm(`确定要删除工具 "${mcpTools[index].name}" 吗?`)) {
|
||||
const toolName = mcpTools[index].name;
|
||||
mcpTools.splice(index, 1);
|
||||
saveMcpTools();
|
||||
renderMcpTools();
|
||||
log(`已删除工具: ${toolName}`, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
// 保存工具
|
||||
function saveMcpTools() {
|
||||
localStorage.setItem('mcpTools', JSON.stringify(mcpTools));
|
||||
}
|
||||
|
||||
// 获取工具列表
|
||||
function getMcpTools() {
|
||||
return mcpTools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema
|
||||
}));
|
||||
}
|
||||
|
||||
// 执行工具调用
|
||||
function executeMcpTool(toolName, toolArgs) {
|
||||
const tool = mcpTools.find(t => t.name === toolName);
|
||||
|
||||
if (!tool) {
|
||||
log(`未找到工具: ${toolName}`, 'error');
|
||||
return {
|
||||
success: false,
|
||||
error: `未知工具: ${toolName}`
|
||||
};
|
||||
}
|
||||
|
||||
// 如果有模拟返回结果,使用它
|
||||
if (tool.mockResponse) {
|
||||
// 替换模板变量
|
||||
let responseStr = JSON.stringify(tool.mockResponse);
|
||||
|
||||
// 替换 ${paramName} 格式的变量
|
||||
if (toolArgs) {
|
||||
Object.keys(toolArgs).forEach(key => {
|
||||
const regex = new RegExp(`\\$\\{${key}\\}`, 'g');
|
||||
responseStr = responseStr.replace(regex, toolArgs[key]);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(responseStr);
|
||||
log(`工具 ${toolName} 执行成功,返回模拟结果: ${responseStr}`, 'success');
|
||||
return response;
|
||||
} catch (e) {
|
||||
log(`解析模拟返回结果失败: ${e.message}`, 'error');
|
||||
return tool.mockResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// 没有模拟返回结果,返回默认成功消息
|
||||
log(`工具 ${toolName} 执行成功,返回默认结果`, 'success');
|
||||
return {
|
||||
success: true,
|
||||
message: `工具 ${toolName} 执行成功`,
|
||||
tool: toolName,
|
||||
arguments: toolArgs
|
||||
};
|
||||
}
|
||||
|
||||
initApp();
|
||||
initMcpTools();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user