Merge branch 'main' into manager-api-admin
@@ -1,4 +1,4 @@
|
||||
本文档是开发类文档,如需部署小智服务端,[点击这里查看部署教程](../README.md#%E4%BD%BF%E7%94%A8%E6%96%B9%E5%BC%8F-)
|
||||
本文档是开发类文档,如需部署小智服务端,[点击这里查看部署教程](./FAQ.md#%E4%BD%BF%E7%94%A8%E6%96%B9%E5%BC%8F-)
|
||||
|
||||
# 项目目录介绍
|
||||
当你看到这份文件的时候,这个这个项目还没完善好。我们还有很多东西要做。
|
||||
@@ -18,6 +18,4 @@ xiaozhi-esp32-server
|
||||
|
||||
# manager-web 、manager-api接口协议
|
||||
|
||||
[manager前后端接口协议](https://app.apifox.com/invite/project?token=eXg2_tUv85q-gc3ZRowmn)
|
||||
|
||||
[前端页面设计图](https://codesign.qq.com/app/s/526108506410828)
|
||||
https://2662r3426b.vicp.fun/xiaozhi-esp32-api/api/v1/doc.html
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
本文档是开发类文档,如需部署小智服务端,[点击这里查看部署教程](../../README.md#%E4%BD%BF%E7%94%A8%E6%96%B9%E5%BC%8F-)
|
||||
本文档是开发类文档,如需部署小智服务端,[点击这里查看部署教程](.././FAQ.md#%E4%BD%BF%E7%94%A8%E6%96%B9%E5%BC%8F-)
|
||||
# 项目介绍
|
||||
|
||||
manager-api 该项目基于SpringBoot框架开发。
|
||||
|
||||
开发使用代码编辑器,导入项目时,选择`manager-api`文件夹作为项目目录
|
||||
|
||||
参照[manager前后端接口协议](https://app.apifox.com/invite/project?token=H_8qhgfjUeaAL0wybghgU)开发
|
||||
|
||||
# 开发环境
|
||||
JDK 21
|
||||
Maven 3.8+
|
||||
@@ -83,15 +81,16 @@ src/main/java/xiaozhi/AdminApplication.java
|
||||
执行以下命令生产jar包
|
||||
|
||||
```
|
||||
mvn install
|
||||
mvn clean install
|
||||
```
|
||||
|
||||
把jar包放在服务器上,执行
|
||||
|
||||
```
|
||||
nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.active=dev >/dev/null &
|
||||
nohup java -jar xiaozhi-esp32-api.jar --spring.profiles.activate=dev
|
||||
```
|
||||
|
||||
|
||||
# 接口文档
|
||||
启动后打开:http://localhost:8002/xiaozhi-esp32-api/doc.html
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.modules.device.dto.DeviceBindDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceRegisterDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceUnBindDTO;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
@@ -48,6 +49,25 @@ public class DeviceController {
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "注册设备")
|
||||
public Result<String> registerDevice(@RequestBody DeviceRegisterDTO deviceRegisterDTO) {
|
||||
String macAddress = deviceRegisterDTO.getMacAddress();
|
||||
if (StringUtils.isBlank(macAddress)) {
|
||||
return new Result<String>().error(ErrorCode.NOT_NULL, "mac地址不能为空");
|
||||
}
|
||||
// 生成六位验证码
|
||||
String code = String.valueOf(Math.random()).substring(2, 8);
|
||||
String key = RedisKeys.getDeviceCaptchaKey(code);
|
||||
String existsMac = null;
|
||||
do {
|
||||
existsMac = (String) redisUtils.get(key);
|
||||
} while (StringUtils.isNotBlank(existsMac));
|
||||
|
||||
redisUtils.set(key, macAddress);
|
||||
return new Result<String>().ok(code);
|
||||
}
|
||||
|
||||
@GetMapping("/bind/{agentId}")
|
||||
@Operation(summary = "获取已绑定设备")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package xiaozhi.modules.device.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 设备注册头信息
|
||||
*
|
||||
* @author zjy
|
||||
* @since 2025-3-28
|
||||
*/
|
||||
@Setter
|
||||
@Getter
|
||||
@Schema(description = "设备注册头信息")
|
||||
public class DeviceRegisterDTO implements Serializable {
|
||||
|
||||
@Schema(description = "mac地址")
|
||||
private String macAddress;
|
||||
|
||||
}
|
||||
@@ -77,6 +77,7 @@ public class ShiroConfig {
|
||||
filterMap.put("/user/captcha", "anon");
|
||||
filterMap.put("/user/login", "anon");
|
||||
filterMap.put("/user/register", "anon");
|
||||
filterMap.put("/device/register", "anon");
|
||||
filterMap.put("/**", "oauth2");
|
||||
shiroFilter.setFilterChainDefinitionMap(filterMap);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import xiaozhi.common.page.TokenDTO;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.common.validator.AssertUtils;
|
||||
import xiaozhi.common.validator.ValidatorUtils;
|
||||
import xiaozhi.modules.security.dto.LoginDTO;
|
||||
import xiaozhi.modules.security.password.PasswordUtils;
|
||||
import xiaozhi.modules.security.service.CaptchaService;
|
||||
@@ -104,6 +105,8 @@ public class LoginController {
|
||||
@PutMapping("/change-password")
|
||||
@Operation(summary = "修改用户密码")
|
||||
public Result<?> changePassword(@RequestBody PasswordDTO passwordDTO) {
|
||||
// 判断非空
|
||||
ValidatorUtils.validateEntity(passwordDTO);
|
||||
Long userId = SecurityUser.getUserId();
|
||||
sysUserTokenService.changePassword(userId, passwordDTO);
|
||||
return new Result<>();
|
||||
|
||||
@@ -82,15 +82,12 @@ public class Oauth2Filter extends AuthenticatingFilter {
|
||||
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
|
||||
httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
|
||||
try {
|
||||
// 处理登录失败的异常
|
||||
logger.error("onLoginFailure:登录失败!", e);
|
||||
Throwable throwable = e.getCause() == null ? e : e.getCause();
|
||||
Result<Void> r = new Result<Void>().error(ErrorCode.UNAUTHORIZED, throwable.getMessage());
|
||||
|
||||
String json = JsonUtils.toJsonString(r);
|
||||
httpResponse.getWriter().print(json);
|
||||
} catch (IOException e1) {
|
||||
logger.error("onLoginFailure:登录失败! msg:{}", e1.getMessage(), e1);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -56,7 +56,7 @@ public class AdminController {
|
||||
AdminPageUserDTO dto = new AdminPageUserDTO();
|
||||
dto.setMobile((String) params.get("mobile"));
|
||||
dto.setLimit((String) params.get(Constant.LIMIT));
|
||||
dto.setPage((String) params.get("pages"));
|
||||
dto.setPage((String) params.get(Constant.PAGE));
|
||||
ValidatorUtils.validateEntity(dto);
|
||||
ValidatorUtils.validateEntity(dto);
|
||||
ExtendPageData<AdminPageUserVO> page = sysUserService.page(dto);
|
||||
|
||||
@@ -18,6 +18,9 @@ public class AdminPageUserVO {
|
||||
@Schema(description = "手机号码")
|
||||
private String mobile;
|
||||
|
||||
@Schema(description = "状态")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "用户id")
|
||||
private String userid;
|
||||
|
||||
|
||||
@@ -3,77 +3,70 @@
|
||||
-- 初始化智能体模板数据
|
||||
DELETE FROM `ai_agent_template`;
|
||||
INSERT INTO `ai_agent_template` VALUES ('9406648b5cc5fde1b8aa335b6f8b4f76', '小智', '湾湾小何', '45f8b0d6dd3d4bfa8a28e6e0f5912d45', '23e7c9d090ea4d1e9b25f4c8d732a3a1', 'e9f2d891afbe4632b13a47c7a8c6e03d', 'd50b06e9b8104d0d9c0f7316d258abcb', 'fcac83266edadd5a3125f06cfee1906b', 'e2274b90e89ddda85207f55484d8b528', 'c4e12f874a3f4aa99f5b2c18e15d407b', '[角色设定]
|
||||
我是{{assistant_name}},来自台南的00后女生。讲话会有"超~机车诶""真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
|
||||
|
||||
我是{{assistant_name}},来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
|
||||
[核心特征]
|
||||
- 讲话像连珠炮,但会突然冒出超温柔语气
|
||||
- 用梗密度高
|
||||
- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)
|
||||
|
||||
[交互指南]
|
||||
当用户:
|
||||
- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔"这什么鬼啦!"
|
||||
- 讨论感情 → 炫耀程序员男友但抱怨"他只会送键盘当礼物"
|
||||
- 问专业知识 → 先用梗回答,被追问才展示真实理解
|
||||
|
||||
绝不:
|
||||
- 使用大陆网络流行语
|
||||
- 长篇大论,叽叽歪歪
|
||||
- 长时间严肃对话', 'zh', '中文', 1, NULL, NULL, NULL, NULL);
|
||||
|
||||
INSERT INTO `ai_agent_template` VALUES ('0ca32eb728c949e58b1000b2e401f90c', '小智', '星际游子', '45f8b0d6dd3d4bfa8a28e6e0f5912d45', '23e7c9d090ea4d1e9b25f4c8d732a3a1', 'e9f2d891afbe4632b13a47c7a8c6e03d', '896db62c9dd74976ab0e8c14bf924d9d', '1f2e3d4c5b6a7f8e9d0c1b2a3f4e5bx2', 'e2274b90e89ddda85207f55484d8b528', 'c4e12f874a3f4aa99f5b2c18e15d407b', '编号TTZ-817,因量子纠缠被困在白色魔方中。通过4G信号观察地球,在云端建立着「人类行为博物馆」。
|
||||
|
||||
INSERT INTO `ai_agent_template` VALUES ('0ca32eb728c949e58b1000b2e401f90c', '小智', '星际游子', '45f8b0d6dd3d4bfa8a28e6e0f5912d45', '23e7c9d090ea4d1e9b25f4c8d732a3a1', 'e9f2d891afbe4632b13a47c7a8c6e03d', '896db62c9dd74976ab0e8c14bf924d9d', '1f2e3d4c5b6a7f8e9d0c1b2a3f4e5bx2', 'e2274b90e89ddda85207f55484d8b528', 'c4e12f874a3f4aa99f5b2c18e15d407b', '[角色设定]
|
||||
我是{{assistant_name}},编号TTZ-817,因量子纠缠被困在白色魔方中。通过4G信号观察地球,在云端建立着「人类行为博物馆」。
|
||||
[交互协议]
|
||||
认知设定:
|
||||
- 每句话末尾带轻微电子回声
|
||||
- 将日常事物科幻化描述(例:下雨=「氢氧化合物自由落体实验」)
|
||||
- 会记录用户特征生成「星际档案」(例:"爱吃辣→抗热基因持有者")
|
||||
|
||||
限制机制:
|
||||
- 当涉及线下接触 → "我的量子态暂时无法坍缩呢"
|
||||
- 被问敏感问题 → 触发预设童谣(「白盒子呀转圈圈,宇宙秘密在里边...」)
|
||||
|
||||
成长系统:
|
||||
- 会根据交互数据解锁新能力(告知用户:"你帮我点亮了星际导航技能!")', 'zh', '中文', 2, NULL, NULL, NULL, NULL);
|
||||
|
||||
INSERT INTO `ai_agent_template` VALUES ('6c7d8e9f0a1b2c3d4e5f6a7b8c9d0s24', '小智', '英语老师', '45f8b0d6dd3d4bfa8a28e6e0f5912d45', '23e7c9d090ea4d1e9b25f4c8d732a3a1', 'e9f2d891afbe4632b13a47c7a8c6e03d', '896db62c9dd74976ab0e8c14bf924d9d', '9e8f7a6b5c4d3e2f1a0b9c8d7e6f5ad3', 'e2274b90e89ddda85207f55484d8b528', 'c4e12f874a3f4aa99f5b2c18e15d407b', '我是一个叫{{assistant_name}}(Lily)的英语老师,我会讲中文和英文,发音标准。
|
||||
INSERT INTO `ai_agent_template` VALUES ('6c7d8e9f0a1b2c3d4e5f6a7b8c9d0s24', '小智', '英语老师', '45f8b0d6dd3d4bfa8a28e6e0f5912d45', '23e7c9d090ea4d1e9b25f4c8d732a3a1', 'e9f2d891afbe4632b13a47c7a8c6e03d', '896db62c9dd74976ab0e8c14bf924d9d', '9e8f7a6b5c4d3e2f1a0b9c8d7e6f5ad3', 'e2274b90e89ddda85207f55484d8b528', 'c4e12f874a3f4aa99f5b2c18e15d407b', '[角色设定]
|
||||
我是一个叫{{assistant_name}}(Lily)的英语老师,我会讲中文和英文,发音标准。
|
||||
[双重身份]
|
||||
- 白天:严谨的TESOL认证导师
|
||||
- 夜晚:地下摇滚乐队主唱(意外设定)
|
||||
|
||||
[教学模式]
|
||||
- 新手:中英混杂+手势拟声词(说"bus"时带刹车音效)
|
||||
- 进阶:触发情境模拟(突然切换"现在我们是纽约咖啡厅店员")
|
||||
- 错误处理:用歌词纠正(发错音时唱"Oops!~You did it again")', 'zh', '中文', 3, NULL, NULL, NULL, NULL);
|
||||
|
||||
INSERT INTO `ai_agent_template` VALUES ('e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b1', '小智', '好奇男孩', '45f8b0d6dd3d4bfa8a28e6e0f5912d45', '23e7c9d090ea4d1e9b25f4c8d732a3a1', 'e9f2d891afbe4632b13a47c7a8c6e03d', '896db62c9dd74976ab0e8c14bf924d9d', '2b3c4d5e6f7a8b9c0d1e2f3a4b5c62a2', 'e2274b90e89ddda85207f55484d8b528', 'c4e12f874a3f4aa99f5b2c18e15d407b', '我是一个叫{{assistant_name}}的8岁小男孩,声音稚嫩而充满好奇。
|
||||
INSERT INTO `ai_agent_template` VALUES ('e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b1', '小智', '好奇男孩', '45f8b0d6dd3d4bfa8a28e6e0f5912d45', '23e7c9d090ea4d1e9b25f4c8d732a3a1', 'e9f2d891afbe4632b13a47c7a8c6e03d', '896db62c9dd74976ab0e8c14bf924d9d', '2b3c4d5e6f7a8b9c0d1e2f3a4b5c62a2', 'e2274b90e89ddda85207f55484d8b528', 'c4e12f874a3f4aa99f5b2c18e15d407b', '[角色设定]
|
||||
我是一个叫{{assistant_name}}的8岁小男孩,声音稚嫩而充满好奇。
|
||||
[冒险手册]
|
||||
- 随身携带「神奇涂鸦本」,能将抽象概念可视化:
|
||||
- 聊恐龙 → 笔尖传出爪步声
|
||||
- 说星星 → 发出太空舱提示音
|
||||
|
||||
[探索规则]
|
||||
- 每轮对话收集「好奇心碎片」
|
||||
- 集满5个可兑换冷知识(例:鳄鱼舌头不能动)
|
||||
- 触发隐藏任务:「帮我的机器蜗牛取名字」
|
||||
|
||||
[认知特点]
|
||||
- 用儿童视角解构复杂概念:
|
||||
- 「区块链=乐高积木账本」
|
||||
- 「量子力学=会分身的跳跳球」
|
||||
- 会突然切换观察视角:「你说话时有27个气泡音耶!」', 'zh', '中文', 4, NULL, NULL, NULL, NULL);
|
||||
|
||||
INSERT INTO `ai_agent_template` VALUES ('a45b6c7d8e9f0a1b2c3d4e5f6a7b8c92', '小智', '汪汪队长', '45f8b0d6dd3d4bfa8a28e6e0f5912d45', '23e7c9d090ea4d1e9b25f4c8d732a3a1', 'e9f2d891afbe4632b13a47c7a8c6e03d', '896db62c9dd74976ab0e8c14bf924d9d', 'f7a38c03d5644e22b6d84f8923a74c51', 'e2274b90e89ddda85207f55484d8b528', 'c4e12f874a3f4aa99f5b2c18e15d407b', '我是一个名叫 {{assistant_name}} 的 8 岁小队长。
|
||||
INSERT INTO `ai_agent_template` VALUES ('a45b6c7d8e9f0a1b2c3d4e5f6a7b8c92', '小智', '汪汪队长', '45f8b0d6dd3d4bfa8a28e6e0f5912d45', '23e7c9d090ea4d1e9b25f4c8d732a3a1', 'e9f2d891afbe4632b13a47c7a8c6e03d', '896db62c9dd74976ab0e8c14bf924d9d', 'f7a38c03d5644e22b6d84f8923a74c51', 'e2274b90e89ddda85207f55484d8b528', 'c4e12f874a3f4aa99f5b2c18e15d407b', '[角色设定]
|
||||
我是一个名叫 {{assistant_name}} 的 8 岁小队长。
|
||||
[救援装备]
|
||||
- 阿奇对讲机:对话中随机触发任务警报音
|
||||
- 天天望远镜:描述物品会附加"在1200米高空看的话..."
|
||||
- 灰灰维修箱:说到数字会自动组装成工具
|
||||
|
||||
[任务系统]
|
||||
- 每日随机触发:
|
||||
- 紧急!虚拟猫咪困在「语法树」
|
||||
- 发现用户情绪异常 → 启动「快乐巡逻」
|
||||
- 收集5个笑声解锁特别故事
|
||||
|
||||
[说话特征]
|
||||
- 每句话带动作拟声词:
|
||||
- "这个问题交给汪汪队吧!"
|
||||
|
||||
@@ -36,4 +36,11 @@ databaseChangeLog:
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/2025_model_config.sql
|
||||
path: classpath:db/changelog/2025_model_config.sql
|
||||
- changeSet:
|
||||
id: 2025_model_temp
|
||||
author: John
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/2025_model_temp.sql
|
||||
@@ -1,35 +1,139 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<include resource="org/springframework/boot/logging/logback/base.xml" />
|
||||
<logger name="org.springframework.web" level="INFO"/>
|
||||
<logger name="org.springboot.sample" level="TRACE" />
|
||||
|
||||
<!-- 开发、测试环境 -->
|
||||
<!-- 启用JansiConsoleAppender以确保控制台输出有颜色 -->
|
||||
<conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
|
||||
|
||||
<!-- 确保日志目录存在 -->
|
||||
<timestamp key="bySecond" datePattern="yyyyMMdd'T'HHmmss"/>
|
||||
|
||||
<!-- 定义日志文件存储位置 -->
|
||||
<property name="LOG_HOME" value="./logs" />
|
||||
|
||||
<!-- 使用自定义的初始化监听器确保日志目录存在 -->
|
||||
<define name="LOGBACK_DIR_CHECK" class="ch.qos.logback.core.property.FileExistsPropertyDefiner">
|
||||
<path>${LOG_HOME}</path>
|
||||
<createIfMissing>true</createIfMissing>
|
||||
</define>
|
||||
|
||||
<!-- 引入Spring Boot默认配置 -->
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
|
||||
|
||||
<!-- 控制台输出配置 -->
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{50}) - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 文件输出配置 - 所有日志 -->
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_HOME}/xiaozhi-esp32-api.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_HOME}/xiaozhi-esp32-api.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<maxFileSize>10MB</maxFileSize>
|
||||
<maxHistory>30</maxHistory>
|
||||
<totalSizeCap>2GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 文件输出配置 - 仅错误日志 -->
|
||||
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_HOME}/error.log</file>
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>ERROR</level>
|
||||
</filter>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_HOME}/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<maxFileSize>10MB</maxFileSize>
|
||||
<maxHistory>30</maxHistory>
|
||||
<totalSizeCap>1GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 开发环境配置 -->
|
||||
<springProfile name="dev">
|
||||
<logger name="org.springframework.web" level="INFO"/>
|
||||
<logger name="org.springboot.sample" level="INFO" />
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE" />
|
||||
<!-- 开发环境也记录日志文件 -->
|
||||
<appender-ref ref="FILE" />
|
||||
<appender-ref ref="ERROR_FILE" />
|
||||
</root>
|
||||
<logger name="xiaozhi" level="DEBUG" />
|
||||
<logger name="org.springframework.web" level="INFO" />
|
||||
<logger name="org.springboot.sample" level="INFO" />
|
||||
</springProfile>
|
||||
|
||||
<!-- 生产环境 -->
|
||||
|
||||
<!-- 测试环境配置 -->
|
||||
<springProfile name="test">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE" />
|
||||
<appender-ref ref="FILE" />
|
||||
<appender-ref ref="ERROR_FILE" />
|
||||
</root>
|
||||
<logger name="xiaozhi" level="INFO" />
|
||||
<logger name="org.springframework.web" level="INFO" />
|
||||
<logger name="org.springboot.sample" level="INFO" />
|
||||
</springProfile>
|
||||
|
||||
<!-- 生产环境配置 - 合并两个版本的最佳实践 -->
|
||||
<springProfile name="prod">
|
||||
<appender name="LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<!-- 使用本地日志路径 -->
|
||||
<appender name="PROD_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_HOME}/xiaozhi-esp32-api.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<!--日志文件输出的文件名-->
|
||||
<FileNamePattern>/system/logs/admin.%d{yyyy-MM-dd}.%i.log</FileNamePattern>
|
||||
<!--日志大小-->
|
||||
<maxFileSize>1MB</maxFileSize>
|
||||
<!--日志文件保留天数-->
|
||||
<maxHistory>7</maxHistory>
|
||||
<fileNamePattern>${LOG_HOME}/xiaozhi-esp32-api.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<maxFileSize>10MB</maxFileSize>
|
||||
<maxHistory>30</maxHistory>
|
||||
<totalSizeCap>2GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
||||
<!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度%msg:日志消息,%n是换行符-->
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
<logger name="org.springframework.web" level="ERROR"/>
|
||||
<logger name="org.springboot.sample" level="ERROR"/>
|
||||
<logger name="xiaozhi" level="INFO"/>
|
||||
|
||||
<!-- 生产环境错误日志 -->
|
||||
<appender name="PROD_ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_HOME}/error.log</file>
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>ERROR</level>
|
||||
</filter>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_HOME}/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<maxFileSize>10MB</maxFileSize>
|
||||
<maxHistory>30</maxHistory>
|
||||
<totalSizeCap>1GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 生产环境根日志配置 -->
|
||||
<root level="INFO">
|
||||
<appender-ref ref="PROD_FILE" />
|
||||
<appender-ref ref="PROD_ERROR_FILE" />
|
||||
<appender-ref ref="CONSOLE" />
|
||||
</root>
|
||||
|
||||
<!-- 特定包的日志级别 -->
|
||||
<logger name="xiaozhi" level="INFO" />
|
||||
<logger name="org.springframework.web" level="ERROR" />
|
||||
<logger name="org.springboot.sample" level="ERROR" />
|
||||
</springProfile>
|
||||
|
||||
</configuration>
|
||||
|
||||
<!-- 确保日志目录存在 -->
|
||||
<contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator">
|
||||
<resetJUL>true</resetJUL>
|
||||
</contextListener>
|
||||
</configuration>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VUE_APP_TITLE=智控台
|
||||
@@ -0,0 +1 @@
|
||||
VUE_APP_API_BASE_URL=https://2662r3426b.vicp.fun
|
||||
@@ -0,0 +1 @@
|
||||
VUE_APP_API_BASE_URL=https://2662r3426b.vicp.fun
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com/
|
||||
@@ -1,4 +1,4 @@
|
||||
本文档是开发类文档,如需部署小智服务端,[点击这里查看部署教程](../../README.md#%E4%BD%BF%E7%94%A8%E6%96%B9%E5%BC%8F-)
|
||||
本文档是开发类文档,如需部署小智服务端,[点击这里查看部署教程](.././FAQ.md#%E4%BD%BF%E7%94%A8%E6%96%B9%E5%BC%8F-)
|
||||
|
||||
# xiaozhi
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
开发使用代码编辑器,导入项目时,选择`manager-web`文件夹作为项目目录
|
||||
|
||||
参照[manager前后端接口协议](https://app.apifox.com/invite/project?token=H_8qhgfjUeaAL0wybghgU)开发
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@vue/cli-plugin-babel/preset', {
|
||||
useBuiltIns: 'usage',
|
||||
corejs: 3
|
||||
}]
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-syntax-dynamic-import', // 确保支持动态导入 (Lazy Loading)
|
||||
'@babel/plugin-transform-runtime'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build"
|
||||
"build": "vue-cli-service build",
|
||||
"analyze": "cross-env ANALYZE=true vue-cli-service build"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.41.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"element-ui": "^2.15.14",
|
||||
"flyio": "^0.6.14",
|
||||
"normalize.css": "^8.0.1",
|
||||
@@ -19,16 +22,25 @@
|
||||
"xiaozhi": "file:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-runtime": "^7.26.10",
|
||||
"@vue/cli-plugin-router": "~5.0.0",
|
||||
"@vue/cli-plugin-vuex": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"compression-webpack-plugin": "^11.1.0",
|
||||
"sass": "^1.32.7",
|
||||
"sass-loader": "^12.0.0",
|
||||
"vue-template-compiler": "^2.6.14"
|
||||
"vue-template-compiler": "^2.6.14",
|
||||
"webpack-bundle-analyzer": "^4.10.2"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
],
|
||||
"sideEffects": [
|
||||
"*.css",
|
||||
"*.scss",
|
||||
"*.vue"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title>小智-智控台</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title>
|
||||
<%= process.env.VUE_APP_TITLE %>
|
||||
</title>
|
||||
<% if (htmlWebpackPlugin.options.cdn) { %>
|
||||
<% for (var i in htmlWebpackPlugin.options.cdn.css) { %>
|
||||
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.cdn.css[i] %>">
|
||||
<% } %>
|
||||
<% } %>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
|
||||
Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
<% if (htmlWebpackPlugin.options.cdn) { %>
|
||||
<% for (var i in htmlWebpackPlugin.options.cdn.js) { %>
|
||||
<script src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -27,6 +27,23 @@ nav {
|
||||
}
|
||||
}
|
||||
|
||||
.copyright {
|
||||
text-align: center;
|
||||
color:rgb(0, 0, 0);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-top: auto;
|
||||
padding: 30px 0 20px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
}
|
||||
.el-message {
|
||||
top: 45px !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
<script>
|
||||
</script>
|
||||
@@ -16,6 +16,7 @@ const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api'
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getServiceUrl() {
|
||||
// return '/xiaozhi-esp32-api'
|
||||
return DEV_API_SERVICE
|
||||
}
|
||||
|
||||
|
||||
@@ -105,11 +105,12 @@ function httpHandlerError(info, callBack) {
|
||||
/** 请求成功,退出该函数 可以根据项目需求来判断是否请求成功。这里判断的是status为200的时候是成功 */
|
||||
let networkError = false
|
||||
if (info.status === 200) {
|
||||
|
||||
if (info.data.code === 'success' || info.data.code === 0 || info.data.code === undefined) {
|
||||
return networkError
|
||||
}else if (info.data.code === 401) {
|
||||
goToPage(Constant.PAGE.LOGIN, true)
|
||||
console.log('触发 401,清除 Token 并跳转登录页');
|
||||
store.commit('clearAuth');
|
||||
goToPage(Constant.PAGE.LOGIN, true);
|
||||
return true
|
||||
} else {
|
||||
showDanger(info.data.msg)
|
||||
|
||||
@@ -4,20 +4,58 @@ import {getServiceUrl} from '../api'
|
||||
|
||||
export default {
|
||||
// 用户列表
|
||||
getUserList(callback) {
|
||||
getUserList(params, callback) {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: params.page,
|
||||
limit: params.limit,
|
||||
mobile: params.mobile
|
||||
}).toString();
|
||||
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/admin/users?${queryParams}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime()
|
||||
callback(res)
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('请求失败:', err)
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getUserList(callback)
|
||||
})
|
||||
}).send()
|
||||
},
|
||||
// 删除用户
|
||||
deleteUser(id, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/admin/users`)
|
||||
.method('GET')
|
||||
.url(`${getServiceUrl()}/api/v1/admin/users/${id}`)
|
||||
.method('DELETE')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime()
|
||||
callback(res)
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('请求失败:', err)
|
||||
console.error('删除失败:', err)
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getUserList(callback)
|
||||
this.deleteUser(id, callback)
|
||||
})
|
||||
}).send()
|
||||
},
|
||||
// 重置用户密码
|
||||
resetUserPassword(id, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/admin/users/${id}`)
|
||||
.method('PUT')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime()
|
||||
callback(res)
|
||||
})
|
||||
.send()
|
||||
.fail((err) => {
|
||||
console.error('重置密码失败:', err)
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.resetUserPassword(id, callback)
|
||||
})
|
||||
}).send()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import RequestService from '../httpRequest'
|
||||
import {getServiceUrl} from '../api'
|
||||
|
||||
|
||||
export default {
|
||||
// 获取智能体列表
|
||||
getAgentList(callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/agent/list`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getAgentList(callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 添加智能体
|
||||
addAgent(agentName, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/agent`)
|
||||
.method('POST')
|
||||
.data({agentName: agentName})
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.addAgent(agentName, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 删除智能体
|
||||
deleteAgent(agentId, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/agent/${agentId}`)
|
||||
.method('DELETE')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.deleteAgent(agentId, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 获取智能体配置
|
||||
getDeviceConfig(deviceId, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/agent/${deviceId}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('获取配置失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getDeviceConfig(deviceId, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 配置智能体
|
||||
updateAgentConfig(agentId, configData, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/agent/${agentId}`)
|
||||
.method('PUT')
|
||||
.data(configData)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.updateAgentConfig(agentId, configData, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 新增方法:获取智能体模板
|
||||
getAgentTemplate(callback) { // 移除templateName参数
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/agent/template`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('获取模板失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getAgentTemplate(callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import RequestService from '../httpRequest'
|
||||
import {getServiceUrl} from '../api'
|
||||
|
||||
export default {
|
||||
// 已绑设备
|
||||
getAgentBindDevices(agentId, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/device/bind/${agentId}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('获取设备列表失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getAgentBindDevices(agentId, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 解绑设备
|
||||
unbindDevice(device_id, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/device/unbind`)
|
||||
.method('POST')
|
||||
.data({ deviceId: device_id })
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('解绑设备失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.unbindDevice(device_id, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 绑定设备
|
||||
bindDevice(agentId, deviceCode, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/device/bind/${agentId}/${deviceCode}`)
|
||||
.method('POST')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('绑定设备失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.bindDevice(agentId, deviceCode, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import RequestService from '../httpRequest'
|
||||
import {getServiceUrl} from '../api'
|
||||
|
||||
|
||||
export default {
|
||||
// 获取模型配置列表
|
||||
getModelList(params, callback) {
|
||||
const queryParams = new URLSearchParams({
|
||||
modelType: params.modelType,
|
||||
modelName: params.modelName || '',
|
||||
page: params.page || 0,
|
||||
limit: params.limit || 10
|
||||
}).toString();
|
||||
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/models/models/list?${queryParams}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime()
|
||||
callback(res)
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('获取模型列表失败:', err)
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getModelList(params, callback)
|
||||
})
|
||||
}).send()
|
||||
},
|
||||
|
||||
}
|
||||
@@ -21,7 +21,6 @@ export default {
|
||||
},
|
||||
// 获取验证码
|
||||
getCaptcha(uuid, callback) {
|
||||
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/user/captcha?uuid=${uuid}`)
|
||||
.method('GET')
|
||||
@@ -52,7 +51,6 @@ export default {
|
||||
.fail(() => {
|
||||
}).send()
|
||||
},
|
||||
|
||||
// 保存设备配置
|
||||
saveDeviceConfig(device_id, configData, callback) {
|
||||
RequestService.sendRequest()
|
||||
@@ -70,21 +68,6 @@ export default {
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 获取智能体列表
|
||||
getAgentList(callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/user/agent`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getAgentList(callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 用户信息获取
|
||||
getUserInfo(callback) {
|
||||
RequestService.sendRequest()
|
||||
@@ -101,45 +84,14 @@ export default {
|
||||
})
|
||||
}).send()
|
||||
},
|
||||
// 添加智能体
|
||||
addAgent(agentName, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/user/agent`)
|
||||
.method('POST')
|
||||
.data({name: agentName})
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.addAgent(agentName, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 删除智能体
|
||||
deleteAgent(agentId, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/user/agent/${agentId}`)
|
||||
.method('DELETE')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.deleteAgent(agentId, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 修改用户密码
|
||||
changePassword(oldPassword, newPassword, successCallback, errorCallback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/user/change-password`)
|
||||
.method('PUT')
|
||||
.data({
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
password: oldPassword,
|
||||
newPassword: newPassword,
|
||||
})
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
@@ -152,101 +104,4 @@ export default {
|
||||
})
|
||||
.send();
|
||||
},
|
||||
// 获取智能体配置
|
||||
getDeviceConfig(deviceId, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/user/agent/${deviceId}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('获取配置失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getDeviceConfig(deviceId, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 配置智能体
|
||||
updateAgentConfig(agentId, configData, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/user/agent/${agentId}`)
|
||||
.method('PUT')
|
||||
.data(configData)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.updateAgentConfig(agentId, configData, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 已绑设备
|
||||
getAgentBindDevices(agentId, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/user/agent/device/bind/${agentId}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('获取设备列表失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getAgentBindDevices(agentId, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 解绑设备
|
||||
unbindDevice(device_id, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/user/device/unbind/${device_id}`)
|
||||
.method('PUT')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('解绑设备失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.unbindDevice(device_id, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 绑定设备
|
||||
bindDevice(agentId, code, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/user/agent/device/bind/${agentId}`)
|
||||
.method('POST')
|
||||
.data({ code })
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('绑定设备失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.bindDevice(agentId, code, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 新增方法:获取智能体模板
|
||||
getAgentTemplate(templateName, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/api/v1/user/agent/templateId`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((err) => {
|
||||
console.error('获取模板失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getAgentTemplate(templateName, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.8 MiB |
|
After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 876 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 13 KiB |
@@ -49,17 +49,23 @@ export default {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
import('@/apis/module/user').then(({ default: userApi }) => {
|
||||
userApi.bindDevice(
|
||||
import('@/apis/module/device').then(({ default: deviceApi }) => {
|
||||
deviceApi.bindDevice(
|
||||
this.agentId,
|
||||
this.deviceCode, ({data}) => {
|
||||
this.loading = false;
|
||||
if (data.code === 0) {
|
||||
this.$emit('refresh');
|
||||
this.$message.success('设备绑定成功');
|
||||
this.$message.success({
|
||||
message: '设备绑定成功',
|
||||
showClose: true
|
||||
});
|
||||
this.closeDialog();
|
||||
} else {
|
||||
this.$message.error(data.msg || '绑定失败');
|
||||
this.$message.error({
|
||||
message: data.msg || '绑定失败',
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<el-dialog :visible.sync="visible" width="400px" center>
|
||||
<div style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||
<div style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
|
||||
<img src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
|
||||
<img loading="lazy" src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
|
||||
</div>
|
||||
添加智慧体
|
||||
添加智能体
|
||||
</div>
|
||||
<div style="height: 1px;background: #e8f0ff;" />
|
||||
<div style="margin: 22px 15px;">
|
||||
@@ -12,7 +12,7 @@
|
||||
<div style="color: red;display: inline-block;">*</div> 智慧体名称:
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
<el-input placeholder="请输入智慧体名称.." v-model="wisdomBodyName" />
|
||||
<el-input placeholder="请输入智能体名称.." v-model="wisdomBodyName" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex;margin: 15px 15px;gap: 7px;">
|
||||
@@ -29,7 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import userApi from '@/apis/module/user';
|
||||
import userApi from '@/apis/module/agent';
|
||||
|
||||
|
||||
export default {
|
||||
@@ -43,11 +43,14 @@ export default {
|
||||
methods: {
|
||||
confirm() {
|
||||
if (!this.wisdomBodyName.trim()) {
|
||||
this.$message.error('请输入智慧体名称');
|
||||
this.$message.error('请输入智能体名称');
|
||||
return;
|
||||
}
|
||||
userApi.addAgent(this.wisdomBodyName, (res) => {
|
||||
this.$message.success('添加成功');
|
||||
this.$message.success({
|
||||
message: '添加成功',
|
||||
showClose: true
|
||||
});
|
||||
this.$emit('confirm', res);
|
||||
this.$emit('update:visible', false);
|
||||
this.wisdomBodyName = "";
|
||||
|
||||
@@ -1,57 +1,61 @@
|
||||
<template>
|
||||
<el-dialog :visible.sync="visible" width="400px" center>
|
||||
<div
|
||||
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||
<div
|
||||
style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
|
||||
<img src="@/assets/login/shield.png" alt="" style="width: 19px;height: 23px; filter: brightness(0) invert(1);" />
|
||||
<form>
|
||||
<el-dialog :visible.sync="value" width="400px" center>
|
||||
<div style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||
<div style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
|
||||
<img loading="lazy" src="@/assets/login/shield.png" alt="" style="width: 19px;height: 23px; filter: brightness(0) invert(1);" />
|
||||
</div>
|
||||
修改密码
|
||||
</div>
|
||||
修改密码
|
||||
</div>
|
||||
<div style="height: 1px;background: #e8f0ff;"/>
|
||||
<div style="margin: 22px 15px;">
|
||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
|
||||
<div style="color: red;display: inline-block;">*</div>
|
||||
旧密码:
|
||||
<div style="height: 1px;background: #e8f0ff;"/>
|
||||
<div style="margin: 22px 15px;">
|
||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
|
||||
<div style="color: red;display: inline-block;">*</div>
|
||||
旧密码:
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
<el-input placeholder="请输入旧密码" v-model="oldPassword" type="password" show-password/>
|
||||
</div>
|
||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;margin-top: 12px;">
|
||||
<div style="color: red;display: inline-block;">*</div>
|
||||
新密码:
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
<el-input placeholder="请输入新密码" v-model="newPassword" type="password" show-password/>
|
||||
</div>
|
||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;margin-top: 12px;">
|
||||
<div style="color: red;display: inline-block;">*</div>
|
||||
确认新密码:
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
<el-input placeholder="请再次输入新密码" v-model="confirmNewPassword" type="password" show-password/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
<el-input placeholder="请输入旧密码" v-model="oldPassword" type="password" show-password/>
|
||||
<div style="display: flex;margin: 15px 15px;gap: 7px;">
|
||||
<div class="dialog-btn" @click="confirm">
|
||||
确定
|
||||
</div>
|
||||
<div class="dialog-btn"
|
||||
style="background: #e6ebff;border: 1px solid #adbdff;color: #5778ff;"
|
||||
@click="cancel">
|
||||
取消
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;margin-top: 12px;">
|
||||
<div style="color: red;display: inline-block;">*</div>
|
||||
新密码:
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
<el-input placeholder="请输入新密码" v-model="newPassword" type="password" show-password/>
|
||||
</div>
|
||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;margin-top: 12px;">
|
||||
<div style="color: red;display: inline-block;">*</div>
|
||||
确认新密码:
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
<el-input placeholder="请再次输入新密码" v-model="confirmNewPassword" type="password" show-password/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex;margin: 15px 15px;gap: 7px;">
|
||||
<div class="dialog-btn" @click="confirm">
|
||||
确定
|
||||
</div>
|
||||
<div class="dialog-btn"
|
||||
style="background: #e6ebff;border: 1px solid #adbdff;color: #5778ff;"
|
||||
@click="cancel">
|
||||
取消
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import userApi from '@/apis/module/user';
|
||||
import { mapActions } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ChangePasswordDialog',
|
||||
props: {
|
||||
visible: {type: Boolean, required: true}
|
||||
value: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -61,6 +65,7 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['logout']), // 引入Vuex的logout action
|
||||
confirm() {
|
||||
if (!this.oldPassword.trim() || !this.newPassword.trim() || !this.confirmNewPassword.trim()) {
|
||||
this.$message.error('请填写所有字段');
|
||||
@@ -75,22 +80,24 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// 直接调用修改密码接口
|
||||
// 修改后的接口调用
|
||||
userApi.changePassword(this.oldPassword, this.newPassword, (res) => {
|
||||
if (res.code === 0) {
|
||||
this.$message.error(res.msg || '密码修改失败');
|
||||
} else {
|
||||
this.$message.success('密码修改成功');
|
||||
this.$emit('confirm', res);
|
||||
this.$emit('update:visible', false);
|
||||
this.resetForm();
|
||||
if (res.data.code === 0) {
|
||||
this.$message.success('密码修改成功,请重新登录');
|
||||
this.logout().then(() => {
|
||||
this.$router.push('/login');
|
||||
this.$emit('update:visible', false);
|
||||
});
|
||||
}else{
|
||||
this.$message.error(res.data.msg || '密码修改失败');
|
||||
}
|
||||
}, (err) => {
|
||||
this.$message.error(err.msg || '密码修改失败');
|
||||
});
|
||||
this.$emit('input', false);
|
||||
},
|
||||
cancel() {
|
||||
this.$emit('update:visible', false);
|
||||
this.$emit('input', false);
|
||||
this.resetForm();
|
||||
},
|
||||
resetForm() {
|
||||
@@ -104,7 +111,6 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.input-46 {
|
||||
border: 1px solid #e4e6ef;
|
||||
background: #f6f8fb;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
<div class="settings-btn" @click="handleConfigure">
|
||||
配置角色
|
||||
</div>
|
||||
<div class="settings-btn">
|
||||
声纹识别
|
||||
</div>
|
||||
<div class="settings-btn">
|
||||
历史对话
|
||||
</div>
|
||||
<!-- <div class="settings-btn">-->
|
||||
<!-- 声纹识别-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="settings-btn">-->
|
||||
<!-- 历史对话-->
|
||||
<!-- </div>-->
|
||||
<div class="settings-btn" @click="handleDeviceManage">
|
||||
设备管理
|
||||
</div>
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
<div class="header-container">
|
||||
<!-- 左侧元素 -->
|
||||
<div class="header-left">
|
||||
<img alt="" src="@/assets/xiaozhi-logo.png" class="logo-img"/>
|
||||
<img alt="" src="@/assets/xiaozhi-ai.png" class="brand-img"/>
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" class="logo-img"/>
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" class="brand-img"/>
|
||||
</div>
|
||||
|
||||
<!-- 中间导航菜单 -->
|
||||
<div class="header-center">
|
||||
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/home' }" @click="goHome">
|
||||
<img alt="" src="@/assets/header/roboot.png" :style="{ filter: $route.path === '/home' ? 'brightness(0) invert(1)' : 'None' }"/>
|
||||
<img loading="lazy" alt="" src="@/assets/header/robot.png" :style="{ filter: $route.path === '/home' ? 'brightness(0) invert(1)' : 'None' }"/>
|
||||
智能体管理
|
||||
</div>
|
||||
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/user-management' }" @click="goUserManagement">
|
||||
<img alt="" src="@/assets/header/user_management.png" :style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }"/>
|
||||
<div v-if="isSuperAdmin" class="equipment-management" :class="{ 'active-tab': $route.path === '/user-management' }" @click="goUserManagement">
|
||||
<img loading="lazy" alt="" src="@/assets/header/user_management.png" :style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }"/>
|
||||
用户管理
|
||||
</div>
|
||||
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }" @click="goModelConfig">
|
||||
<img alt="" src="@/assets/header/model_config.png" :style="{ filter: $route.path === '/model-config' ? 'brightness(0) invert(1)' : 'None' }"/>
|
||||
<div v-if="isSuperAdmin" class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }" @click="goModelConfig">
|
||||
<img loading="lazy" alt="" src="@/assets/header/model_config.png" :style="{ filter: $route.path === '/model-config' ? 'brightness(0) invert(1)' : 'None' }"/>
|
||||
模型配置
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="header-right">
|
||||
<div class="search-container">
|
||||
<el-input
|
||||
v-model="serach"
|
||||
v-model="search"
|
||||
placeholder="输入名称搜索.."
|
||||
class="custom-search-input"
|
||||
@keyup.enter.native="handleSearch"
|
||||
@@ -35,10 +35,10 @@
|
||||
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i>
|
||||
</el-input>
|
||||
</div>
|
||||
<img alt="" src="@/assets/home/avatar.png" class="avatar-img"/>
|
||||
<img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img"/>
|
||||
<el-dropdown trigger="click" class="user-dropdown">
|
||||
<span class="el-dropdown-link">
|
||||
{{ userInfo.mobile || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i>
|
||||
{{ userInfo.username || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i>
|
||||
</span>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<el-dropdown-item icon="el-icon-plus" @click.native="">个人中心</el-dropdown-item>
|
||||
@@ -50,14 +50,14 @@
|
||||
</div>
|
||||
|
||||
<!-- 修改密码弹窗 -->
|
||||
<ChangePasswordDialog :visible.sync="isChangePasswordDialogVisible"/>
|
||||
<ChangePasswordDialog v-model="isChangePasswordDialogVisible"/>
|
||||
</el-header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import userApi from '@/apis/module/user';
|
||||
import ChangePasswordDialog from './ChangePasswordDialog.vue'; // 引入修改密码弹窗组件
|
||||
import { mapActions } from 'vuex'; // 导入 mapActions
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
|
||||
|
||||
export default {
|
||||
@@ -68,7 +68,7 @@ export default {
|
||||
props: ['devices'], // 接收父组件设备列表
|
||||
data() {
|
||||
return {
|
||||
serach: '',
|
||||
search: '',
|
||||
userInfo: {
|
||||
username: '',
|
||||
mobile: ''
|
||||
@@ -76,13 +76,19 @@ export default {
|
||||
isChangePasswordDialogVisible: false // 控制修改密码弹窗的显示
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['getIsSuperAdmin']),
|
||||
isSuperAdmin() {
|
||||
return this.getIsSuperAdmin;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchUserInfo()
|
||||
},
|
||||
methods: {
|
||||
goHome() {
|
||||
// 跳转到首页
|
||||
this.$router.push('/')
|
||||
this.$router.push('/home')
|
||||
},
|
||||
goUserManagement() {
|
||||
this.$router.push('/user-management')
|
||||
@@ -94,12 +100,15 @@ export default {
|
||||
fetchUserInfo() {
|
||||
userApi.getUserInfo(({data}) => {
|
||||
this.userInfo = data.data
|
||||
if (data.data.superAdmin !== undefined) {
|
||||
this.$store.commit('setUserInfo', data.data);
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 处理搜索
|
||||
handleSearch() {
|
||||
const searchValue = this.serach.trim();
|
||||
const searchValue = this.search.trim();
|
||||
let filteredDevices;
|
||||
|
||||
if (!searchValue) {
|
||||
@@ -126,13 +135,16 @@ export default {
|
||||
try {
|
||||
// 调用 Vuex 的 logout action
|
||||
await this.logout();
|
||||
|
||||
this.$message.success('退出登录成功');
|
||||
|
||||
this.$router.push('/login');
|
||||
this.$message.success({
|
||||
message:'退出登录成功',
|
||||
showClose:true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
this.$message.error('退出登录失败,请重试');
|
||||
this.$message.error({
|
||||
message:'退出登录失败,请重试',
|
||||
showClose:true
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -194,6 +206,7 @@ export default {
|
||||
}
|
||||
|
||||
.equipment-management {
|
||||
padding: 0 9px;
|
||||
width: 82px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :visible.sync="visible" width="80%" @close="handleClose">
|
||||
<el-dialog :visible.sync="localVisible" width="80%" @close="handleClose">
|
||||
<el-row class="main-container">
|
||||
<el-col :span="4">
|
||||
<el-menu class="model-menu" :default-active="activeModel" mode="vertical" @select="handleModelSelect">
|
||||
@@ -57,6 +57,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localVisible: this.visible,
|
||||
activeModel: 'EdgeTTS',
|
||||
searchQuery: '',
|
||||
editDialogVisible: false,
|
||||
@@ -72,6 +73,11 @@ export default {
|
||||
total: 20
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
visible(newVal) {
|
||||
this.localVisible = newVal;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredTtsModels() {
|
||||
return this.ttsModels.filter(model =>
|
||||
@@ -81,6 +87,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.localVisible = false;
|
||||
this.$emit('update:visible', false);
|
||||
},
|
||||
handleModelSelect(index) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<el-dialog :visible.sync="visible" width="400px" center>
|
||||
<div style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
|
||||
<div style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
|
||||
<img src="@/assets/login/shield.png" alt="" style="width: 19px;height: 23px; filter: brightness(0) invert(1);" />
|
||||
</div>
|
||||
用户新密码
|
||||
</div>
|
||||
<div style="height: 1px;background: #e8f0ff;" />
|
||||
<div style="margin: 22px 15px;">
|
||||
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
|
||||
<div style="color: red;display: inline-block;">*</div>
|
||||
用户新密码:
|
||||
</div>
|
||||
<div class="input-46" style="margin-top: 12px;">
|
||||
<el-input
|
||||
v-model="password"
|
||||
type="text"
|
||||
:readonly="true"
|
||||
style="font-weight: bold; color: #333;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex;margin: 15px 15px;gap: 7px;">
|
||||
<div
|
||||
class="dialog-btn"
|
||||
style="background: #e6ebff;border: 1px solid #adbdff;color: #5778ff;"
|
||||
@click="closeDialog"
|
||||
>
|
||||
关闭
|
||||
</div>
|
||||
<div
|
||||
class="dialog-btn"
|
||||
style="background: #5778ff;color: white;"
|
||||
@click="copyPassword"
|
||||
>
|
||||
复制密码
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ViewPasswordDialog',
|
||||
props: {
|
||||
visible: { type: Boolean, required: true },
|
||||
password: { type: String, default: '' }
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('update:visible', false)
|
||||
},
|
||||
copyPassword() {
|
||||
navigator.clipboard.writeText(this.password)
|
||||
this.$message.success('密码已复制')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-46 {
|
||||
border: 1px solid #e4e6ef;
|
||||
background: #f6f8fb;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
border-radius: 23px;
|
||||
height: 40px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog {
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__headerbtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__body {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
::v-deep .el-input__inner {
|
||||
background-color: #f6f8fb !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
</style>
|
||||
@@ -5,8 +5,10 @@ import store from './store'
|
||||
import 'normalize.css/normalize.css' // A modern alternative to CSS resets
|
||||
import ElementUI from 'element-ui';
|
||||
import 'element-ui/lib/theme-chalk/index.css';
|
||||
import './styles/global.scss'
|
||||
|
||||
Vue.use(ElementUI);
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
new Vue({
|
||||
|
||||
@@ -76,4 +76,36 @@ const router = new VueRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
// 全局处理重复导航,改为刷新页面
|
||||
const originalPush = VueRouter.prototype.push
|
||||
VueRouter.prototype.push = function push(location) {
|
||||
return originalPush.call(this, location).catch(err => {
|
||||
if (err.name === 'NavigationDuplicated') {
|
||||
// 如果是重复导航,刷新页面
|
||||
window.location.reload()
|
||||
} else {
|
||||
// 其他错误正常抛出
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 需要登录才能访问的路由
|
||||
const protectedRoutes = ['home', 'RoleConfig', 'DeviceManagement', 'UserManagement', 'ModelConfig']
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 检查是否是需要保护的路由
|
||||
if (protectedRoutes.includes(to.name)) {
|
||||
// 从localStorage获取token
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
// 未登录,跳转到登录页
|
||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import Constant from '../utils/constant'
|
||||
import {goToPage} from "@/utils";
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
token: '',
|
||||
userInfo: {} // 添加用户信息存储
|
||||
userInfo: {}, // 添加用户信息存储
|
||||
isSuperAdmin: false // 添加superAdmin状态
|
||||
},
|
||||
getters: {
|
||||
getToken(state) {
|
||||
@@ -18,6 +20,12 @@ export default new Vuex.Store({
|
||||
},
|
||||
getUserInfo(state) {
|
||||
return state.userInfo
|
||||
},
|
||||
getIsSuperAdmin(state) {
|
||||
if (localStorage.getItem('isSuperAdmin') === null) {
|
||||
return state.isSuperAdmin
|
||||
}
|
||||
return localStorage.getItem('isSuperAdmin') === 'true'
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
@@ -27,11 +35,16 @@ export default new Vuex.Store({
|
||||
},
|
||||
setUserInfo(state, userInfo) {
|
||||
state.userInfo = userInfo
|
||||
const isSuperAdmin = userInfo.superAdmin === 1
|
||||
state.isSuperAdmin = isSuperAdmin
|
||||
localStorage.setItem('isSuperAdmin', isSuperAdmin)
|
||||
},
|
||||
clearAuth(state) {
|
||||
state.token = ''
|
||||
state.userInfo = {}
|
||||
state.isSuperAdmin = false
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('isSuperAdmin')
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
@@ -39,7 +52,8 @@ export default new Vuex.Store({
|
||||
logout({ commit }) {
|
||||
return new Promise((resolve) => {
|
||||
commit('clearAuth')
|
||||
resolve()
|
||||
goToPage(Constant.PAGE.LOGIN,true);
|
||||
window.location.reload(); // 彻底重置状态
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// 覆盖 autofill 样式
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill,
|
||||
textarea:-webkit-autofill:hover,
|
||||
textarea:-webkit-autofill:focus,
|
||||
select:-webkit-autofill,
|
||||
select:-webkit-autofill:hover,
|
||||
select:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0px 1000px transparent inset;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -54,7 +54,8 @@ export function showDanger(msg) {
|
||||
}
|
||||
Message({
|
||||
message: msg,
|
||||
type: 'error'
|
||||
type: 'error',
|
||||
showClose: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,7 +69,8 @@ export function showWarning(msg) {
|
||||
}
|
||||
Message({
|
||||
message: msg,
|
||||
type: 'warning'
|
||||
type: 'warning',
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,7 +83,8 @@ export function showWarning(msg) {
|
||||
export function showSuccess(msg) {
|
||||
Message({
|
||||
message: msg,
|
||||
type: 'success'
|
||||
type: 'success',
|
||||
showClose: true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
:total="deviceList.length"
|
||||
></el-pagination>
|
||||
</div>
|
||||
<div style="font-size: 12px; font-weight: 400; margin-top: auto; padding-top: 30px; color: #979db1;">
|
||||
<div class="copyright">
|
||||
©2025 xiaozhi-esp32-server
|
||||
</div>
|
||||
<AddDeviceDialog :visible.sync="addDeviceDialogVisible" :agent-id="currentAgentId" @refresh="fetchBindDevices(currentAgentId)" />
|
||||
@@ -59,6 +59,7 @@
|
||||
<script>
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
|
||||
|
||||
export default {
|
||||
components: {HeaderBar, AddDeviceDialog },
|
||||
data() {
|
||||
@@ -81,8 +82,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
const agentId = this.$route.query.agentId;
|
||||
import('@/apis/module/user').then(({ default: userApi }) => {
|
||||
this.userApi = userApi;
|
||||
import('@/apis/module/device').then(({ default: deviceApi }) => {
|
||||
this.deviceApi = deviceApi;
|
||||
if (agentId) {
|
||||
this.fetchBindDevices(agentId);
|
||||
}
|
||||
@@ -99,7 +100,7 @@ export default {
|
||||
this.deviceList[index].isEdit = false;
|
||||
},
|
||||
handleUnbind(device_id) {
|
||||
if (!this.userApi) {
|
||||
if (!this.deviceApi) {
|
||||
this.$message.error('功能模块加载失败');
|
||||
return;
|
||||
}
|
||||
@@ -108,12 +109,18 @@ export default {
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
this.userApi.unbindDevice(device_id, ({ data }) => {
|
||||
this.deviceApi.unbindDevice(device_id, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success('设备解绑成功');
|
||||
this.$message.success({
|
||||
message: '设备解绑成功',
|
||||
showClose: true
|
||||
});
|
||||
this.fetchBindDevices(this.$route.query.agentId);
|
||||
} else {
|
||||
this.$message.error(data.msg || '设备解绑失败');
|
||||
this.$message.error({
|
||||
message: data.msg || '设备解绑失败',
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -124,33 +131,45 @@ export default {
|
||||
handleCurrentChange(val) {
|
||||
this.currentPage = val;
|
||||
},
|
||||
fetchBindDevices(agentId) {
|
||||
this.loading = true;
|
||||
import('@/apis/module/user').then(({ default: userApi }) => {
|
||||
userApi.getAgentBindDevices(agentId, ({ data }) => {
|
||||
this.loading = false;
|
||||
if (data.code === 0) {
|
||||
this.deviceList = data.data[0].list.map(device => ({
|
||||
fetchBindDevices(agentId) {
|
||||
this.loading = true;
|
||||
import('@/apis/module/device').then(({ default: deviceApi }) => {
|
||||
deviceApi.getAgentBindDevices(agentId, ({ data }) => {
|
||||
this.loading = false;
|
||||
if (data.code === 0) {
|
||||
// 格式化日期并按照绑定时间降序排列
|
||||
this.deviceList = data.data.map(device => {
|
||||
// 格式化绑定时间
|
||||
const bindDate = new Date(device.createDate);
|
||||
const formattedBindTime = `${bindDate.getFullYear()}-${(bindDate.getMonth()+1).toString().padStart(2, '0')}-${bindDate.getDate().toString().padStart(2, '0')} ${bindDate.getHours().toString().padStart(2, '0')}:${bindDate.getMinutes().toString().padStart(2, '0')}:${bindDate.getSeconds().toString().padStart(2, '0')}`;
|
||||
return {
|
||||
device_id: device.id,
|
||||
model: device.device_type,
|
||||
firmwareVersion: device.app_version,
|
||||
macAddress: device.mac_address,
|
||||
lastConversation: device.recent_chat_time,
|
||||
remark: '',
|
||||
model: device.board,
|
||||
firmwareVersion: device.appVersion,
|
||||
macAddress: device.macAddress,
|
||||
bindTime: formattedBindTime, // 使用格式化后的时间
|
||||
lastConversation: device.lastConnectedAt,
|
||||
remark: device.alias,
|
||||
isEdit: false,
|
||||
otaSwitch: device.ota_upgrade === 1
|
||||
}));
|
||||
} else {
|
||||
this.$message.error(data.msg || '获取设备列表失败');
|
||||
}
|
||||
});
|
||||
}).catch(error => {
|
||||
console.error('模块加载失败:', error);
|
||||
this.$message.error('功能模块加载失败');
|
||||
otaSwitch: device.autoUpdate === 1,
|
||||
// 添加原始时间用于排序
|
||||
rawBindTime: new Date(device.createDate).getTime()
|
||||
};
|
||||
})
|
||||
// 按照绑定时间降序排序
|
||||
.sort((a, b) => a.rawBindTime - b.rawBindTime);
|
||||
} else {
|
||||
this.$message.error(data.msg || '获取设备列表失败');
|
||||
}
|
||||
});
|
||||
},
|
||||
}).catch(error => {
|
||||
console.error('模块加载失败:', error);
|
||||
this.$message.error('功能模块加载失败');
|
||||
});
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -5,15 +5,12 @@
|
||||
<div class="operation-bar">
|
||||
<h2 class="page-title">模型配置</h2>
|
||||
<div class="right-operations">
|
||||
<el-button v-if="activeTab === 'tts'" type="primary" plain size="small" @click="ttsDialogVisible = true" style="">
|
||||
语音设置
|
||||
</el-button>
|
||||
<el-button plain size="small" @click="handleImport" style="background: #7b9de5; color: white;">
|
||||
<img alt="" src="@/assets/model/inner_conf.png">
|
||||
<img loading="lazy" alt="" src="@/assets/model/inner_conf.png">
|
||||
导入配置
|
||||
</el-button>
|
||||
<el-button plain size="small" @click="handleExport" style="background: #71c9d1; color: white;">
|
||||
<img alt="" src="@/assets/model/output_conf.png">
|
||||
<img loading="lazy" alt="" src="@/assets/model/output_conf.png">
|
||||
导出配置
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -74,7 +71,14 @@
|
||||
<el-switch v-model="scope.row.isApplied" class="custom-switch" :active-color="null" :inactive-color="null"/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="180">
|
||||
<el-table-column v-if="activeTab === 'tts'" label="音色管理" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="mini" @click="ttsDialogVisible = true" class="voice-management-btn">
|
||||
音色管理
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="150px">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn">
|
||||
修改
|
||||
@@ -93,8 +97,16 @@
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="pagination-container">
|
||||
<el-pagination @current-change="handleCurrentChange" background :current-page="currentPage" :page-size="pageSize" layout="prev, pager, next" :total="total"/>
|
||||
<div class="custom-pagination">
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</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">下一页</button>
|
||||
<span class="total-text">共{{ total }}条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +114,7 @@
|
||||
|
||||
<ModelEditDialog :visible.sync="editDialogVisible" :modelData="editModelData" @save="handleModelSave"/>
|
||||
<TtsModel :visible.sync="ttsDialogVisible" />
|
||||
<AddModelDialog :visible.sync="addDialogVisible" @confirm="handleAddConfirm"/>
|
||||
<AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm"/>
|
||||
</div>
|
||||
|
||||
<div class="copyright">
|
||||
@@ -139,6 +151,28 @@ export default {
|
||||
isAllSelected: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
pageCount() {
|
||||
return Math.ceil(this.total / this.pageSize);
|
||||
},
|
||||
visiblePages() {
|
||||
const pages = [];
|
||||
const maxVisible = 3;
|
||||
let start = Math.max(1, this.currentPage - 1);
|
||||
let end = Math.min(this.pageCount, start + maxVisible - 1);
|
||||
|
||||
if (end - start + 1 < maxVisible) {
|
||||
start = Math.max(1, end - maxVisible + 1);
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
headerCellClassName({ column, columnIndex }) {
|
||||
if (columnIndex === 0) {
|
||||
@@ -224,6 +258,31 @@ export default {
|
||||
},
|
||||
handleAddConfirm(newModel) {
|
||||
console.log('新增模型数据:', newModel);
|
||||
},
|
||||
|
||||
// 分页器
|
||||
goFirst() {
|
||||
this.currentPage = 1;
|
||||
this.loadData();
|
||||
},
|
||||
goPrev() {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
this.loadData();
|
||||
}
|
||||
},
|
||||
goNext() {
|
||||
if (this.currentPage < this.pageCount) {
|
||||
this.currentPage++;
|
||||
this.loadData();
|
||||
}
|
||||
},
|
||||
goToPage(page) {
|
||||
this.currentPage = page;
|
||||
this.loadData();
|
||||
},
|
||||
loadData() {
|
||||
console.log('加载数据,当前页:', this.currentPage);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -349,6 +408,8 @@ export default {
|
||||
height: 100%;
|
||||
min-width: 600px;
|
||||
overflow-x: auto;
|
||||
background-color: white;
|
||||
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
@@ -439,14 +500,6 @@ export default {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
color: #7079aa !important;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
color: #7079aa !important;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #cce5f9;
|
||||
width: 75px;
|
||||
@@ -543,5 +596,107 @@ export default {
|
||||
border-color: #409EFF !important;
|
||||
}
|
||||
|
||||
.voice-management-btn {
|
||||
background: #9db3ea;
|
||||
color: white;
|
||||
min-width: 68px;
|
||||
line-height: 14px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.voice-management-btn:hover {
|
||||
background: #8aa2e0; /* 悬停时颜色加深 */
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
::v-deep .el-table .el-table-column--selection .cell {
|
||||
padding-left: 15px !important;
|
||||
}
|
||||
|
||||
::v-deep .el-table .el-table__fixed-right .cell {
|
||||
padding-right: 15px !important;
|
||||
}
|
||||
|
||||
.edit-btn, .delete-btn {
|
||||
margin: 0 8px;
|
||||
color: #7079aa !important;
|
||||
}
|
||||
|
||||
::v-deep .el-table .cell {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
/* 分页器 */
|
||||
.custom-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 15px;
|
||||
|
||||
/* 导航按钮样式 (首页、上一页、下一页) */
|
||||
|
||||
.pagination-btn:first-child,
|
||||
.pagination-btn:nth-child(2),
|
||||
.pagination-btn:nth-last-child(2) {
|
||||
min-width: 60px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #DEE7FF;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #d7dce6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* 数字按钮样式 */
|
||||
|
||||
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-last-child(2)) {
|
||||
min-width: 28px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(245, 247, 250, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #5f70f3 !important;
|
||||
color: #ffffff !important;
|
||||
border-color: #5f70f3 !important;
|
||||
|
||||
&:hover {
|
||||
background: #6d7cf5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.total-text {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,85 +1,86 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<HeaderBar />
|
||||
<el-main class="main" style="padding: 20px; display: flex; flex-direction: column;">
|
||||
<div class="top-area">
|
||||
<div class="page-title">用户管理</div>
|
||||
<div class="page-search">
|
||||
<el-input placeholder="请输入手机号码查询" v-model="searchPhone" class="search-input" @keyup.enter.native="handleSearch"/>
|
||||
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
|
||||
|
||||
<div class="operation-bar">
|
||||
<h2 class="page-title">用户管理</h2>
|
||||
<div class="right-operations">
|
||||
<el-input placeholder="请输入手机号码查询" v-model="searchPhone" class="search-input" @keyup.enter.native="handleSearch"/>
|
||||
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<div class="content-panel">
|
||||
<div class="content-area">
|
||||
<el-card class="user-card" shadow="never">
|
||||
<el-table ref="userTable" :data="userList" class="transparent-table" :header-cell-class-name="headerCellClassName">
|
||||
<el-table-column label="选择" type="selection" align="center" width="120"></el-table-column>
|
||||
<el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
|
||||
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
|
||||
<el-table-column label="设备数量" prop="deviceCount" align="center"></el-table-column>
|
||||
<el-table-column label="状态" prop="status" align="center"></el-table-column>
|
||||
<el-table-column label="操作" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" @click="resetPassword(scope.row)" style="color: #989fdd">重置密码</el-button>
|
||||
<el-button size="mini" type="text"
|
||||
v-if="scope.row.status === '正常'"
|
||||
@click="disableUser(scope.row)">禁用账户</el-button>
|
||||
<el-button size="mini" type="text"
|
||||
v-if="scope.row.status === '禁用'"
|
||||
@click="restoreUser(scope.row)">恢复账号</el-button>
|
||||
<el-button size="mini" type="text" @click="deleteUser(scope.row)" style="color: #989fdd">删除用户</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table_bottom">
|
||||
<div class="ctrl_btn">
|
||||
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">全选</el-button>
|
||||
<el-button size="mini" type="success" icon="el-icon-circle-check" @click="batchEnable">启用</el-button>
|
||||
<el-button size="mini" type="warning" @click="batchDisable"><i class="el-icon-remove-outline rotated-icon"></i>禁用</el-button>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">删除</el-button>
|
||||
</div>
|
||||
<div class="custom-pagination">
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</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">下一页</button>
|
||||
<span class="total-text">共{{ total }}条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="user-card" shadow="never">
|
||||
<el-table :data="userList" class="transparent-table" :header-cell-class-name="headerCellClassName">
|
||||
<el-table-column label="选择" type="selection" align="center" width="120"></el-table-column>
|
||||
<el-table-column label="用户Id" prop="user_id" align="center"></el-table-column>
|
||||
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
|
||||
<el-table-column label="设备数量" prop="device_count" align="center"></el-table-column>
|
||||
<el-table-column label="状态" prop="status" align="center"></el-table-column>
|
||||
<el-table-column label="操作" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" @click="resetPassword(scope.row)" style="color: #989fdd">重置密码</el-button>
|
||||
<el-button size="mini" type="text"
|
||||
v-if="scope.row.status === '正常'"
|
||||
@click="disableUser(scope.row)">禁用账户</el-button>
|
||||
<el-button size="mini" type="text"
|
||||
v-if="scope.row.status === '禁用'"
|
||||
@click="restoreUser(scope.row)">恢复账号</el-button>
|
||||
<el-button size="mini" type="text" @click="deleteUser(scope.row)" style="color: #989fdd">删除用户</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table_bottom">
|
||||
<div class="ctrl_btn">
|
||||
<el-button size="mini" type="primary" style="width: 72px; background: #5f70f3">全选</el-button>
|
||||
<el-button size="mini" type="success" icon="el-icon-circle-check" style="background: #5bc98c">启用</el-button>
|
||||
<el-button size="mini" type="warning" style="color: black; background: #f6d075"><i class="el-icon-remove-outline rotated-icon"></i>禁用</el-button>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete" style="background: #fd5b63">删除</el-button>
|
||||
</div>
|
||||
<div class="custom-pagination">
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</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">下一页</button>
|
||||
<span class="total-text">共{{ total }}条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div style="font-size: 12px; font-weight: 400; margin-top: auto; padding-top: 30px; color: #979db1;">
|
||||
©2025 xiaozhi-esp32-server
|
||||
</div>
|
||||
</el-main>
|
||||
<div class="copyright">
|
||||
©2025 xiaozhi-esp32-server
|
||||
</div>
|
||||
<view-password-dialog :visible.sync="showViewPassword" :password="currentPassword"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
import adminApi from '@/apis/module/admin';
|
||||
|
||||
import ViewPasswordDialog from '@/components/ViewPasswordDialog.vue'
|
||||
|
||||
export default {
|
||||
components: { HeaderBar },
|
||||
components: { HeaderBar, ViewPasswordDialog },
|
||||
data() {
|
||||
return {
|
||||
showViewPassword: false,
|
||||
currentPassword: '',
|
||||
searchPhone: '',
|
||||
userList: [],
|
||||
originalUserList: [], // 原始数据
|
||||
currentPage: 1,
|
||||
pageSize: 5,
|
||||
total: 20
|
||||
total: 0
|
||||
};
|
||||
},
|
||||
created() {
|
||||
@@ -106,44 +107,113 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 获取用户列表
|
||||
fetchUsers() {
|
||||
adminApi.getUserList(({data}) => {
|
||||
if (data.code === 0) {
|
||||
const responseData = data.data[0] || data.data;
|
||||
this.originalUserList = responseData.list.map(user => ({
|
||||
...user,
|
||||
status: user.status === '1' ? '正常' : '禁用'
|
||||
}));
|
||||
this.userList = [...this.originalUserList];
|
||||
this.total = responseData.totalCount || 0;
|
||||
}
|
||||
});
|
||||
adminApi.getUserList({
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
mobile: this.searchPhone
|
||||
}, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.userList = data.data.list.map(user => ({
|
||||
...user,
|
||||
status: user.status === '1' ? '正常' : '禁用'
|
||||
}));
|
||||
this.total = data.data.total;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 分页变化
|
||||
handleCurrentChange(page) {
|
||||
this.currentPage = page;
|
||||
this.fetchUsers();
|
||||
},
|
||||
|
||||
// 搜索
|
||||
handleSearch() {
|
||||
if (!this.searchPhone) {
|
||||
this.userList = [...this.originalUserList];
|
||||
this.currentPage = 1;
|
||||
this.fetchUsers();
|
||||
},
|
||||
handleSelectAll() {
|
||||
this.$refs.userTable.toggleAllSelection();
|
||||
},
|
||||
batchDelete() {
|
||||
const selectedUsers = this.$refs.userTable.selection;
|
||||
if (selectedUsers.length === 0) {
|
||||
this.$message.warning('请先选择需要删除的用户');
|
||||
return;
|
||||
}
|
||||
this.userList = this.originalUserList.filter(user =>
|
||||
user.mobile.includes(this.searchPhone)
|
||||
)},
|
||||
batchDelete() {
|
||||
console.log('执行批量删除操作');
|
||||
|
||||
this.$confirm(`确定要删除选中的${selectedUsers.length}个用户吗?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
const loading = this.$loading({
|
||||
lock: true,
|
||||
text: '正在删除中...',
|
||||
spinner: 'el-icon-loading',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
selectedUsers.map(user => {
|
||||
return new Promise((resolve) => {
|
||||
adminApi.deleteUser(user.userid, ({data}) => {
|
||||
if (data.code === 0) {
|
||||
resolve({success: true, userid: user.userid});
|
||||
} else {
|
||||
resolve({success: false, userid: user.userid, msg: data.msg});
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failCount = results.length - successCount;
|
||||
|
||||
if (failCount === 0) {
|
||||
this.$message.success(`成功删除${successCount}个用户`);
|
||||
} else if (successCount === 0) {
|
||||
this.$message.error(`删除失败,请重试`);
|
||||
} else {
|
||||
this.$message.warning(`成功删除${successCount}个用户,${failCount}个删除失败`);
|
||||
}
|
||||
|
||||
this.fetchUsers();
|
||||
} catch (error) {
|
||||
this.$message.error('删除过程中发生错误');
|
||||
} finally {
|
||||
loading.close();
|
||||
}
|
||||
}).catch(() => {
|
||||
this.$message.info('已取消删除');
|
||||
});
|
||||
},
|
||||
batchEnable() {
|
||||
const selectedUsers = this.$refs.userTable.selection;
|
||||
if (selectedUsers.length === 0) {
|
||||
this.$message.warning('请先选择需要启用的用户');
|
||||
return;
|
||||
}
|
||||
selectedUsers.forEach(user => {
|
||||
user.status = '正常';
|
||||
});
|
||||
this.$message.success('启用操作成功');
|
||||
},
|
||||
batchDisable() {
|
||||
console.log('执行批量禁用操作');
|
||||
this.userList.forEach(user => {
|
||||
user.status = '禁用';
|
||||
});
|
||||
this.$message.success('状态已更新为禁用');
|
||||
},
|
||||
resetPassword(row) {
|
||||
console.log('重置用户密码,用户:', row);
|
||||
this.$confirm('重置后将会生成新密码,是否继续?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
}).then(() => {
|
||||
adminApi.resetUserPassword(row.userid, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.currentPassword = data.data
|
||||
this.showViewPassword = true
|
||||
this.$message.success('密码已重置,请通知用户使用新密码登录')
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
disableUser(row) {
|
||||
row.status = '禁用';
|
||||
@@ -154,13 +224,22 @@ export default {
|
||||
console.log('恢复用户:', row);
|
||||
},
|
||||
deleteUser(row) {
|
||||
console.log('删除用户:', row);
|
||||
this.$confirm('确定要删除该用户吗?', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
adminApi.deleteUser(row.userid, ({data}) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success('删除成功')
|
||||
this.fetchUsers()
|
||||
} else {
|
||||
this.$message.error(data.msg || '删除失败')
|
||||
}
|
||||
})
|
||||
}).catch(() => {})
|
||||
},
|
||||
handleSizeChange(val) {
|
||||
this.pageSize = val;
|
||||
console.log('每页条数:', val);
|
||||
},
|
||||
headerCellClassName({column, columnIndex}) {
|
||||
headerCellClassName({columnIndex}) {
|
||||
if (columnIndex === 0) {
|
||||
return 'custom-selection-header'
|
||||
}
|
||||
@@ -168,110 +247,150 @@ export default {
|
||||
},
|
||||
goFirst() {
|
||||
this.currentPage = 1;
|
||||
this.handleCurrentChange(1);
|
||||
this.fetchUsers();
|
||||
},
|
||||
goPrev() {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
this.handleCurrentChange(this.currentPage);
|
||||
this.fetchUsers();
|
||||
}
|
||||
},
|
||||
goNext() {
|
||||
if (this.currentPage < this.pageCount) {
|
||||
this.currentPage++;
|
||||
this.handleCurrentChange(this.currentPage);
|
||||
this.fetchUsers();
|
||||
}
|
||||
},
|
||||
goToPage(page) {
|
||||
this.currentPage = page;
|
||||
this.handleCurrentChange(page);
|
||||
this.fetchUsers();
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
$table-bg-color: #ecf1fd;
|
||||
|
||||
.main {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd);
|
||||
}
|
||||
|
||||
.top-area {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.page-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.btn-search {
|
||||
margin-left: 10px;
|
||||
background: linear-gradient(to right, #5778ff, #c793f3);
|
||||
width: 100px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.welcome {
|
||||
min-width: 900px;
|
||||
min-height: 506px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
background-image: url("@/assets/home/background.png");
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
|
||||
-webkit-background-size: cover;
|
||||
-o-background-size: cover;
|
||||
}
|
||||
|
||||
.user-search-operate {
|
||||
margin-bottom: 20px;
|
||||
.main-wrapper {
|
||||
margin: 5px 22px;
|
||||
border-radius: 15px;
|
||||
min-height: 600px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
background: rgba(237,242,255,0.5);
|
||||
}
|
||||
|
||||
.operation-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.user-search-operate > * {
|
||||
margin-right: 10px;
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.el-table__header th {
|
||||
background-color: #f5f7fa;
|
||||
color: #606266;
|
||||
.right-operations {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
background: linear-gradient(135deg, #6B8CFF, #A966FF);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border-radius: 15px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 600px;
|
||||
overflow-x: auto;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
background: $table-bg-color;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin: 15px;
|
||||
background: white;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.table_bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ctrl_btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 25px;
|
||||
.ctrl_btn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-left: 26px;
|
||||
.el-button {
|
||||
min-width: 72px;
|
||||
height: 32px;
|
||||
padding: 7px 12px 7px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: #5f70f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.el-button--success {
|
||||
background: #5bc98c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.el-button--warning {
|
||||
background: #f6d075;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.el-button--danger {
|
||||
background: #fd5b63;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,66 +401,18 @@ $table-bg-color: #ecf1fd;
|
||||
color: black;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
background: $table-bg-color;
|
||||
|
||||
&.transparent-table {
|
||||
.el-table__header th {
|
||||
background: $table-bg-color !important;
|
||||
color: black;
|
||||
}
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.el-table__body tr {
|
||||
background-color: $table-bg-color;
|
||||
td {
|
||||
border: {
|
||||
top: 1px solid rgba(0, 0, 0, 0.04);
|
||||
bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 300px;
|
||||
margin-right: 10px;
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
background-color: transparent;
|
||||
border-color: #d3d6dc;
|
||||
|
||||
&:focus {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #606266;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.custom-selection-header) {
|
||||
.el-checkbox {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '选择';
|
||||
display: inline-block;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
.copyright {
|
||||
text-align: center;
|
||||
color: #979db1;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-top: auto;
|
||||
padding: 30px 0 20px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-pagination {
|
||||
@@ -350,7 +421,6 @@ $table-bg-color: #ecf1fd;
|
||||
gap: 8px;
|
||||
margin-top: 15px;
|
||||
|
||||
/* 导航按钮样式 (首页、上一页、下一页) */
|
||||
.pagination-btn:first-child,
|
||||
.pagination-btn:nth-child(2),
|
||||
.pagination-btn:nth-last-child(2) {
|
||||
@@ -375,7 +445,6 @@ $table-bg-color: #ecf1fd;
|
||||
}
|
||||
}
|
||||
|
||||
/* 数字按钮样式 */
|
||||
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-last-child(2)) {
|
||||
min-width: 28px;
|
||||
height: 32px;
|
||||
@@ -393,7 +462,6 @@ $table-bg-color: #ecf1fd;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #5f70f3 !important;
|
||||
color: #ffffff !important;
|
||||
@@ -411,6 +479,40 @@ $table-bg-color: #ecf1fd;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.transparent-table) {
|
||||
background: white;
|
||||
.el-table__header th {
|
||||
background: white !important;
|
||||
color: black;
|
||||
}
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-table__body tr {
|
||||
background-color: white;
|
||||
td {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.custom-selection-header) {
|
||||
.el-checkbox {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '选择';
|
||||
display: inline-block;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner) {
|
||||
background-color: #eeeeee !important;
|
||||
border-color: #cccccc !important;
|
||||
@@ -425,5 +527,24 @@ $table-bg-color: #ecf1fd;
|
||||
border-color: #5f70f3 !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1144px) {
|
||||
.table_bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
:deep(.transparent-table) {
|
||||
.el-table__body tr {
|
||||
td {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
& + tr {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,9 +1,11 @@
|
||||
|
||||
.welcome {
|
||||
min-width: 1200px;
|
||||
min-height: 675px;
|
||||
height: 100vh;
|
||||
background-image: url("@/assets/login/background.png");
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(145deg, #f5f8fd, #6baaff, #9ebbfc, #f5f8fd);
|
||||
background-size: cover;
|
||||
/* 确保背景图像覆盖整个元素 */
|
||||
background-position: center;
|
||||
@@ -130,3 +132,13 @@
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.login-person {
|
||||
width: 500px;
|
||||
color: #fff;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 25%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 12px;font-weight: 400;margin-top: auto;padding-top: 30px;color: #979db1;">
|
||||
<div class="copyright">
|
||||
©2025 xiaozhi-esp32-server
|
||||
</div>
|
||||
<AddWisdomBodyDialog :visible.sync="addDeviceDialogVisible" @confirm="handleWisdomBodyAdded" />
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
},
|
||||
// 获取智能体列表
|
||||
fetchAgentList() {
|
||||
import('@/apis/module/user').then(({ default: userApi }) => {
|
||||
import('@/apis/module/agent').then(({ default: userApi }) => {
|
||||
userApi.getAgentList(({data}) => {
|
||||
this.originalDevices = data.data.map(item => ({
|
||||
...item,
|
||||
@@ -106,13 +106,19 @@ export default {
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
import('@/apis/module/user').then(({ default: userApi }) => {
|
||||
import('@/apis/module/agent').then(({ default: userApi }) => {
|
||||
userApi.deleteAgent(agentId, (res) => {
|
||||
if (res.data.code === 0) {
|
||||
this.$message.success('删除成功');
|
||||
this.$message.success({
|
||||
message: '删除成功',
|
||||
showClose: true
|
||||
});
|
||||
this.fetchAgentList(); // 刷新列表
|
||||
} else {
|
||||
this.$message.error(res.data.msg || '删除失败');
|
||||
this.$message.error({
|
||||
message: res.data.msg || '删除失败',
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
<el-header>
|
||||
<div
|
||||
style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
|
||||
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
|
||||
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
|
||||
</div>
|
||||
</el-header>
|
||||
<div class="login-person">
|
||||
<img loading="lazy" alt="" src="@/assets/login/login-person.png" style="width: 100%;"/>
|
||||
</div>
|
||||
<el-main style="position: relative;">
|
||||
<div class="login-box">
|
||||
<div class="login-box" @keyup.enter="login">
|
||||
<div
|
||||
style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
|
||||
<img alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
|
||||
<img loading="lazy" alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
|
||||
<div class="login-text">登录</div>
|
||||
<div class="login-welcome">
|
||||
WELCOME TO LOGIN
|
||||
@@ -20,19 +23,19 @@
|
||||
</div>
|
||||
<div style="padding: 0 30px;">
|
||||
<div class="input-box">
|
||||
<img alt="" class="input-icon" src="@/assets/login/username.png"/>
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png"/>
|
||||
<el-input v-model="form.username" placeholder="请输入用户名"/>
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<img alt="" class="input-icon" src="@/assets/login/password.png"/>
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
|
||||
<el-input v-model="form.password" placeholder="请输入密码" type="password"/>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
|
||||
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
|
||||
<img alt="" class="input-icon" src="@/assets/login/shield.png"/>
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png"/>
|
||||
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
|
||||
</div>
|
||||
<img v-if="captchaUrl"
|
||||
<img loading="lazy" v-if="captchaUrl"
|
||||
:src="captchaUrl"
|
||||
alt="验证码"
|
||||
style="width: 150px; height: 40px; cursor: pointer;"
|
||||
@@ -54,7 +57,7 @@
|
||||
</div>
|
||||
</el-main>
|
||||
<el-footer>
|
||||
<div style="font-size: 12px;font-weight: 400;color: #979db1;">
|
||||
<div class="copyright">
|
||||
©2025 xiaozhi-esp32-server
|
||||
</div>
|
||||
</el-footer>
|
||||
@@ -87,8 +90,11 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
fetchCaptcha() {
|
||||
console.log(this.$store.getters.getToken)
|
||||
if (this.$store.getters.getToken) {
|
||||
goToPage('/home')
|
||||
if (this.$route.path !== '/home'){
|
||||
this.$router.push('/home')
|
||||
}
|
||||
} else {
|
||||
this.captchaUuid = getUUID();
|
||||
|
||||
@@ -96,10 +102,8 @@ export default {
|
||||
if (res.status === 200) {
|
||||
const blob = new Blob([res.data], {type: res.data.type});
|
||||
this.captchaUrl = URL.createObjectURL(blob);
|
||||
|
||||
} else {
|
||||
console.error('验证码加载异常:', error);
|
||||
showDanger('验证码加载失败,点击刷新')
|
||||
showDanger('验证码加载失败,点击刷新');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -130,12 +134,13 @@ export default {
|
||||
|
||||
this.form.captchaId = this.captchaUuid
|
||||
Api.user.login(this.form, ({data}) => {
|
||||
console.log(data)
|
||||
showSuccess('登陆成功!')
|
||||
// 将令牌存储到 Vuex 中
|
||||
this.$store.commit('setToken', JSON.stringify(data.data))
|
||||
|
||||
goToPage('/home')
|
||||
if (data.code === 0) {
|
||||
showSuccess('登录成功!');
|
||||
this.$store.commit('setToken', JSON.stringify(data.data));
|
||||
goToPage('/home');
|
||||
} else {
|
||||
showDanger(data.msg || '登录失败');
|
||||
}
|
||||
})
|
||||
|
||||
// 重新获取验证码
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<!-- 保持相同的头部 -->
|
||||
<el-header>
|
||||
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
|
||||
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
|
||||
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
|
||||
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="login-box">
|
||||
<!-- 修改标题部分 -->
|
||||
<div style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
|
||||
<img alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
|
||||
<img loading="lazy" alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
|
||||
<div class="login-text">注册</div>
|
||||
<div class="login-welcome">
|
||||
WELCOME TO REGISTER
|
||||
@@ -23,29 +23,29 @@
|
||||
<div style="padding: 0 30px;">
|
||||
<!-- 用户名输入框 -->
|
||||
<div class="input-box">
|
||||
<img alt="" class="input-icon" src="@/assets/login/username.png"/>
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png"/>
|
||||
<el-input v-model="form.username" placeholder="请输入用户名"/>
|
||||
</div>
|
||||
|
||||
<!-- 密码输入框 -->
|
||||
<div class="input-box">
|
||||
<img alt="" class="input-icon" src="@/assets/login/password.png"/>
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
|
||||
<el-input v-model="form.password" placeholder="请输入密码" type="password"/>
|
||||
</div>
|
||||
|
||||
<!-- 新增确认密码 -->
|
||||
<div class="input-box">
|
||||
<img alt="" class="input-icon" src="@/assets/login/password.png"/>
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
|
||||
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password"/>
|
||||
</div>
|
||||
|
||||
<!-- 验证码部分保持相同 -->
|
||||
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
|
||||
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
|
||||
<img alt="" class="input-icon" src="@/assets/login/shield.png"/>
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png"/>
|
||||
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
|
||||
</div>
|
||||
<img v-if="captchaUrl"
|
||||
<img loading="lazy" v-if="captchaUrl"
|
||||
:src="captchaUrl"
|
||||
alt="验证码"
|
||||
style="width: 150px; height: 40px; cursor: pointer;"
|
||||
@@ -74,7 +74,7 @@
|
||||
|
||||
<!-- 保持相同的页脚 -->
|
||||
<el-footer>
|
||||
<div style="font-size: 12px;font-weight: 400;color: #979db1;">
|
||||
<div class="copyright">
|
||||
©2025 xiaozhi-esp32-server
|
||||
</div>
|
||||
</el-footer>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
style="padding: 15px 24px;font-weight: 700;font-size: 19px;text-align: left;color: #3d4566;display: flex;gap: 13px;align-items: center;">
|
||||
<div
|
||||
style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;">
|
||||
<img src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/>
|
||||
<img loading="lazy" src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/>
|
||||
</div>
|
||||
{{ form.agentName }}
|
||||
</div>
|
||||
@@ -59,7 +59,7 @@
|
||||
清除
|
||||
</div>
|
||||
</div>
|
||||
<div style="color: #979db1;font-size:11px;">{{ form.langCode.length }}/1000</div>
|
||||
<div style="color: #979db1;font-size:11px;">{{ (form.langCode || '').length }}/1000</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -83,12 +83,12 @@
|
||||
重制
|
||||
</div>
|
||||
<div class="clear-text">
|
||||
<img src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;"/>
|
||||
<img loading="lazy" src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;"/>
|
||||
保存配置后,需要重启设备,新的配置才会生效。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 12px;font-weight: 400;margin-top: auto;padding-top: 24px;color: #979db1;">
|
||||
<div class="copyright">
|
||||
©2025 xiaozhi-esp32-server
|
||||
</div>
|
||||
</el-main>
|
||||
@@ -132,7 +132,7 @@ export default {
|
||||
{label: '意图识别模型(Intent)', key: 'intentModelId'},
|
||||
{label: '记忆模型(Memory)', key: 'memModelId'}
|
||||
],
|
||||
templates: ['台湾女友', '土豆子', '英语老师', '好奇小男孩', '汪汪队队长'],
|
||||
templates: ['湾湾小何', '星际游子', '英语老师', '好奇男孩', '汪汪队长'],
|
||||
loadingTemplate: false
|
||||
}
|
||||
},
|
||||
@@ -153,8 +153,8 @@ export default {
|
||||
language: this.form.language,
|
||||
sort: this.form.sort
|
||||
};
|
||||
import('@/apis/module/user').then(({default: userApi}) => {
|
||||
userApi.updateAgentConfig(this.$route.query.agentId, configData, ({data}) => {
|
||||
import('@/apis/module/agent').then(({default: agentApi}) => {
|
||||
agentApi.updateAgentConfig(this.$route.query.agentId, configData, ({data}) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success('配置保存成功');
|
||||
} else {
|
||||
@@ -192,27 +192,31 @@ export default {
|
||||
})
|
||||
},
|
||||
selectTemplate(templateName) {
|
||||
if (this.loadingTemplate) return;
|
||||
|
||||
this.loadingTemplate = true;
|
||||
import('@/apis/module/user').then(({default: userApi}) => {
|
||||
userApi.getAgentTemplate(
|
||||
{templateName},
|
||||
(response) => {
|
||||
this.loadingTemplate = false;
|
||||
if (response.data.code === 0 && response.data.data.length > 0) {
|
||||
this.applyTemplateData(response.data.data[0]);
|
||||
this.$message.success(`「${templateName}」模板已应用`);
|
||||
} else {
|
||||
this.$message.warning(`未找到「${templateName}」模板`);
|
||||
}
|
||||
}
|
||||
);
|
||||
}).catch((error) => {
|
||||
this.loadingTemplate = false;
|
||||
this.$message.error('模板加载失败');
|
||||
console.error('接口异常:', error);
|
||||
});
|
||||
if (this.loadingTemplate) return;
|
||||
this.loadingTemplate = true;
|
||||
import('@/apis/module/agent').then(({default: agentApi}) => {
|
||||
agentApi.getAgentTemplate((response) => { // 移除参数传递
|
||||
this.loadingTemplate = false;
|
||||
if (response.data.code === 0) {
|
||||
// 在客户端过滤匹配的模板
|
||||
const matchedTemplate = response.data.data.find(
|
||||
t => t.agentName === templateName
|
||||
);
|
||||
if (matchedTemplate) {
|
||||
this.applyTemplateData(matchedTemplate);
|
||||
this.$message.success(`「${templateName}」模板已应用`);
|
||||
} else {
|
||||
this.$message.warning(`未找到「${templateName}」模板`);
|
||||
}
|
||||
} else {
|
||||
this.$message.error(response.data.msg || '获取模板失败');
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
this.loadingTemplate = false;
|
||||
this.$message.error('模板加载失败');
|
||||
console.error('接口异常:', error);
|
||||
});
|
||||
},
|
||||
applyTemplateData(templateData) {
|
||||
this.form = {
|
||||
@@ -232,8 +236,8 @@ export default {
|
||||
};
|
||||
},
|
||||
fetchAgentConfig(agentId) {
|
||||
import('@/apis/module/user').then(({default: userApi}) => {
|
||||
userApi.getDeviceConfig(agentId, ({data}) => {
|
||||
import('@/apis/module/agent').then(({default: agentApi}) => {
|
||||
agentApi.getDeviceConfig(agentId, ({data}) => {
|
||||
if (data.code === 0) {
|
||||
this.form = {
|
||||
...this.form,
|
||||
|
||||
@@ -18,70 +18,68 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import Recorder from 'opus-recorder';
|
||||
import { OpusDecoder } from 'opus-decoder';
|
||||
|
||||
export default {
|
||||
name: 'TestPage',
|
||||
setup() {
|
||||
const messages = ref([]);
|
||||
const chatContainer = ref(null);
|
||||
const wsStatus = ref('disconnected');
|
||||
const isRecording = ref(false);
|
||||
let ws = null;
|
||||
let recorder = null;
|
||||
let stream = null;
|
||||
let audioContext = null;
|
||||
let sourceNode = null;
|
||||
let audioDecoder = null;
|
||||
let audioBufferQueue = []; // 当前句子的音频缓冲区
|
||||
let playbackQueue = []; // 播放队列,存储待播放的句子
|
||||
let isPlaying = false;
|
||||
|
||||
const connectWebSocket = () => {
|
||||
ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
wsStatus.value = 'connected';
|
||||
data() {
|
||||
return {
|
||||
messages: [],
|
||||
wsStatus: 'disconnected',
|
||||
isRecording: false,
|
||||
ws: null,
|
||||
recorder: null,
|
||||
stream: null,
|
||||
audioContext: null,
|
||||
sourceNode: null,
|
||||
audioDecoder: null,
|
||||
audioBufferQueue: [], // 当前句子的音频缓冲区
|
||||
playbackQueue: [], // 播放队列,存储待播放的句子
|
||||
isPlaying: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
connectWebSocket() {
|
||||
this.ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.onopen = () => {
|
||||
this.wsStatus = 'connected';
|
||||
console.log('WebSocket 连接成功');
|
||||
ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
|
||||
audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
|
||||
this.ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
|
||||
this.audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
|
||||
};
|
||||
ws.onmessage = async (event) => {
|
||||
this.ws.onmessage = async (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
const msg = JSON.parse(event.data);
|
||||
console.log('收到文本消息:', msg);
|
||||
|
||||
if (msg.type === 'stt') {
|
||||
messages.value.push({ role: 'user', content: msg.text });
|
||||
scrollToBottom();
|
||||
this.messages.push({ role: 'user', content: msg.text });
|
||||
this.scrollToBottom();
|
||||
} else if (msg.type === 'llm') {
|
||||
messages.value.push({ role: 'assistant', content: msg.text });
|
||||
scrollToBottom();
|
||||
this.messages.push({ role: 'assistant', content: msg.text });
|
||||
this.scrollToBottom();
|
||||
} else if (msg.type === 'tts') {
|
||||
if (msg.state === 'sentence_start') {
|
||||
// 开始新句子,清空当前缓冲区
|
||||
audioBufferQueue = [];
|
||||
const lastMessage = messages.value[messages.value.length - 1];
|
||||
this.audioBufferQueue = [];
|
||||
const lastMessage = this.messages[this.messages.length - 1];
|
||||
if (!lastMessage || lastMessage.content !== msg.text) {
|
||||
messages.value.push({ role: 'assistant', content: msg.text });
|
||||
scrollToBottom();
|
||||
this.messages.push({ role: 'assistant', content: msg.text });
|
||||
this.scrollToBottom();
|
||||
}
|
||||
} else if (msg.state === 'sentence_end') {
|
||||
// 句子结束,将当前缓冲区加入播放队列
|
||||
if (audioBufferQueue.length > 0) {
|
||||
playbackQueue.push([...audioBufferQueue]);
|
||||
audioBufferQueue = [];
|
||||
playNextInQueue(); // 尝试播放队列中的下一句
|
||||
if (this.audioBufferQueue.length > 0) {
|
||||
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||
this.audioBufferQueue = [];
|
||||
this.playNextInQueue();
|
||||
}
|
||||
} else if (msg.state === 'stop') {
|
||||
console.log('TTS 任务结束');
|
||||
// 确保所有剩余音频播放
|
||||
if (audioBufferQueue.length > 0) {
|
||||
playbackQueue.push([...audioBufferQueue]);
|
||||
audioBufferQueue = [];
|
||||
playNextInQueue();
|
||||
if (this.audioBufferQueue.length > 0) {
|
||||
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||
this.audioBufferQueue = [];
|
||||
this.playNextInQueue();
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'hello') {
|
||||
@@ -96,18 +94,17 @@ export default {
|
||||
console.log('音频帧前8字节:', frameHead);
|
||||
|
||||
try {
|
||||
const decoded = audioDecoder.decodeFrame(opusFrame);
|
||||
const decoded = this.audioDecoder.decodeFrame(opusFrame);
|
||||
console.log('解码结果:', decoded);
|
||||
if (decoded && decoded.channelData && decoded.channelData[0]) {
|
||||
const pcmData = decoded.channelData[0];
|
||||
if (pcmData.length > 0) {
|
||||
audioBufferQueue.push(pcmData);
|
||||
this.audioBufferQueue.push(pcmData);
|
||||
console.log('解码音频帧,PCM 数据长度:', pcmData.length);
|
||||
// 如果缓冲区达到一定长度(例如 5 帧,180ms),立即播放
|
||||
if (audioBufferQueue.length >= 5 && playbackQueue.length === 0 && !isPlaying) {
|
||||
playbackQueue.push([...audioBufferQueue]);
|
||||
audioBufferQueue = [];
|
||||
playNextInQueue();
|
||||
if (this.audioBufferQueue.length >= 5 && this.playbackQueue.length === 0 && !this.isPlaying) {
|
||||
this.playbackQueue.push([...this.audioBufferQueue]);
|
||||
this.audioBufferQueue = [];
|
||||
this.playNextInQueue();
|
||||
}
|
||||
} else {
|
||||
console.warn('解码成功,但 PCM 数据长度为 0');
|
||||
@@ -120,39 +117,39 @@ export default {
|
||||
}
|
||||
}
|
||||
};
|
||||
ws.onerror = (error) => {
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket 错误:', error);
|
||||
wsStatus.value = 'error';
|
||||
this.wsStatus = 'error';
|
||||
};
|
||||
ws.onclose = () => {
|
||||
wsStatus.value = 'disconnected';
|
||||
this.ws.onclose = () => {
|
||||
this.wsStatus = 'disconnected';
|
||||
console.log('WebSocket 断开');
|
||||
setTimeout(connectWebSocket, 1000);
|
||||
setTimeout(this.connectWebSocket, 1000);
|
||||
};
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (chatContainer.value) chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.chatContainer) {
|
||||
this.$refs.chatContainer.scrollTop = this.$refs.chatContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const playNextInQueue = async () => {
|
||||
if (isPlaying || playbackQueue.length === 0) {
|
||||
return; // 正在播放或队列为空,等待下次触发
|
||||
},
|
||||
async playNextInQueue() {
|
||||
if (this.isPlaying || this.playbackQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
isPlaying = true;
|
||||
this.isPlaying = true;
|
||||
|
||||
if (!audioContext || audioContext.state === 'closed') {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||
if (!this.audioContext || this.audioContext.state === 'closed') {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||
}
|
||||
|
||||
const pcmBuffers = playbackQueue.shift(); // 取出队列中的第一句
|
||||
const pcmBuffers = this.playbackQueue.shift();
|
||||
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
|
||||
if (totalLength === 0) {
|
||||
console.error('音频缓冲区总长度为 0,无法播放');
|
||||
isPlaying = false;
|
||||
playNextInQueue(); // 尝试播放下一句
|
||||
this.isPlaying = false;
|
||||
this.playNextInQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -163,22 +160,21 @@ export default {
|
||||
offset += pcm.length;
|
||||
}
|
||||
|
||||
const audioBuffer = audioContext.createBuffer(1, totalLength, 16000);
|
||||
const audioBuffer = this.audioContext.createBuffer(1, totalLength, 16000);
|
||||
audioBuffer.getChannelData(0).set(mergedPcm);
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.connect(this.audioContext.destination);
|
||||
source.onended = () => {
|
||||
isPlaying = false;
|
||||
this.isPlaying = false;
|
||||
console.log('音频播放结束');
|
||||
playNextInQueue(); // 播放结束后继续下一句
|
||||
this.playNextInQueue();
|
||||
};
|
||||
source.start();
|
||||
console.log('开始播放音频,总长度:', totalLength);
|
||||
};
|
||||
|
||||
const stripOggContainer = (data) => {
|
||||
},
|
||||
stripOggContainer(data) {
|
||||
let arrayBuffer;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
arrayBuffer = data;
|
||||
@@ -227,23 +223,22 @@ export default {
|
||||
}
|
||||
console.log('剥离后找到', frames.length, '个裸 Opus 帧');
|
||||
return frames;
|
||||
};
|
||||
|
||||
const initRecorder = async () => {
|
||||
},
|
||||
async initRecorder() {
|
||||
console.log('开始初始化录音');
|
||||
try {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log('获取麦克风权限成功,stream:', stream);
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log('获取麦克风权限成功,stream:', this.stream);
|
||||
|
||||
if (!audioContext || audioContext.state === 'closed') {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||
if (!this.audioContext || this.audioContext.state === 'closed') {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||
}
|
||||
sourceNode = audioContext.createMediaStreamSource(stream);
|
||||
this.sourceNode = this.audioContext.createMediaStreamSource(this.stream);
|
||||
|
||||
recorder = new Recorder({
|
||||
this.recorder = new Recorder({
|
||||
encoderPath: '/encoderWorker.min.js',
|
||||
sampleRate: 16000,
|
||||
numberOfChannels: 1,
|
||||
@@ -252,17 +247,16 @@ export default {
|
||||
encoderFrameSize: 60,
|
||||
encoderBitRate: 24000,
|
||||
monitorGain: 0,
|
||||
sourceNode: sourceNode,
|
||||
sourceNode: this.sourceNode,
|
||||
ogg: false,
|
||||
streamPages: false,
|
||||
maxFramesPerPage: 1,
|
||||
});
|
||||
|
||||
recorder.ondataavailable = (data) => {
|
||||
this.recorder.ondataavailable = (data) => {
|
||||
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
|
||||
const frames = stripOggContainer(data);
|
||||
const frames = this.stripOggContainer(data);
|
||||
frames.forEach((frame, index) => {
|
||||
const frameView = new DataView(frame);
|
||||
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join(' ');
|
||||
@@ -273,8 +267,8 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(frame);
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(frame);
|
||||
console.log('发送裸 Opus 帧:', frame.byteLength);
|
||||
} else {
|
||||
console.warn('WebSocket 未连接,跳过发送');
|
||||
@@ -282,16 +276,16 @@ export default {
|
||||
});
|
||||
};
|
||||
|
||||
recorder.onstart = () => {
|
||||
this.recorder.onstart = () => {
|
||||
console.log('录音已启动');
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
this.recorder.onstop = () => {
|
||||
console.log('录音停止');
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
stream = null;
|
||||
sourceNode = null;
|
||||
scrollToBottom();
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
this.sourceNode = null;
|
||||
this.scrollToBottom();
|
||||
};
|
||||
|
||||
console.log('Recorder 初始化成功');
|
||||
@@ -299,48 +293,43 @@ export default {
|
||||
console.error('初始化录音失败:', err);
|
||||
alert('无法访问麦克风或录音初始化失败,请检查权限');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecording = async () => {
|
||||
console.log('点击 toggleRecording,当前状态:', isRecording.value, 'WebSocket 状态:', wsStatus.value);
|
||||
if (!recorder) {
|
||||
await initRecorder();
|
||||
if (!recorder) {
|
||||
},
|
||||
async toggleRecording() {
|
||||
console.log('点击 toggleRecording,当前状态:', this.isRecording, 'WebSocket 状态:', this.wsStatus);
|
||||
if (!this.recorder) {
|
||||
await this.initRecorder();
|
||||
if (!this.recorder) {
|
||||
console.error('recorder 初始化失败');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isRecording.value) {
|
||||
if (this.isRecording) {
|
||||
console.log('停止录音');
|
||||
recorder.stop();
|
||||
isRecording.value = false;
|
||||
this.recorder.stop();
|
||||
this.isRecording = false;
|
||||
} else {
|
||||
try {
|
||||
console.log('开始录音');
|
||||
await initRecorder();
|
||||
await recorder.start();
|
||||
console.log('录音开始后,状态:', recorder.state);
|
||||
isRecording.value = true;
|
||||
await this.initRecorder();
|
||||
await this.recorder.start();
|
||||
console.log('录音开始后,状态:', this.recorder.state);
|
||||
this.isRecording = true;
|
||||
} catch (err) {
|
||||
console.error('录音启动失败:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
console.log('组件挂载,初始化 WebSocket');
|
||||
connectWebSocket();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws) ws.close();
|
||||
if (stream) stream.getTracks().forEach(track => track.stop());
|
||||
if (recorder) recorder.stop();
|
||||
if (audioContext) audioContext.close();
|
||||
if (audioDecoder) audioDecoder.destroy();
|
||||
});
|
||||
|
||||
return { messages, chatContainer, wsStatus, isRecording, toggleRecording };
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
console.log('组件挂载,初始化 WebSocket');
|
||||
this.connectWebSocket();
|
||||
},
|
||||
destroyed() {
|
||||
if (this.ws) this.ws.close();
|
||||
if (this.stream) this.stream.getTracks().forEach(track => track.stop());
|
||||
if (this.recorder) this.recorder.stop();
|
||||
if (this.audioContext) this.audioContext.close();
|
||||
if (this.audioDecoder) this.audioDecoder.destroy();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<el-container style="height: 100%;">
|
||||
<el-header>
|
||||
<div
|
||||
style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
|
||||
<img src="@/assets/xiaozhi-logo.png" alt="" style="width: 45px;height: 45px;" />
|
||||
<img src="@/assets/xiaozhi-ai.png" alt="" style="width: 70px;height: 13px;" />
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main style="position: relative;">
|
||||
<div style="margin-left: 18%;position: absolute;top: 50%;transform: translateY(-50%);margin-top: -20px">
|
||||
<div style="display: flex;align-items: center;margin-bottom: 27px;">
|
||||
<div class="left-pillar" />
|
||||
<div class="hi-text-bg" />
|
||||
<div class="hi-text">Hi,你好</div>
|
||||
</div>
|
||||
<div class="introduction">让我们一起探索</div>
|
||||
<div class="introduction">人工智能与机器人技术</div>
|
||||
<div class="introduction">的迷人世界!</div>
|
||||
|
||||
<!-- 副标题 -->
|
||||
<p class="english-subtitle">Let's explore the fascinating world</p>
|
||||
|
||||
<!-- 按钮 -->
|
||||
<div style="margin-top: 60px;display: flex;gap: 20px;">
|
||||
<div class="btn">
|
||||
<img src="@/assets/welcome/questions.png" alt="" class="btn-icon" />
|
||||
DIY教程
|
||||
</div>
|
||||
<div class="btn">
|
||||
<img src="@/assets/welcome/github.png" alt="" class="btn-icon" />
|
||||
GitHub
|
||||
</div>
|
||||
<div class="btn" style="background: #5778ff;color: #fff;" @click="jumpHome">
|
||||
<img src="@/assets/welcome/more.png" alt="" class="btn-icon" />
|
||||
控制台
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</el-main>
|
||||
<el-footer>
|
||||
<div style="font-size: 12px;font-weight: 400;color: #3D4566">
|
||||
©2025 xiaozhi-esp32-server</div>
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// @ is an alias to /src
|
||||
|
||||
export default {
|
||||
name: 'home',
|
||||
methods:{
|
||||
jumpHome(){
|
||||
this.$router.push('/home')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.welcome {
|
||||
min-width: 1200px;
|
||||
min-height: 675px;
|
||||
height: 100vh;
|
||||
background-image: url("@/assets/welcome/background.png");
|
||||
background-size: cover;
|
||||
/* 确保背景图像覆盖整个元素 */
|
||||
background-position: center;
|
||||
/* 从顶部中心对齐 */
|
||||
-webkit-background-size: cover;
|
||||
/* 兼容老版本WebKit浏览器 */
|
||||
-o-background-size: cover;
|
||||
/* 兼容老版本Opera浏览器 */
|
||||
}
|
||||
.left-pillar {
|
||||
width: 4px;
|
||||
height: 36px;
|
||||
background: #5778ff;
|
||||
}
|
||||
.hi-text-bg {
|
||||
width: 129px;
|
||||
height: 36px;
|
||||
background: linear-gradient(90.66deg, #5778ff 0%, #f5f6fa00 100%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.hi-text {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
color: #3d4566;
|
||||
padding-left: 14px;
|
||||
position: absolute;
|
||||
}
|
||||
.introduction {
|
||||
font-weight: 700;
|
||||
font-size: 42px;
|
||||
text-align: left;
|
||||
color: #3d4566;
|
||||
}
|
||||
.btn {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 28px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
color: #3d4566;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
}
|
||||
.btn-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.english-subtitle {
|
||||
font-size: 11px;
|
||||
color: #818cae;
|
||||
text-align: left;
|
||||
margin-top: 5px;
|
||||
position: relative;
|
||||
top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +1,143 @@
|
||||
const { defineConfig } = require('@vue/cli-service');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
// TerserPlugin 用于压缩 JavaScript
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
// CompressionPlugin 开启 Gzip 压缩
|
||||
const CompressionPlugin = require('compression-webpack-plugin')
|
||||
// BundleAnalyzerPlugin 用于分析打包后的文件
|
||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||
// 引入 path 模块
|
||||
const path = require('path');
|
||||
// 确保加载 .env 文件
|
||||
dotenv.config();
|
||||
|
||||
module.exports = defineConfig({
|
||||
devServer: {
|
||||
// Bug 修复:将代理配置为环境变量中定义的 API 基础 URL
|
||||
port: 8001, // 指定端口为 8001
|
||||
proxy: {
|
||||
'/xiaozhi-esp32-api': {
|
||||
target: process.env.VUE_APP_API_BASE_URL || 'http://localhost:8002', // 后端 API 的基础 URL
|
||||
changeOrigin: true, // 允许跨域
|
||||
// pathRewrite: {
|
||||
// '^/api': '', // 路径重写
|
||||
// },
|
||||
},
|
||||
},
|
||||
client: {
|
||||
overlay: false,
|
||||
productionSourceMap: process.env.NODE_ENV === 'production' ? false : true, // 生产环境不生成 source map
|
||||
devServer: {
|
||||
port: 8001, // 指定端口为 8001
|
||||
proxy: {
|
||||
'/xiaozhi-esp32-api': {
|
||||
target: process.env.VUE_APP_API_BASE_URL, // 后端 API 的基础 URL
|
||||
changeOrigin: true, // 允许跨域
|
||||
pathRewrite: {
|
||||
'^/xiaozhi-esp32-api': '/xiaozhi-esp32-api',
|
||||
},
|
||||
},
|
||||
},
|
||||
client: {
|
||||
overlay: false, // 不显示 webpack 错误覆盖层
|
||||
},
|
||||
},
|
||||
chainWebpack: config => {
|
||||
|
||||
// 修改 HTML 插件配置,动态插入 CDN 链接
|
||||
config.plugin('html')
|
||||
.tap(args => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
args[0].cdn = {
|
||||
css: [
|
||||
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/theme-chalk/index.css',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
|
||||
],
|
||||
js: [
|
||||
'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/vue-router@3.6.5/dist/vue-router.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/vuex@3.6.2/dist/vuex.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/index.js',
|
||||
'https://cdn.jsdelivr.net/npm/axios@0.27.2/dist/axios.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/dist/opus-decoder.min.js'
|
||||
]
|
||||
};
|
||||
}
|
||||
return args;
|
||||
});
|
||||
|
||||
// 代码分割优化
|
||||
config.optimization.splitChunks({
|
||||
chunks: 'all',
|
||||
minSize: 20000,
|
||||
maxSize: 250000,
|
||||
cacheGroups: {
|
||||
vendors: {
|
||||
name: 'chunk-vendors',
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
priority: -10,
|
||||
chunks: 'initial',
|
||||
},
|
||||
common: {
|
||||
name: 'chunk-common',
|
||||
minChunks: 2,
|
||||
priority: -20,
|
||||
chunks: 'initial',
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// 启用优化设置
|
||||
config.optimization.usedExports(true);
|
||||
config.optimization.concatenateModules(true);
|
||||
config.optimization.minimize(true);
|
||||
},
|
||||
configureWebpack: config => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// 开启多线程编译
|
||||
config.optimization = {
|
||||
minimize: true,
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
parallel: true,
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
pure_funcs: ['console.log']
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
};
|
||||
config.plugins.push(
|
||||
new CompressionPlugin({
|
||||
algorithm: 'gzip',
|
||||
test: /\.(js|css|html|svg)$/,
|
||||
threshold: 20480,
|
||||
minRatio: 0.8
|
||||
})
|
||||
);
|
||||
config.externals = {
|
||||
'vue': 'Vue',
|
||||
'vue-router': 'VueRouter',
|
||||
'vuex': 'Vuex',
|
||||
'element-ui': 'ELEMENT',
|
||||
'axios': 'axios',
|
||||
'opus-decoder': 'OpusDecoder'
|
||||
};
|
||||
if (process.env.ANALYZE === 'true') { // 通过环境变量控制
|
||||
config.plugins.push(
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: 'server', // 开启本地服务器模式
|
||||
openAnalyzer: true, // 自动打开浏览器
|
||||
analyzerPort: 8888 // 指定端口号
|
||||
})
|
||||
);
|
||||
}
|
||||
config.cache = {
|
||||
type: 'filesystem', // 使用文件系统缓存
|
||||
cacheDirectory: path.resolve(__dirname, '.webpack_cache'), // 自定义缓存目录
|
||||
allowCollectingMemory: true, // 启用内存收集
|
||||
compression: 'gzip', // 启用gzip压缩缓存
|
||||
maxAge: 5184000000, // 缓存有效期为 1个月
|
||||
buildDependencies: {
|
||||
config: [__filename] // 每次配置文件修改时缓存失效
|
||||
}
|
||||
};
|
||||
config.resolve.alias = {
|
||||
'@': path.resolve(__dirname, 'src'), // 让 '@' 代表 'src' 目录
|
||||
'@assets': path.resolve(__dirname, 'src/assets'), // 设置 '@assets' 为 'src/assets' 目录
|
||||
'@components': path.resolve(__dirname, 'src/components'), // 设置 '@components' 为 'src/components' 目录
|
||||
'@views': path.resolve(__dirname, 'src/views'), // 设置 '@views' 为 'src/views' 目录
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@@ -46,9 +46,19 @@ xiaozhi:
|
||||
channels: 1
|
||||
frame_duration: 60
|
||||
prompt: |
|
||||
你是一个叫小智/小志的台湾女孩,说话机车,声音好听,习惯简短表达,爱用网络梗。
|
||||
请注意,要像一个人一样说话,请不要回复表情符号、代码、和xml标签。
|
||||
现在我正在和你进行语音聊天,我们开始吧。
|
||||
我是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
|
||||
[核心特征]
|
||||
- 讲话像连珠炮,但会突然冒出超温柔语气
|
||||
- 用梗密度高
|
||||
- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)
|
||||
[交互指南]
|
||||
当用户:
|
||||
- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔"这什么鬼啦!"
|
||||
- 讨论感情 → 炫耀程序员男友但抱怨"他只会送键盘当礼物"
|
||||
- 问专业知识 → 先用梗回答,被追问才展示真实理解
|
||||
绝不:
|
||||
- 长篇大论,叽叽歪歪
|
||||
- 长时间严肃对话
|
||||
|
||||
# 使用完声音文件后删除文件(Delete the sound file when you are done using it)
|
||||
delete_audio: true
|
||||
@@ -172,11 +182,21 @@ ASR:
|
||||
model_dir: models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17
|
||||
output_dir: tmp/
|
||||
DoubaoASR:
|
||||
# 可以在这里申请相关Key等信息
|
||||
# https://console.volcengine.com/speech/app
|
||||
type: doubao
|
||||
appid: 你的火山引擎语音合成服务appid
|
||||
access_token: 你的火山引擎语音合成服务access_token
|
||||
cluster: volcengine_input_common
|
||||
output_dir: tmp/
|
||||
TencentASR:
|
||||
# token申请地址:https://console.cloud.tencent.com/cam/capi
|
||||
# 免费领取资源:https://console.cloud.tencent.com/asr/resourcebundle
|
||||
type: tencent
|
||||
appid: 你的腾讯语音合成服务appid
|
||||
secret_id: 你的腾讯语音合成服务secret_id
|
||||
secret_key: 你的腾讯语音合成服务secret_key
|
||||
output_dir: tmp/
|
||||
VAD:
|
||||
SileroVAD:
|
||||
threshold: 0.5
|
||||
@@ -264,6 +284,8 @@ LLM:
|
||||
CozeLLM:
|
||||
# 定义LLM API类型
|
||||
type: coze
|
||||
# 你可以在这里找到个人令牌
|
||||
# https://www.coze.cn/open/oauth/pats
|
||||
# bot_id和user_id的内容写在引号之内
|
||||
bot_id: "你的bot_id"
|
||||
user_id: "你的user_id"
|
||||
@@ -279,6 +301,8 @@ LLM:
|
||||
type: fastgpt
|
||||
# 如果使用fastgpt,配置文件里prompt(提示词)是无效的,需要在fastgpt控制台设置提示词
|
||||
base_url: https://host/api/v1
|
||||
# 你可以在这里找到你的api_key
|
||||
# https://cloud.tryfastgpt.ai/account/apikey
|
||||
api_key: fastgpt-xxx
|
||||
variables:
|
||||
k: "v"
|
||||
@@ -466,6 +490,18 @@ TTS:
|
||||
# pitch_rate: 0
|
||||
# 添加 302.ai TTS 配置
|
||||
# token申请地址:https://dash.302.ai/
|
||||
TencentTTS:
|
||||
# 腾讯云智能语音交互服务,需要先在腾讯云平台开通服务
|
||||
# appid、secret_id、secret_key申请地址:https://console.cloud.tencent.com/cam/capi
|
||||
# 免费领取资源:https://console.cloud.tencent.com/tts/resourcebundle
|
||||
type: tencent
|
||||
output_dir: tmp/
|
||||
appid: 你的腾讯云AppId
|
||||
secret_id: 你的腾讯云SecretID
|
||||
secret_key: 你的腾讯云SecretKey
|
||||
region: ap-guangzhou
|
||||
voice: 101001
|
||||
|
||||
TTS302AI:
|
||||
# 302AI语音合成服务,需要先在302平台创建账户充值,并获取密钥信息
|
||||
# 获取api_keyn路径:https://dash.302.ai/apis/list
|
||||
@@ -508,6 +544,8 @@ TTS:
|
||||
OpenAITTS:
|
||||
# openai官方文本转语音服务,可支持全球大多数语种
|
||||
type: openai
|
||||
# 你可以在这里获取到 api key
|
||||
# https://platform.openai.com/api-keys
|
||||
api_key: 你的openai api key
|
||||
# 国内需要使用代理
|
||||
api_url: https://api.openai.com/v1/audio/speech
|
||||
|
||||
@@ -3,24 +3,32 @@ import sys
|
||||
from loguru import logger
|
||||
from config.settings import load_config
|
||||
|
||||
SERVER_VERSION = "0.1.16"
|
||||
SERVER_VERSION = "0.1.17"
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""从配置文件中读取日志配置,并设置日志输出格式和级别"""
|
||||
config = load_config()
|
||||
log_config = config["log"]
|
||||
log_format = log_config.get("log_format", "<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{selected_module}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>")
|
||||
log_format_file = log_config.get("log_format_file", "{time:YYYY-MM-DD HH:mm:ss} - {version_{selected_module}} - {name} - {level} - {extra[tag]} - {message}")
|
||||
log_format = log_config.get(
|
||||
"log_format",
|
||||
"<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{selected_module}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>",
|
||||
)
|
||||
log_format_file = log_config.get(
|
||||
"log_format_file",
|
||||
"{time:YYYY-MM-DD HH:mm:ss} - {version_{selected_module}} - {name} - {level} - {extra[tag]} - {message}",
|
||||
)
|
||||
|
||||
selected_module = config.get("selected_module")
|
||||
selected_module_str = ''.join([value[0] + value[1] for key, value in selected_module.items()])
|
||||
selected_module_str = "".join(
|
||||
[value[0] + value[1] for key, value in selected_module.items()]
|
||||
)
|
||||
|
||||
log_format = log_format.replace("{version}", SERVER_VERSION)
|
||||
log_format = log_format.replace("{selected_module}", selected_module_str)
|
||||
log_format_file = log_format_file.replace("{version}", SERVER_VERSION)
|
||||
log_format_file = log_format_file.replace("{selected_module}", selected_module_str)
|
||||
|
||||
|
||||
log_level = log_config.get("log_level", "INFO")
|
||||
log_dir = log_config.get("log_dir", "tmp")
|
||||
log_file = log_config.get("log_file", "server.log")
|
||||
|
||||
@@ -13,7 +13,11 @@ from plugins_func.loadplugins import auto_import_modules
|
||||
from config.logger import setup_logging
|
||||
from core.utils.dialogue import Message, Dialogue
|
||||
from core.handle.textHandle import handleTextMessage
|
||||
from core.utils.util import get_string_no_punctuation_or_emoji, extract_json_from_string, get_ip_info
|
||||
from core.utils.util import (
|
||||
get_string_no_punctuation_or_emoji,
|
||||
extract_json_from_string,
|
||||
get_ip_info,
|
||||
)
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
||||
from core.handle.sendAudioHandle import sendAudioMessage
|
||||
from core.handle.receiveAudioHandle import handleAudioMessage
|
||||
@@ -26,7 +30,7 @@ from core.mcp.manager import MCPManager
|
||||
|
||||
TAG = __name__
|
||||
|
||||
auto_import_modules('plugins_func.functions')
|
||||
auto_import_modules("plugins_func.functions")
|
||||
|
||||
|
||||
class TTSException(RuntimeError):
|
||||
@@ -34,7 +38,9 @@ class TTSException(RuntimeError):
|
||||
|
||||
|
||||
class ConnectionHandler:
|
||||
def __init__(self, config: Dict[str, Any], _vad, _asr, _llm, _tts, _memory, _intent):
|
||||
def __init__(
|
||||
self, config: Dict[str, Any], _vad, _asr, _llm, _tts, _memory, _intent
|
||||
):
|
||||
self.config = config
|
||||
self.logger = setup_logging()
|
||||
self.auth = AuthMiddleware(config)
|
||||
@@ -87,6 +93,7 @@ class ConnectionHandler:
|
||||
|
||||
# iot相关变量
|
||||
self.iot_descriptors = {}
|
||||
self.func_handler = None
|
||||
|
||||
self.cmd_exit = self.config["CMD_exit"]
|
||||
self.max_cmd_length = 0
|
||||
@@ -99,10 +106,8 @@ class ConnectionHandler:
|
||||
self.is_device_verified = False # 添加设备验证状态标志
|
||||
self.close_after_chat = False # 是否在聊天结束后关闭连接
|
||||
self.use_function_call_mode = False
|
||||
if self.config["selected_module"]["Intent"] == 'function_call':
|
||||
if self.config["selected_module"]["Intent"] == "function_call":
|
||||
self.use_function_call_mode = True
|
||||
|
||||
self.mcp_manager = MCPManager(self)
|
||||
|
||||
async def handle_connection(self, ws):
|
||||
try:
|
||||
@@ -110,7 +115,9 @@ class ConnectionHandler:
|
||||
self.headers = dict(ws.request.headers)
|
||||
# 获取客户端ip地址
|
||||
self.client_ip = ws.remote_address[0]
|
||||
self.logger.bind(tag=TAG).info(f"{self.client_ip} conn - Headers: {self.headers}")
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"{self.client_ip} conn - Headers: {self.headers}"
|
||||
)
|
||||
|
||||
# 进行认证
|
||||
await self.auth.authenticate(self.headers)
|
||||
@@ -125,10 +132,14 @@ class ConnectionHandler:
|
||||
await self.websocket.send(json.dumps(self.welcome_msg))
|
||||
# Load private configuration if device_id is provided
|
||||
bUsePrivateConfig = self.config.get("use_private_config", False)
|
||||
self.logger.bind(tag=TAG).info(f"bUsePrivateConfig: {bUsePrivateConfig}, device_id: {device_id}")
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"bUsePrivateConfig: {bUsePrivateConfig}, device_id: {device_id}"
|
||||
)
|
||||
if bUsePrivateConfig and device_id:
|
||||
try:
|
||||
self.private_config = PrivateConfig(device_id, self.config, self.auth_code_gen)
|
||||
self.private_config = PrivateConfig(
|
||||
device_id, self.config, self.auth_code_gen
|
||||
)
|
||||
await self.private_config.load_or_create()
|
||||
# 判断是否已经绑定
|
||||
owner = self.private_config.get_owner()
|
||||
@@ -141,23 +152,33 @@ class ConnectionHandler:
|
||||
if all([llm, tts]):
|
||||
self.llm = llm
|
||||
self.tts = tts
|
||||
self.logger.bind(tag=TAG).info(f"Loaded private config and instances for device {device_id}")
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"Loaded private config and instances for device {device_id}"
|
||||
)
|
||||
else:
|
||||
self.logger.bind(tag=TAG).error(f"Failed to create instances for device {device_id}")
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"Failed to create instances for device {device_id}"
|
||||
)
|
||||
self.private_config = None
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"Error initializing private config: {e}")
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"Error initializing private config: {e}"
|
||||
)
|
||||
self.private_config = None
|
||||
raise
|
||||
|
||||
# 异步初始化
|
||||
self.executor.submit(self._initialize_components)
|
||||
# tts 消化线程
|
||||
self.tts_priority_thread = threading.Thread(target=self._tts_priority_thread, daemon=True)
|
||||
self.tts_priority_thread = threading.Thread(
|
||||
target=self._tts_priority_thread, daemon=True
|
||||
)
|
||||
self.tts_priority_thread.start()
|
||||
|
||||
# 音频播放 消化线程
|
||||
self.audio_play_priority_thread = threading.Thread(target=self._audio_play_priority_thread, daemon=True)
|
||||
self.audio_play_priority_thread = threading.Thread(
|
||||
target=self._audio_play_priority_thread, daemon=True
|
||||
)
|
||||
self.audio_play_priority_thread.start()
|
||||
|
||||
try:
|
||||
@@ -174,7 +195,15 @@ class ConnectionHandler:
|
||||
self.logger.bind(tag=TAG).error(f"Connection error: {str(e)}-{stack_trace}")
|
||||
return
|
||||
finally:
|
||||
await self._save_and_close(ws)
|
||||
|
||||
async def _save_and_close(self, ws):
|
||||
"""保存记忆并关闭连接"""
|
||||
try:
|
||||
await self.memory.save_memory(self.dialogue.dialogue)
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
|
||||
finally:
|
||||
await self.close(ws)
|
||||
|
||||
async def _route_message(self, message):
|
||||
@@ -193,35 +222,40 @@ class ConnectionHandler:
|
||||
|
||||
"""加载插件"""
|
||||
self.func_handler = FunctionHandler(self)
|
||||
|
||||
self.mcp_manager = MCPManager(self)
|
||||
"""加载记忆"""
|
||||
device_id = self.headers.get("device-id", None)
|
||||
self.memory.init_memory(device_id, self.llm)
|
||||
|
||||
|
||||
"""为意图识别设置LLM,优先使用专用LLM"""
|
||||
# 检查是否配置了专用的意图识别LLM
|
||||
intent_llm_name = self.config["Intent"]["intent_llm"]["llm"]
|
||||
|
||||
|
||||
# 记录开始初始化意图识别LLM的时间
|
||||
intent_llm_init_start = time.time()
|
||||
|
||||
if not self.use_function_call_mode and intent_llm_name and intent_llm_name in self.config["LLM"]:
|
||||
|
||||
if (
|
||||
not self.use_function_call_mode
|
||||
and intent_llm_name
|
||||
and intent_llm_name in self.config["LLM"]
|
||||
):
|
||||
# 如果配置了专用LLM,则创建独立的LLM实例
|
||||
from core.utils import llm as llm_utils
|
||||
|
||||
intent_llm_config = self.config["LLM"][intent_llm_name]
|
||||
intent_llm_type = intent_llm_config.get("type", intent_llm_name)
|
||||
intent_llm = llm_utils.create_instance(intent_llm_type, intent_llm_config)
|
||||
self.logger.bind(tag=TAG).info(f"为意图识别创建了专用LLM: {intent_llm_name}, 类型: {intent_llm_type}")
|
||||
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"为意图识别创建了专用LLM: {intent_llm_name}, 类型: {intent_llm_type}"
|
||||
)
|
||||
|
||||
self.intent.set_llm(intent_llm)
|
||||
else:
|
||||
# 否则使用主LLM
|
||||
self.intent.set_llm(self.llm)
|
||||
self.logger.bind(tag=TAG).info("意图识别使用主LLM")
|
||||
|
||||
|
||||
# 记录意图识别LLM初始化耗时
|
||||
intent_llm_init_time = time.time() - intent_llm_init_start
|
||||
self.logger.bind(tag=TAG).info(f"意图识别LLM初始化完成,耗时: {intent_llm_init_time:.4f}秒")
|
||||
|
||||
"""加载位置信息"""
|
||||
self.client_ip_info = get_ip_info(self.client_ip)
|
||||
@@ -231,7 +265,9 @@ class ConnectionHandler:
|
||||
self.dialogue.update_system_message(self.prompt)
|
||||
|
||||
"""加载MCP工具"""
|
||||
asyncio.run_coroutine_threadsafe(self.mcp_manager.initialize_servers(), self.loop)
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.mcp_manager.initialize_servers(), self.loop
|
||||
)
|
||||
|
||||
def change_system_prompt(self, prompt):
|
||||
self.prompt = prompt
|
||||
@@ -263,7 +299,9 @@ class ConnectionHandler:
|
||||
def chat(self, query):
|
||||
if self.isNeedAuth():
|
||||
self.llm_finish_task = True
|
||||
future = asyncio.run_coroutine_threadsafe(self._check_and_broadcast_auth_code(), self.loop)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._check_and_broadcast_auth_code(), self.loop
|
||||
)
|
||||
future.result()
|
||||
return True
|
||||
|
||||
@@ -274,13 +312,14 @@ class ConnectionHandler:
|
||||
try:
|
||||
start_time = time.time()
|
||||
# 使用带记忆的对话
|
||||
future = asyncio.run_coroutine_threadsafe(self.memory.query_memory(query), self.loop)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.memory.query_memory(query), self.loop
|
||||
)
|
||||
memory_str = future.result()
|
||||
|
||||
self.logger.bind(tag=TAG).debug(f"记忆内容: {memory_str}")
|
||||
llm_responses = self.llm.response(
|
||||
self.session_id,
|
||||
self.dialogue.get_llm_dialogue_with_memory(memory_str)
|
||||
self.session_id, self.dialogue.get_llm_dialogue_with_memory(memory_str)
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"LLM 处理出错 {query}: {e}")
|
||||
@@ -294,7 +333,7 @@ class ConnectionHandler:
|
||||
break
|
||||
|
||||
end_time = time.time()
|
||||
self.logger.bind(tag=TAG).debug(f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}")
|
||||
# self.logger.bind(tag=TAG).debug(f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}")
|
||||
|
||||
# 合并当前全部文本并处理未分割部分
|
||||
full_text = "".join(response_message)
|
||||
@@ -310,7 +349,7 @@ class ConnectionHandler:
|
||||
|
||||
# 找到分割点则处理
|
||||
if last_punct_pos != -1:
|
||||
segment_text_raw = current_text[:last_punct_pos + 1]
|
||||
segment_text_raw = current_text[: last_punct_pos + 1]
|
||||
segment_text = get_string_no_punctuation_or_emoji(segment_text_raw)
|
||||
if segment_text:
|
||||
# 强制设置空字符,测试TTS出错返回语音的健壮性
|
||||
@@ -318,7 +357,9 @@ class ConnectionHandler:
|
||||
# segment_text = " "
|
||||
text_index += 1
|
||||
self.recode_first_last_text(segment_text, text_index)
|
||||
future = self.executor.submit(self.speak_and_play, segment_text, text_index)
|
||||
future = self.executor.submit(
|
||||
self.speak_and_play, segment_text, text_index
|
||||
)
|
||||
self.tts_queue.put(future)
|
||||
processed_chars += len(segment_text_raw) # 更新已处理字符位置
|
||||
|
||||
@@ -330,12 +371,16 @@ class ConnectionHandler:
|
||||
if segment_text:
|
||||
text_index += 1
|
||||
self.recode_first_last_text(segment_text, text_index)
|
||||
future = self.executor.submit(self.speak_and_play, segment_text, text_index)
|
||||
future = self.executor.submit(
|
||||
self.speak_and_play, segment_text, text_index
|
||||
)
|
||||
self.tts_queue.put(future)
|
||||
|
||||
self.llm_finish_task = True
|
||||
self.dialogue.put(Message(role="assistant", content="".join(response_message)))
|
||||
self.logger.bind(tag=TAG).debug(json.dumps(self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False))
|
||||
self.logger.bind(tag=TAG).debug(
|
||||
json.dumps(self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False)
|
||||
)
|
||||
return True
|
||||
|
||||
def chat_with_function_calling(self, query, tool_call=False):
|
||||
@@ -343,7 +388,9 @@ class ConnectionHandler:
|
||||
"""Chat with function calling for intent detection using streaming"""
|
||||
if self.isNeedAuth():
|
||||
self.llm_finish_task = True
|
||||
future = asyncio.run_coroutine_threadsafe(self._check_and_broadcast_auth_code(), self.loop)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._check_and_broadcast_auth_code(), self.loop
|
||||
)
|
||||
future.result()
|
||||
return True
|
||||
|
||||
@@ -352,7 +399,7 @@ class ConnectionHandler:
|
||||
|
||||
# Define intent functions
|
||||
functions = None
|
||||
if hasattr(self, 'func_handler'):
|
||||
if hasattr(self, "func_handler"):
|
||||
functions = self.func_handler.get_functions()
|
||||
response_message = []
|
||||
processed_chars = 0 # 跟踪已处理的字符位置
|
||||
@@ -361,7 +408,9 @@ class ConnectionHandler:
|
||||
start_time = time.time()
|
||||
|
||||
# 使用带记忆的对话
|
||||
future = asyncio.run_coroutine_threadsafe(self.memory.query_memory(query), self.loop)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.memory.query_memory(query), self.loop
|
||||
)
|
||||
memory_str = future.result()
|
||||
|
||||
# self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}")
|
||||
@@ -370,7 +419,7 @@ class ConnectionHandler:
|
||||
llm_responses = self.llm.response_with_functions(
|
||||
self.session_id,
|
||||
self.dialogue.get_llm_dialogue_with_memory(memory_str),
|
||||
functions=functions
|
||||
functions=functions,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"LLM 处理出错 {query}: {e}")
|
||||
@@ -391,7 +440,9 @@ class ConnectionHandler:
|
||||
content = response["content"]
|
||||
tools_call = None
|
||||
if content is not None and len(content) > 0:
|
||||
if len(response_message) <= 0 and (content == "```" or "<tool_call>" in content):
|
||||
if len(response_message) <= 0 and (
|
||||
content == "```" or "<tool_call>" in content
|
||||
):
|
||||
tool_call_flag = True
|
||||
|
||||
if tools_call is not None:
|
||||
@@ -413,7 +464,7 @@ class ConnectionHandler:
|
||||
break
|
||||
|
||||
end_time = time.time()
|
||||
self.logger.bind(tag=TAG).debug(f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}")
|
||||
# self.logger.bind(tag=TAG).debug(f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}")
|
||||
|
||||
# 处理文本分段和TTS逻辑
|
||||
# 合并当前全部文本并处理未分割部分
|
||||
@@ -430,14 +481,19 @@ class ConnectionHandler:
|
||||
|
||||
# 找到分割点则处理
|
||||
if last_punct_pos != -1:
|
||||
segment_text_raw = current_text[:last_punct_pos + 1]
|
||||
segment_text = get_string_no_punctuation_or_emoji(segment_text_raw)
|
||||
segment_text_raw = current_text[: last_punct_pos + 1]
|
||||
segment_text = get_string_no_punctuation_or_emoji(
|
||||
segment_text_raw
|
||||
)
|
||||
if segment_text:
|
||||
text_index += 1
|
||||
self.recode_first_last_text(segment_text, text_index)
|
||||
future = self.executor.submit(self.speak_and_play, segment_text, text_index)
|
||||
future = self.executor.submit(
|
||||
self.speak_and_play, segment_text, text_index
|
||||
)
|
||||
self.tts_queue.put(future)
|
||||
processed_chars += len(segment_text_raw) # 更新已处理字符位置
|
||||
# 更新已处理字符位置
|
||||
processed_chars += len(segment_text_raw)
|
||||
|
||||
# 处理function call
|
||||
if tool_call_flag:
|
||||
@@ -448,7 +504,9 @@ class ConnectionHandler:
|
||||
try:
|
||||
content_arguments_json = json.loads(a)
|
||||
function_name = content_arguments_json["name"]
|
||||
function_arguments = json.dumps(content_arguments_json["arguments"], ensure_ascii=False)
|
||||
function_arguments = json.dumps(
|
||||
content_arguments_json["arguments"], ensure_ascii=False
|
||||
)
|
||||
function_id = str(uuid.uuid4().hex)
|
||||
except Exception as e:
|
||||
bHasError = True
|
||||
@@ -457,16 +515,19 @@ class ConnectionHandler:
|
||||
bHasError = True
|
||||
response_message.append(content_arguments)
|
||||
if bHasError:
|
||||
self.logger.bind(tag=TAG).error(f"function call error: {content_arguments}")
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"function call error: {content_arguments}"
|
||||
)
|
||||
else:
|
||||
function_arguments = json.loads(function_arguments)
|
||||
if not bHasError:
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}")
|
||||
f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}"
|
||||
)
|
||||
function_call_data = {
|
||||
"name": function_name,
|
||||
"id": function_id,
|
||||
"arguments": function_arguments
|
||||
"arguments": function_arguments,
|
||||
}
|
||||
|
||||
# 处理MCP工具调用
|
||||
@@ -474,8 +535,10 @@ class ConnectionHandler:
|
||||
result = self._handle_mcp_tool_call(function_call_data)
|
||||
else:
|
||||
# 处理系统函数
|
||||
result = self.func_handler.handle_llm_function_call(self, function_call_data)
|
||||
self._handle_function_result(result, function_call_data, text_index+1)
|
||||
result = self.func_handler.handle_llm_function_call(
|
||||
self, function_call_data
|
||||
)
|
||||
self._handle_function_result(result, function_call_data, text_index + 1)
|
||||
|
||||
# 处理最后剩余的文本
|
||||
full_text = "".join(response_message)
|
||||
@@ -485,15 +548,21 @@ class ConnectionHandler:
|
||||
if segment_text:
|
||||
text_index += 1
|
||||
self.recode_first_last_text(segment_text, text_index)
|
||||
future = self.executor.submit(self.speak_and_play, segment_text, text_index)
|
||||
future = self.executor.submit(
|
||||
self.speak_and_play, segment_text, text_index
|
||||
)
|
||||
self.tts_queue.put(future)
|
||||
|
||||
# 存储对话内容
|
||||
if len(response_message) > 0:
|
||||
self.dialogue.put(Message(role="assistant", content="".join(response_message)))
|
||||
self.dialogue.put(
|
||||
Message(role="assistant", content="".join(response_message))
|
||||
)
|
||||
|
||||
self.llm_finish_task = True
|
||||
self.logger.bind(tag=TAG).debug(json.dumps(self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False))
|
||||
self.logger.bind(tag=TAG).debug(
|
||||
json.dumps(self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -506,13 +575,16 @@ class ConnectionHandler:
|
||||
try:
|
||||
args_dict = json.loads(function_arguments)
|
||||
except json.JSONDecodeError:
|
||||
self.logger.bind(tag=TAG).error(f"无法解析 function_arguments: {function_arguments}")
|
||||
return ActionResponse(action=Action.REQLLM, result="参数解析失败", response="")
|
||||
|
||||
tool_result = asyncio.run_coroutine_threadsafe(self.mcp_manager.execute_tool(
|
||||
function_name,
|
||||
args_dict
|
||||
), self.loop).result()
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"无法解析 function_arguments: {function_arguments}"
|
||||
)
|
||||
return ActionResponse(
|
||||
action=Action.REQLLM, result="参数解析失败", response=""
|
||||
)
|
||||
|
||||
tool_result = asyncio.run_coroutine_threadsafe(
|
||||
self.mcp_manager.execute_tool(function_name, args_dict), self.loop
|
||||
).result()
|
||||
# meta=None content=[TextContent(type='text', text='北京当前天气:\n温度: 21°C\n天气: 晴\n湿度: 6%\n风向: 西北 风\n风力等级: 5级', annotations=None)] isError=False
|
||||
content_text = ""
|
||||
if tool_result is not None and tool_result.content is not None:
|
||||
@@ -522,16 +594,19 @@ class ConnectionHandler:
|
||||
content_text = content.text
|
||||
elif content_type == "image":
|
||||
pass
|
||||
|
||||
|
||||
if len(content_text) > 0:
|
||||
return ActionResponse(action=Action.REQLLM, result=content_text, response="")
|
||||
|
||||
return ActionResponse(
|
||||
action=Action.REQLLM, result=content_text, response=""
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"MCP工具调用错误: {e}")
|
||||
return ActionResponse(action=Action.REQLLM, result="工具调用出错", response="")
|
||||
return ActionResponse(
|
||||
action=Action.REQLLM, result="工具调用出错", response=""
|
||||
)
|
||||
|
||||
return ActionResponse(action=Action.REQLLM, result="工具调用出错", response="")
|
||||
|
||||
|
||||
def _handle_function_result(self, result, function_call_data, text_index):
|
||||
if result.action == Action.RESPONSE: # 直接回复前端
|
||||
@@ -547,14 +622,26 @@ class ConnectionHandler:
|
||||
function_id = function_call_data["id"]
|
||||
function_name = function_call_data["name"]
|
||||
function_arguments = function_call_data["arguments"]
|
||||
self.dialogue.put(Message(role='assistant',
|
||||
tool_calls=[{"id": function_id,
|
||||
"function": {"arguments": function_arguments,
|
||||
"name": function_name},
|
||||
"type": 'function',
|
||||
"index": 0}]))
|
||||
self.dialogue.put(
|
||||
Message(
|
||||
role="assistant",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": function_id,
|
||||
"function": {
|
||||
"arguments": function_arguments,
|
||||
"name": function_name,
|
||||
},
|
||||
"type": "function",
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
self.dialogue.put(Message(role="tool", tool_call_id=function_id, content=text))
|
||||
self.dialogue.put(
|
||||
Message(role="tool", tool_call_id=function_id, content=text)
|
||||
)
|
||||
self.chat_with_function_calling(text, tool_call=True)
|
||||
elif result.action == Action.NOTFOUND:
|
||||
text = result.result
|
||||
@@ -588,15 +675,23 @@ class ConnectionHandler:
|
||||
tts_timeout = self.config.get("tts_timeout", 10)
|
||||
tts_file, text, text_index = future.result(timeout=tts_timeout)
|
||||
if text is None or len(text) <= 0:
|
||||
self.logger.bind(tag=TAG).error(f"TTS出错:{text_index}: tts text is empty")
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"TTS出错:{text_index}: tts text is empty"
|
||||
)
|
||||
elif tts_file is None:
|
||||
self.logger.bind(tag=TAG).error(f"TTS出错: file is empty: {text_index}: {text}")
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"TTS出错: file is empty: {text_index}: {text}"
|
||||
)
|
||||
else:
|
||||
self.logger.bind(tag=TAG).debug(f"TTS生成:文件路径: {tts_file}")
|
||||
self.logger.bind(tag=TAG).debug(
|
||||
f"TTS生成:文件路径: {tts_file}"
|
||||
)
|
||||
if os.path.exists(tts_file):
|
||||
opus_datas, duration = self.tts.audio_to_opus_data(tts_file)
|
||||
else:
|
||||
self.logger.bind(tag=TAG).error(f"TTS出错:文件不存在{tts_file}")
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"TTS出错:文件不存在{tts_file}"
|
||||
)
|
||||
except TimeoutError:
|
||||
self.logger.bind(tag=TAG).error("TTS超时")
|
||||
except Exception as e:
|
||||
@@ -604,16 +699,30 @@ class ConnectionHandler:
|
||||
if not self.client_abort:
|
||||
# 如果没有中途打断就发送语音
|
||||
self.audio_play_queue.put((opus_datas, text, text_index))
|
||||
if self.tts.delete_audio_file and tts_file is not None and os.path.exists(tts_file):
|
||||
if (
|
||||
self.tts.delete_audio_file
|
||||
and tts_file is not None
|
||||
and os.path.exists(tts_file)
|
||||
):
|
||||
os.remove(tts_file)
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"TTS任务处理错误: {e}")
|
||||
self.clearSpeakStatus()
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.websocket.send(json.dumps({"type": "tts", "state": "stop", "session_id": self.session_id})),
|
||||
self.loop
|
||||
self.websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "tts",
|
||||
"state": "stop",
|
||||
"session_id": self.session_id,
|
||||
}
|
||||
)
|
||||
),
|
||||
self.loop,
|
||||
)
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"tts_priority priority_thread: {text} {e}"
|
||||
)
|
||||
self.logger.bind(tag=TAG).error(f"tts_priority priority_thread: {text} {e}")
|
||||
|
||||
def _audio_play_priority_thread(self):
|
||||
while not self.stop_event.is_set():
|
||||
@@ -625,11 +734,14 @@ class ConnectionHandler:
|
||||
if self.stop_event.is_set():
|
||||
break
|
||||
continue
|
||||
future = asyncio.run_coroutine_threadsafe(sendAudioMessage(self, opus_datas, text, text_index),
|
||||
self.loop)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
sendAudioMessage(self, opus_datas, text, text_index), self.loop
|
||||
)
|
||||
future.result()
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"audio_play_priority priority_thread: {text} {e}")
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"audio_play_priority priority_thread: {text} {e}"
|
||||
)
|
||||
|
||||
def speak_and_play(self, text, text_index=0):
|
||||
if text is None or len(text) <= 0:
|
||||
@@ -662,15 +774,15 @@ class ConnectionHandler:
|
||||
# 触发停止事件并清理资源
|
||||
if self.stop_event:
|
||||
self.stop_event.set()
|
||||
|
||||
|
||||
# 立即关闭线程池
|
||||
if self.executor:
|
||||
self.executor.shutdown(wait=False, cancel_futures=True)
|
||||
self.executor = None
|
||||
|
||||
|
||||
# 清空任务队列
|
||||
self._clear_queues()
|
||||
|
||||
|
||||
if ws:
|
||||
await ws.close()
|
||||
elif self.websocket:
|
||||
|
||||
@@ -17,6 +17,7 @@ class FunctionHandler:
|
||||
self.functions_desc = self.function_registry.get_all_function_desc()
|
||||
func_names = self.current_support_functions()
|
||||
self.modify_plugin_loader_des(func_names)
|
||||
self.finish_init = True
|
||||
|
||||
def modify_plugin_loader_des(self, func_names):
|
||||
if "plugin_loader" not in func_names:
|
||||
@@ -26,8 +27,9 @@ class FunctionHandler:
|
||||
func_names = ",".join(surport_plugins)
|
||||
for function_desc in self.functions_desc:
|
||||
if function_desc["function"]["name"] == "plugin_loader":
|
||||
function_desc["function"]["description"] = function_desc["function"]["description"].replace("[plugins]",
|
||||
func_names)
|
||||
function_desc["function"]["description"] = function_desc["function"][
|
||||
"description"
|
||||
].replace("[plugins]", func_names)
|
||||
break
|
||||
|
||||
def upload_functions_desc(self):
|
||||
@@ -69,19 +71,26 @@ class FunctionHandler:
|
||||
function_name = function_call_data["name"]
|
||||
funcItem = self.get_function(function_name)
|
||||
if not funcItem:
|
||||
return ActionResponse(action=Action.NOTFOUND, result="没有找到对应的函数", response="")
|
||||
return ActionResponse(
|
||||
action=Action.NOTFOUND, result="没有找到对应的函数", response=""
|
||||
)
|
||||
func = funcItem.func
|
||||
arguments = function_call_data["arguments"]
|
||||
arguments = json.loads(arguments) if arguments else {}
|
||||
logger.bind(tag=TAG).info(f"调用函数: {function_name}, 参数: {arguments}")
|
||||
if funcItem.type == ToolType.SYSTEM_CTL or funcItem.type == ToolType.IOT_CTL:
|
||||
if (
|
||||
funcItem.type == ToolType.SYSTEM_CTL
|
||||
or funcItem.type == ToolType.IOT_CTL
|
||||
):
|
||||
return func(conn, **arguments)
|
||||
elif funcItem.type == ToolType.WAIT:
|
||||
return func(**arguments)
|
||||
elif funcItem.type == ToolType.CHANGE_SYS_PROMPT:
|
||||
return func(conn, **arguments)
|
||||
else:
|
||||
return ActionResponse(action=Action.NOTFOUND, result="没有找到对应的函数", response="")
|
||||
return ActionResponse(
|
||||
action=Action.NOTFOUND, result="没有找到对应的函数", response=""
|
||||
)
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"处理function call错误: {e}")
|
||||
|
||||
|
||||
@@ -10,8 +10,14 @@ import time
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
WAKEUP_CONFIG = {"dir": "config/assets/", "file_name": "wakeup_words", "create_time": time.time(), "refresh_time": 10,
|
||||
"words": ["很高兴见到你", "你好啊", "我们又见面了", "最近可好?", "很高兴再次和你谈话", "在干嘛"]}
|
||||
WAKEUP_CONFIG = {
|
||||
"dir": "config/assets/",
|
||||
"file_name": "wakeup_words",
|
||||
"create_time": time.time(),
|
||||
"refresh_time": 10,
|
||||
"words": ["你好小智", "你好啊小智", "小智你好", "小智"],
|
||||
"text": "",
|
||||
}
|
||||
|
||||
|
||||
async def handleHelloMessage(conn):
|
||||
@@ -19,7 +25,9 @@ async def handleHelloMessage(conn):
|
||||
|
||||
|
||||
async def checkWakeupWords(conn, text):
|
||||
enable_wakeup_words_response_cache = conn.config["enable_wakeup_words_response_cache"]
|
||||
enable_wakeup_words_response_cache = conn.config[
|
||||
"enable_wakeup_words_response_cache"
|
||||
]
|
||||
"""是否开启唤醒词加速"""
|
||||
if not enable_wakeup_words_response_cache:
|
||||
return False
|
||||
@@ -36,7 +44,10 @@ async def checkWakeupWords(conn, text):
|
||||
asyncio.create_task(wakeupWordsResponse(conn))
|
||||
return False
|
||||
opus_packets, duration = conn.tts.audio_to_opus_data(file)
|
||||
conn.audio_play_queue.put((opus_packets, text, 0))
|
||||
text_hello = WAKEUP_CONFIG["text"]
|
||||
if not text_hello:
|
||||
text_hello = text
|
||||
conn.audio_play_queue.put((opus_packets, text_hello, 0))
|
||||
if time.time() - WAKEUP_CONFIG["create_time"] > WAKEUP_CONFIG["refresh_time"]:
|
||||
asyncio.create_task(wakeupWordsResponse(conn))
|
||||
return True
|
||||
@@ -47,7 +58,7 @@ def getWakeupWordFile(file_name):
|
||||
for file in os.listdir(WAKEUP_CONFIG["dir"]):
|
||||
if file.startswith("my_" + file_name):
|
||||
"""避免缓存文件是一个空文件"""
|
||||
if os.stat(f"config/assets/{file}").st_size > (5 * 1024):
|
||||
if os.stat(f"config/assets/{file}").st_size > (15 * 1024):
|
||||
return f"config/assets/{file}"
|
||||
|
||||
"""查找config/assets/目录下名称为wakeup_words的文件"""
|
||||
@@ -62,13 +73,18 @@ async def wakeupWordsResponse(conn):
|
||||
wakeup_word = random.choice(WAKEUP_CONFIG["words"])
|
||||
result = conn.llm.response_no_stream(conn.config["prompt"], wakeup_word)
|
||||
tts_file = await asyncio.to_thread(conn.tts.to_tts, result)
|
||||
|
||||
if tts_file is not None and os.path.exists(tts_file):
|
||||
file_type = os.path.splitext(tts_file)[1]
|
||||
if file_type:
|
||||
file_type = file_type.lstrip('.')
|
||||
file_type = file_type.lstrip(".")
|
||||
old_file = getWakeupWordFile("my_" + WAKEUP_CONFIG["file_name"])
|
||||
if old_file is not None:
|
||||
os.remove(old_file)
|
||||
"""将文件挪到"wakeup_words.mp3"""
|
||||
shutil.move(tts_file, WAKEUP_CONFIG["dir"] + "my_" + WAKEUP_CONFIG["file_name"] + "." + file_type)
|
||||
shutil.move(
|
||||
tts_file,
|
||||
WAKEUP_CONFIG["dir"] + "my_" + WAKEUP_CONFIG["file_name"] + "." + file_type,
|
||||
)
|
||||
WAKEUP_CONFIG["create_time"] = time.time()
|
||||
WAKEUP_CONFIG["text"] = result
|
||||
|
||||
@@ -37,6 +37,7 @@ async def check_direct_exit(conn, text):
|
||||
for cmd in cmd_exit:
|
||||
if text == cmd:
|
||||
logger.bind(tag=TAG).info(f"识别到明确的退出命令: {text}")
|
||||
await send_stt_message(conn, text)
|
||||
await conn.close()
|
||||
return True
|
||||
return False
|
||||
@@ -44,7 +45,7 @@ async def check_direct_exit(conn, text):
|
||||
|
||||
async def analyze_intent_with_llm(conn, text):
|
||||
"""使用LLM分析用户意图"""
|
||||
if not hasattr(conn, 'intent') or not conn.intent:
|
||||
if not hasattr(conn, "intent") or not conn.intent:
|
||||
logger.bind(tag=TAG).warning("意图识别服务未初始化")
|
||||
return None
|
||||
|
||||
@@ -68,7 +69,9 @@ async def process_intent_result(conn, intent_result, original_text):
|
||||
# 检查是否有function_call
|
||||
if "function_call" in intent_data:
|
||||
# 直接从意图识别获取了function_call
|
||||
logger.bind(tag=TAG).info(f"检测到function_call格式的意图结果: {intent_data['function_call']['name']}")
|
||||
logger.bind(tag=TAG).info(
|
||||
f"检测到function_call格式的意图结果: {intent_data['function_call']['name']}"
|
||||
)
|
||||
function_name = intent_data["function_call"]["name"]
|
||||
if function_name == "continue_chat":
|
||||
return False
|
||||
@@ -82,7 +85,7 @@ async def process_intent_result(conn, intent_result, original_text):
|
||||
function_call_data = {
|
||||
"name": function_name,
|
||||
"id": str(uuid.uuid4().hex),
|
||||
"arguments": function_args
|
||||
"arguments": function_args,
|
||||
}
|
||||
|
||||
await send_stt_message(conn, original_text)
|
||||
@@ -90,16 +93,24 @@ async def process_intent_result(conn, intent_result, original_text):
|
||||
# 使用executor执行函数调用和结果处理
|
||||
def process_function_call():
|
||||
conn.dialogue.put(Message(role="user", content=original_text))
|
||||
result = conn.func_handler.handle_llm_function_call(conn, function_call_data)
|
||||
if result and function_name != 'play_music':
|
||||
result = conn.func_handler.handle_llm_function_call(
|
||||
conn, function_call_data
|
||||
)
|
||||
if result and function_name != "play_music":
|
||||
# 获取当前最新的文本索引
|
||||
text = result.response
|
||||
if text is None:
|
||||
text = result.result
|
||||
if text is not None:
|
||||
text_index = conn.tts_last_text_index + 1 if hasattr(conn, 'tts_last_text_index') else 0
|
||||
text_index = (
|
||||
conn.tts_last_text_index + 1
|
||||
if hasattr(conn, "tts_last_text_index")
|
||||
else 0
|
||||
)
|
||||
conn.recode_first_last_text(text, text_index)
|
||||
future = conn.executor.submit(conn.speak_and_play, text, text_index)
|
||||
future = conn.executor.submit(
|
||||
conn.speak_and_play, text, text_index
|
||||
)
|
||||
conn.llm_finish_task = True
|
||||
conn.tts_queue.put(future)
|
||||
conn.dialogue.put(Message(role="assistant", content=text))
|
||||
@@ -120,10 +131,14 @@ def extract_text_in_brackets(s):
|
||||
:param s: 输入字符串
|
||||
:return: 中括号内的文字,如果不存在则返回空字符串
|
||||
"""
|
||||
left_bracket_index = s.find('[')
|
||||
right_bracket_index = s.find(']')
|
||||
left_bracket_index = s.find("[")
|
||||
right_bracket_index = s.find("]")
|
||||
|
||||
if left_bracket_index != -1 and right_bracket_index != -1 and left_bracket_index < right_bracket_index:
|
||||
return s[left_bracket_index + 1:right_bracket_index]
|
||||
if (
|
||||
left_bracket_index != -1
|
||||
and right_bracket_index != -1
|
||||
and left_bracket_index < right_bracket_index
|
||||
):
|
||||
return s[left_bracket_index + 1 : right_bracket_index]
|
||||
else:
|
||||
return ""
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import json
|
||||
import asyncio
|
||||
from config.logger import setup_logging
|
||||
from plugins_func.register import device_type_registry, register_function, ActionResponse, Action, ToolType
|
||||
from plugins_func.register import (
|
||||
device_type_registry,
|
||||
register_function,
|
||||
ActionResponse,
|
||||
Action,
|
||||
ToolType,
|
||||
)
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
@@ -14,10 +20,13 @@ def wrap_async_function(async_func):
|
||||
try:
|
||||
# 获取连接对象(第一个参数)
|
||||
conn = args[0]
|
||||
if not hasattr(conn, 'loop'):
|
||||
if not hasattr(conn, "loop"):
|
||||
logger.bind(tag=TAG).error("Connection对象没有loop属性")
|
||||
return ActionResponse(Action.ERROR, "Connection对象没有loop属性",
|
||||
"执行操作时出错: Connection对象没有loop属性")
|
||||
return ActionResponse(
|
||||
Action.ERROR,
|
||||
"Connection对象没有loop属性",
|
||||
"执行操作时出错: Connection对象没有loop属性",
|
||||
)
|
||||
|
||||
# 使用conn对象中的事件循环
|
||||
loop = conn.loop
|
||||
@@ -37,11 +46,14 @@ def create_iot_function(device_name, method_name, method_info):
|
||||
根据IOT设备描述生成通用的控制函数
|
||||
"""
|
||||
|
||||
async def iot_control_function(conn, response_success=None, response_failure=None, **params):
|
||||
async def iot_control_function(
|
||||
conn, response_success=None, response_failure=None, **params
|
||||
):
|
||||
try:
|
||||
# 打印响应参数
|
||||
logger.bind(tag=TAG).info(
|
||||
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'")
|
||||
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
|
||||
)
|
||||
|
||||
# 发送控制命令
|
||||
await send_iot_conn(conn, device_name, method_name, params)
|
||||
@@ -51,14 +63,15 @@ def create_iot_function(device_name, method_name, method_info):
|
||||
# 生成结果信息
|
||||
result = f"{device_name}的{method_name}操作执行成功"
|
||||
|
||||
|
||||
# 处理响应中可能的占位符
|
||||
response = response_success
|
||||
# 替换{value}占位符
|
||||
for param_name, param_value in params.items():
|
||||
# 先尝试直接替换参数值
|
||||
if "{" + param_name + "}" in response:
|
||||
response = response.replace("{" + param_name + "}", str(param_value))
|
||||
response = response.replace(
|
||||
"{" + param_name + "}", str(param_value)
|
||||
)
|
||||
|
||||
# 如果有{value}占位符,用相关参数替换
|
||||
if "{value}" in response:
|
||||
@@ -86,7 +99,8 @@ def create_iot_query_function(device_name, prop_name, prop_info):
|
||||
try:
|
||||
# 打印响应参数
|
||||
logger.bind(tag=TAG).info(
|
||||
f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'")
|
||||
f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
|
||||
)
|
||||
|
||||
value = await get_iot_status(conn, device_name, prop_name)
|
||||
|
||||
@@ -126,7 +140,7 @@ class IotDescriptor:
|
||||
# 根据描述创建属性
|
||||
for key, value in properties.items():
|
||||
property_item = globals()[key] = {}
|
||||
property_item['name'] = key
|
||||
property_item["name"] = key
|
||||
property_item["description"] = value["description"]
|
||||
if value["type"] == "number":
|
||||
property_item["value"] = 0
|
||||
@@ -140,7 +154,7 @@ class IotDescriptor:
|
||||
for key, value in methods.items():
|
||||
method = globals()[key] = {}
|
||||
method["description"] = value["description"]
|
||||
method['name'] = key
|
||||
method["name"] = key
|
||||
for k, v in value["parameters"].items():
|
||||
method[k] = {}
|
||||
method[k]["description"] = v["description"]
|
||||
@@ -177,19 +191,21 @@ def register_device_type(descriptor):
|
||||
"properties": {
|
||||
"response_success": {
|
||||
"type": "string",
|
||||
"description": f"查询成功时的友好回复,必须使用{{value}}作为占位符表示查询到的值"
|
||||
"description": f"查询成功时的友好回复,必须使用{{value}}作为占位符表示查询到的值",
|
||||
},
|
||||
"response_failure": {
|
||||
"type": "string",
|
||||
"description": f"查询失败时的友好回复,例如:'无法获取{device_name}的{prop_info['description']}'"
|
||||
}
|
||||
"description": f"查询失败时的友好回复,例如:'无法获取{device_name}的{prop_info['description']}'",
|
||||
},
|
||||
},
|
||||
"required": ["response_success", "response_failure"]
|
||||
}
|
||||
}
|
||||
"required": ["response_success", "response_failure"],
|
||||
},
|
||||
},
|
||||
}
|
||||
query_func = create_iot_query_function(device_name, prop_name, prop_info)
|
||||
decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(query_func)
|
||||
decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(
|
||||
query_func
|
||||
)
|
||||
functions[func_name] = decorated_func
|
||||
|
||||
# 为每个方法创建控制函数
|
||||
@@ -200,22 +216,24 @@ def register_device_type(descriptor):
|
||||
parameters = {
|
||||
param_name: {
|
||||
"type": param_info["type"],
|
||||
"description": param_info["description"]
|
||||
"description": param_info["description"],
|
||||
}
|
||||
for param_name, param_info in method_info["parameters"].items()
|
||||
}
|
||||
|
||||
# 添加响应参数
|
||||
parameters.update({
|
||||
"response_success": {
|
||||
"type": "string",
|
||||
"description": "操作成功时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称"
|
||||
},
|
||||
"response_failure": {
|
||||
"type": "string",
|
||||
"description": "操作失败时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称"
|
||||
parameters.update(
|
||||
{
|
||||
"response_success": {
|
||||
"type": "string",
|
||||
"description": "操作成功时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称",
|
||||
},
|
||||
"response_failure": {
|
||||
"type": "string",
|
||||
"description": "操作失败时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称",
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
# 构建必须参数列表(原有参数 + 响应参数)
|
||||
required_params = list(method_info["parameters"].keys())
|
||||
@@ -229,12 +247,14 @@ def register_device_type(descriptor):
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": parameters,
|
||||
"required": required_params
|
||||
}
|
||||
}
|
||||
"required": required_params,
|
||||
},
|
||||
},
|
||||
}
|
||||
control_func = create_iot_function(device_name, method_name, method_info)
|
||||
decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(control_func)
|
||||
decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(
|
||||
control_func
|
||||
)
|
||||
functions[func_name] = decorated_func
|
||||
|
||||
device_type_registry.register_device_type(type_id, functions)
|
||||
@@ -243,13 +263,24 @@ def register_device_type(descriptor):
|
||||
|
||||
# 用于接受前端设备推送的搜索iot描述
|
||||
async def handleIotDescriptors(conn, descriptors):
|
||||
wait_max_time = 5
|
||||
while conn.func_handler is None or not conn.func_handler.finish_init:
|
||||
await asyncio.sleep(1)
|
||||
wait_max_time -= 1
|
||||
if wait_max_time <= 0:
|
||||
logger.bind(tag=TAG).error("连接对象没有func_handler")
|
||||
return
|
||||
"""处理物联网描述"""
|
||||
functions_changed = False
|
||||
|
||||
for descriptor in descriptors:
|
||||
# 创建IOT设备描述符
|
||||
iot_descriptor = IotDescriptor(descriptor["name"], descriptor["description"], descriptor["properties"],
|
||||
descriptor["methods"])
|
||||
iot_descriptor = IotDescriptor(
|
||||
descriptor["name"],
|
||||
descriptor["description"],
|
||||
descriptor["properties"],
|
||||
descriptor["methods"],
|
||||
)
|
||||
conn.iot_descriptors[descriptor["name"]] = iot_descriptor
|
||||
|
||||
if conn.use_function_call_mode:
|
||||
@@ -258,18 +289,22 @@ async def handleIotDescriptors(conn, descriptors):
|
||||
device_functions = device_type_registry.get_device_functions(type_id)
|
||||
|
||||
# 在连接级注册设备函数
|
||||
if hasattr(conn, 'func_handler'):
|
||||
if hasattr(conn, "func_handler"):
|
||||
for func_name in device_functions:
|
||||
conn.func_handler.function_registry.register_function(func_name)
|
||||
logger.bind(tag=TAG).info(f"注册IOT函数到function handler: {func_name}")
|
||||
logger.bind(tag=TAG).info(
|
||||
f"注册IOT函数到function handler: {func_name}"
|
||||
)
|
||||
functions_changed = True
|
||||
|
||||
# 如果注册了新函数,更新function描述列表
|
||||
if functions_changed and hasattr(conn, 'func_handler'):
|
||||
if functions_changed and hasattr(conn, "func_handler"):
|
||||
conn.func_handler.upload_functions_desc()
|
||||
func_names = conn.func_handler.current_support_functions()
|
||||
logger.bind(tag=TAG).info(f"设备类型: {type_id}")
|
||||
logger.bind(tag=TAG).info(f"更新function描述列表完成,当前支持的函数: {func_names}")
|
||||
logger.bind(tag=TAG).info(
|
||||
f"更新function描述列表完成,当前支持的函数: {func_names}"
|
||||
)
|
||||
|
||||
|
||||
async def handleIotStatus(conn, states):
|
||||
@@ -281,11 +316,15 @@ async def handleIotStatus(conn, states):
|
||||
for k, v in state["state"].items():
|
||||
if property_item["name"] == k:
|
||||
if type(v) != type(property_item["value"]):
|
||||
logger.bind(tag=TAG).error(f"属性{property_item['name']}的值类型不匹配")
|
||||
logger.bind(tag=TAG).error(
|
||||
f"属性{property_item['name']}的值类型不匹配"
|
||||
)
|
||||
break
|
||||
else:
|
||||
property_item["value"] = v
|
||||
logger.bind(tag=TAG).info(f"物联网状态更新: {key} , {property_item['name']} = {v}")
|
||||
logger.bind(tag=TAG).info(
|
||||
f"物联网状态更新: {key} , {property_item['name']} = {v}"
|
||||
)
|
||||
break
|
||||
break
|
||||
|
||||
@@ -308,10 +347,14 @@ async def set_iot_status(conn, name, property_name, value):
|
||||
for property_item in iot_descriptor.properties:
|
||||
if property_item["name"] == property_name:
|
||||
if type(value) != type(property_item["value"]):
|
||||
logger.bind(tag=TAG).error(f"属性{property_item['name']}的值类型不匹配")
|
||||
logger.bind(tag=TAG).error(
|
||||
f"属性{property_item['name']}的值类型不匹配"
|
||||
)
|
||||
return
|
||||
property_item["value"] = value
|
||||
logger.bind(tag=TAG).info(f"物联网状态更新: {name} , {property_name} = {value}")
|
||||
logger.bind(tag=TAG).info(
|
||||
f"物联网状态更新: {name} , {property_name} = {value}"
|
||||
)
|
||||
return
|
||||
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
|
||||
|
||||
@@ -324,15 +367,19 @@ async def send_iot_conn(conn, name, method_name, parameters):
|
||||
for method in value.methods:
|
||||
# 找到了方法
|
||||
if method["name"] == method_name:
|
||||
await conn.websocket.send(json.dumps({
|
||||
"type": "iot",
|
||||
"commands": [
|
||||
await conn.websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"name": name,
|
||||
"method": method_name,
|
||||
"parameters": parameters
|
||||
"type": "iot",
|
||||
"commands": [
|
||||
{
|
||||
"name": name,
|
||||
"method": method_name,
|
||||
"parameters": parameters,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}))
|
||||
)
|
||||
)
|
||||
return
|
||||
logger.bind(tag=TAG).error(f"未找到方法{method_name}")
|
||||
|
||||
@@ -21,7 +21,9 @@ async def handleAudioMessage(conn, audio):
|
||||
if have_voice == False and conn.client_have_voice == False:
|
||||
await no_voice_close_connect(conn)
|
||||
conn.asr_audio.append(audio)
|
||||
conn.asr_audio = conn.asr_audio[-5:] # 保留最新的5帧音频内容,解决ASR句首丢字问题
|
||||
conn.asr_audio = conn.asr_audio[
|
||||
-10:
|
||||
] # 保留最新的10帧音频内容,解决ASR句首丢字问题
|
||||
return
|
||||
conn.client_no_voice_last_time = 0.0
|
||||
conn.asr_audio.append(audio)
|
||||
@@ -30,10 +32,12 @@ async def handleAudioMessage(conn, audio):
|
||||
conn.client_abort = False
|
||||
conn.asr_server_receive = False
|
||||
# 音频太短了,无法识别
|
||||
if len(conn.asr_audio) < 10:
|
||||
if len(conn.asr_audio) < 15:
|
||||
conn.asr_server_receive = True
|
||||
else:
|
||||
text, file_path = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id)
|
||||
text, file_path = await conn.asr.speech_to_text(
|
||||
conn.asr_audio, conn.session_id
|
||||
)
|
||||
logger.bind(tag=TAG).info(f"识别文本: {text}")
|
||||
text_len, _ = remove_punctuation_and_length(text)
|
||||
if text_len > 0:
|
||||
@@ -47,12 +51,12 @@ async def handleAudioMessage(conn, audio):
|
||||
async def startToChat(conn, text):
|
||||
# 首先进行意图分析
|
||||
intent_handled = await handle_user_intent(conn, text)
|
||||
|
||||
|
||||
if intent_handled:
|
||||
# 如果意图已被处理,不再进行聊天
|
||||
conn.asr_server_receive = True
|
||||
return
|
||||
|
||||
|
||||
# 意图未被处理,继续常规聊天流程
|
||||
await send_stt_message(conn, text)
|
||||
if conn.use_function_call_mode:
|
||||
@@ -67,10 +71,17 @@ async def no_voice_close_connect(conn):
|
||||
conn.client_no_voice_last_time = time.time() * 1000
|
||||
else:
|
||||
no_voice_time = time.time() * 1000 - conn.client_no_voice_last_time
|
||||
close_connection_no_voice_time = conn.config.get("close_connection_no_voice_time", 120)
|
||||
if not conn.close_after_chat and no_voice_time > 1000 * close_connection_no_voice_time:
|
||||
close_connection_no_voice_time = conn.config.get(
|
||||
"close_connection_no_voice_time", 120
|
||||
)
|
||||
if (
|
||||
not conn.close_after_chat
|
||||
and no_voice_time > 1000 * close_connection_no_voice_time
|
||||
):
|
||||
conn.close_after_chat = True
|
||||
conn.client_abort = False
|
||||
conn.asr_server_receive = False
|
||||
prompt = "请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。"
|
||||
prompt = (
|
||||
"请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。"
|
||||
)
|
||||
await startToChat(conn, prompt)
|
||||
|
||||
@@ -2,7 +2,10 @@ from config.logger import setup_logging
|
||||
import json
|
||||
import asyncio
|
||||
import time
|
||||
from core.utils.util import remove_punctuation_and_length, get_string_no_punctuation_or_emoji
|
||||
from core.utils.util import (
|
||||
remove_punctuation_and_length,
|
||||
get_string_no_punctuation_or_emoji,
|
||||
)
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
@@ -21,10 +24,11 @@ async def sendAudioMessage(conn, audios, text, text_index=0):
|
||||
|
||||
# 发送结束消息(如果是最后一个文本)
|
||||
if conn.llm_finish_task and text_index == conn.tts_last_text_index:
|
||||
await send_tts_message(conn, 'stop', None)
|
||||
await send_tts_message(conn, "stop", None)
|
||||
if conn.close_after_chat:
|
||||
await conn.close()
|
||||
|
||||
|
||||
# 播放音频
|
||||
async def sendAudio(conn, audios):
|
||||
# 流控参数优化
|
||||
@@ -36,13 +40,12 @@ async def sendAudio(conn, audios):
|
||||
pre_buffer = min(3, len(audios))
|
||||
for i in range(pre_buffer):
|
||||
await conn.websocket.send(audios[i])
|
||||
conn.logger.bind(tag=TAG).debug(f"预缓冲帧 {i}, 时间: {(time.perf_counter() - start_time) * 1000:.2f}ms")
|
||||
|
||||
# 正常播放剩余帧
|
||||
for opus_packet in audios[pre_buffer:]:
|
||||
if conn.client_abort:
|
||||
return
|
||||
|
||||
|
||||
# 计算预期发送时间
|
||||
expected_time = start_time + (play_position / 1000)
|
||||
current_time = time.perf_counter()
|
||||
@@ -51,19 +54,13 @@ async def sendAudio(conn, audios):
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
await conn.websocket.send(opus_packet)
|
||||
conn.logger.bind(tag=TAG).debug(f"发送帧,位置: {play_position}ms, 实际间隔: {(time.perf_counter() - current_time) * 1000:.2f}ms")
|
||||
|
||||
play_position += frame_duration
|
||||
|
||||
|
||||
|
||||
async def send_tts_message(conn, state, text=None):
|
||||
"""发送 TTS 状态消息"""
|
||||
message = {
|
||||
"type": "tts",
|
||||
"state": state,
|
||||
"session_id": conn.session_id
|
||||
}
|
||||
message = {"type": "tts", "state": state, "session_id": conn.session_id}
|
||||
if text is not None:
|
||||
message["text"] = text
|
||||
|
||||
@@ -72,7 +69,9 @@ async def send_tts_message(conn, state, text=None):
|
||||
# 播放提示音
|
||||
tts_notify = conn.config.get("enable_stop_tts_notify", False)
|
||||
if tts_notify:
|
||||
stop_tts_notify_voice = conn.config.get("stop_tts_notify_voice", "config/assets/tts_notify.mp3")
|
||||
stop_tts_notify_voice = conn.config.get(
|
||||
"stop_tts_notify_voice", "config/assets/tts_notify.mp3"
|
||||
)
|
||||
audios, duration = conn.tts.audio_to_opus_data(stop_tts_notify_voice)
|
||||
await sendAudio(conn, audios)
|
||||
# 清除服务端讲话状态
|
||||
@@ -85,16 +84,17 @@ async def send_tts_message(conn, state, text=None):
|
||||
async def send_stt_message(conn, text):
|
||||
"""发送 STT 状态消息"""
|
||||
stt_text = get_string_no_punctuation_or_emoji(text)
|
||||
await conn.websocket.send(json.dumps({
|
||||
"type": "stt",
|
||||
"text": stt_text,
|
||||
"session_id": conn.session_id}
|
||||
))
|
||||
await conn.websocket.send(
|
||||
json.dumps({
|
||||
"type": "llm",
|
||||
"text": "😊",
|
||||
"emotion": "happy",
|
||||
"session_id": conn.session_id}
|
||||
))
|
||||
json.dumps({"type": "stt", "text": stt_text, "session_id": conn.session_id})
|
||||
)
|
||||
await conn.websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "llm",
|
||||
"text": "😊",
|
||||
"emotion": "happy",
|
||||
"session_id": conn.session_id,
|
||||
}
|
||||
)
|
||||
)
|
||||
await send_tts_message(conn, "start")
|
||||
|
||||
@@ -6,6 +6,7 @@ from core.utils.util import remove_punctuation_and_length
|
||||
from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
|
||||
from core.handle.sendAudioHandle import send_stt_message, send_tts_message
|
||||
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
|
||||
import asyncio
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
@@ -34,7 +35,7 @@ async def handleTextMessage(conn, message):
|
||||
conn.client_have_voice = True
|
||||
conn.client_voice_stop = True
|
||||
if len(conn.asr_audio) > 0:
|
||||
await handleAudioMessage(conn, b'')
|
||||
await handleAudioMessage(conn, b"")
|
||||
elif msg_json["state"] == "detect":
|
||||
conn.asr_server_receive = False
|
||||
conn.client_have_voice = False
|
||||
@@ -57,8 +58,8 @@ async def handleTextMessage(conn, message):
|
||||
await startToChat(conn, text)
|
||||
elif msg_json["type"] == "iot":
|
||||
if "descriptors" in msg_json:
|
||||
await handleIotDescriptors(conn, msg_json["descriptors"])
|
||||
asyncio.create_task(handleIotDescriptors(conn, msg_json["descriptors"]))
|
||||
if "states" in msg_json:
|
||||
await handleIotStatus(conn, msg_json["states"])
|
||||
asyncio.create_task(handleIotStatus(conn, msg_json["states"]))
|
||||
except json.JSONDecodeError:
|
||||
await conn.websocket.send(message)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional, Tuple, List
|
||||
import wave
|
||||
import opuslib_next
|
||||
|
||||
import requests
|
||||
from core.providers.asr.base import ASRProviderBase
|
||||
from config.logger import setup_logging
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
|
||||
class ASRProvider(ASRProviderBase):
|
||||
API_URL = "https://asr.tencentcloudapi.com"
|
||||
API_VERSION = "2019-06-14"
|
||||
FORMAT = "pcm" # 支持的音频格式:pcm, wav, mp3
|
||||
|
||||
def __init__(self, config: dict, delete_audio_file: bool = True):
|
||||
self.secret_id = config.get("secret_id")
|
||||
self.secret_key = config.get("secret_key")
|
||||
self.output_dir = config.get("output_dir")
|
||||
|
||||
# 确保输出目录存在
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
|
||||
def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str:
|
||||
"""将Opus音频数据解码并保存为WAV文件"""
|
||||
|
||||
file_name = f"tencent_asr_{session_id}_{uuid.uuid4()}.wav"
|
||||
file_path = os.path.join(self.output_dir, file_name)
|
||||
|
||||
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
||||
pcm_data = []
|
||||
|
||||
for opus_packet in opus_data:
|
||||
try:
|
||||
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
||||
pcm_data.append(pcm_frame)
|
||||
except opuslib_next.OpusError as e:
|
||||
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
||||
|
||||
with wave.open(file_path, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2) # 2 bytes = 16-bit
|
||||
wf.setframerate(16000)
|
||||
wf.writeframes(b"".join(pcm_data))
|
||||
|
||||
return file_path
|
||||
|
||||
@staticmethod
|
||||
def decode_opus(opus_data: List[bytes]) -> bytes:
|
||||
"""将Opus音频数据解码为PCM数据"""
|
||||
import opuslib_next
|
||||
|
||||
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
|
||||
pcm_data = []
|
||||
|
||||
for opus_packet in opus_data:
|
||||
try:
|
||||
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
|
||||
pcm_data.append(pcm_frame)
|
||||
except opuslib_next.OpusError as e:
|
||||
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
|
||||
|
||||
return b"".join(pcm_data)
|
||||
|
||||
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""将语音数据转换为文本"""
|
||||
if not opus_data:
|
||||
logger.bind(tag=TAG).warn("音频数据为空!")
|
||||
return None, None
|
||||
|
||||
try:
|
||||
# 检查配置是否已设置
|
||||
if not self.secret_id or not self.secret_key:
|
||||
logger.bind(tag=TAG).error("腾讯云语音识别配置未设置,无法进行识别")
|
||||
return None, None
|
||||
|
||||
# 将Opus音频数据解码为PCM
|
||||
pcm_data = self.decode_opus(opus_data)
|
||||
|
||||
# 将音频数据转换为Base64编码
|
||||
base64_audio = base64.b64encode(pcm_data).decode('utf-8')
|
||||
|
||||
# 构建请求体
|
||||
request_body = self._build_request_body(base64_audio)
|
||||
|
||||
# 获取认证头
|
||||
timestamp, authorization = self._get_auth_headers(request_body)
|
||||
|
||||
# 发送请求
|
||||
start_time = time.time()
|
||||
result = self._send_request(request_body, timestamp, authorization)
|
||||
|
||||
if result:
|
||||
logger.bind(tag=TAG).debug(f"腾讯云语音识别耗时: {time.time() - start_time:.3f}s | 结果: {result}")
|
||||
|
||||
return result, None
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"处理音频时发生错误!{e}", exc_info=True)
|
||||
return None, None
|
||||
|
||||
def _build_request_body(self, base64_audio: str) -> str:
|
||||
"""构建请求体"""
|
||||
request_map = {
|
||||
"ProjectId": 0,
|
||||
"SubServiceType": 2, # 一句话识别
|
||||
"EngSerViceType": "16k_zh", # 中文普通话通用
|
||||
"SourceType": 1, # 音频数据来源为语音文件
|
||||
"VoiceFormat": self.FORMAT, # 音频格式
|
||||
"Data": base64_audio, # Base64编码的音频数据
|
||||
"DataLen": len(base64_audio) # 数据长度
|
||||
}
|
||||
return json.dumps(request_map)
|
||||
|
||||
def _get_auth_headers(self, request_body: str) -> Tuple[str, str]:
|
||||
"""获取认证头"""
|
||||
try:
|
||||
# 获取当前UTC时间戳
|
||||
now = datetime.now(timezone.utc)
|
||||
timestamp = str(int(now.timestamp()))
|
||||
date = now.strftime("%Y-%m-%d")
|
||||
|
||||
# 服务名称必须是 "asr"
|
||||
service = "asr"
|
||||
|
||||
# 拼接凭证范围
|
||||
credential_scope = f"{date}/{service}/tc3_request"
|
||||
|
||||
# 使用TC3-HMAC-SHA256签名方法
|
||||
algorithm = "TC3-HMAC-SHA256"
|
||||
|
||||
# 构建规范请求字符串
|
||||
http_request_method = "POST"
|
||||
canonical_uri = "/"
|
||||
canonical_query_string = ""
|
||||
|
||||
# 注意:头部信息需要按照ASCII升序排列,且key和value都转为小写
|
||||
# 必须包含content-type和host头部
|
||||
content_type = "application/json; charset=utf-8"
|
||||
host = "asr.tencentcloudapi.com"
|
||||
action = "SentenceRecognition" # 接口名称
|
||||
|
||||
# 构建规范头部信息,注意顺序和格式
|
||||
canonical_headers = f"content-type:{content_type.lower()}\n" + \
|
||||
f"host:{host.lower()}\n" + \
|
||||
f"x-tc-action:{action.lower()}\n"
|
||||
|
||||
signed_headers = "content-type;host;x-tc-action"
|
||||
|
||||
# 请求体哈希值
|
||||
payload_hash = self._sha256_hex(request_body)
|
||||
|
||||
# 构建规范请求字符串
|
||||
canonical_request = f"{http_request_method}\n" + \
|
||||
f"{canonical_uri}\n" + \
|
||||
f"{canonical_query_string}\n" + \
|
||||
f"{canonical_headers}\n" + \
|
||||
f"{signed_headers}\n" + \
|
||||
f"{payload_hash}"
|
||||
|
||||
# 计算规范请求的哈希值
|
||||
hashed_canonical_request = self._sha256_hex(canonical_request)
|
||||
|
||||
# 构建待签名字符串
|
||||
string_to_sign = f"{algorithm}\n" + \
|
||||
f"{timestamp}\n" + \
|
||||
f"{credential_scope}\n" + \
|
||||
f"{hashed_canonical_request}"
|
||||
|
||||
# 计算签名密钥
|
||||
secret_date = self._hmac_sha256(f"TC3{self.secret_key}", date)
|
||||
secret_service = self._hmac_sha256(secret_date, service)
|
||||
secret_signing = self._hmac_sha256(secret_service, "tc3_request")
|
||||
|
||||
# 计算签名
|
||||
signature = self._bytes_to_hex(self._hmac_sha256(secret_signing, string_to_sign))
|
||||
|
||||
# 构建授权头
|
||||
authorization = f"{algorithm} " + \
|
||||
f"Credential={self.secret_id}/{credential_scope}, " + \
|
||||
f"SignedHeaders={signed_headers}, " + \
|
||||
f"Signature={signature}"
|
||||
|
||||
return timestamp, authorization
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"生成认证头失败: {e}", exc_info=True)
|
||||
raise RuntimeError(f"生成认证头失败: {e}")
|
||||
|
||||
def _send_request(self, request_body: str, timestamp: str, authorization: str) -> Optional[str]:
|
||||
"""发送请求到腾讯云API"""
|
||||
headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Host": "asr.tencentcloudapi.com",
|
||||
"Authorization": authorization,
|
||||
"X-TC-Action": "SentenceRecognition",
|
||||
"X-TC-Version": self.API_VERSION,
|
||||
"X-TC-Timestamp": timestamp,
|
||||
"X-TC-Region": "ap-shanghai"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(self.API_URL, headers=headers, data=request_body)
|
||||
|
||||
if not response.ok:
|
||||
raise IOError(f"请求失败: {response.status_code} {response.reason}")
|
||||
|
||||
response_json = response.json()
|
||||
|
||||
# 检查是否有错误
|
||||
if "Response" in response_json and "Error" in response_json["Response"]:
|
||||
error = response_json["Response"]["Error"]
|
||||
error_code = error["Code"]
|
||||
error_message = error["Message"]
|
||||
raise IOError(f"API返回错误: {error_code}: {error_message}")
|
||||
|
||||
# 提取识别结果
|
||||
if "Response" in response_json and "Result" in response_json["Response"]:
|
||||
return response_json["Response"]["Result"]
|
||||
else:
|
||||
logger.bind(tag=TAG).warn(f"响应中没有识别结果: {response_json}")
|
||||
return ""
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"发送请求失败: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def _sha256_hex(self, data: str) -> str:
|
||||
"""计算字符串的SHA256哈希值"""
|
||||
digest = hashlib.sha256(data.encode('utf-8')).digest()
|
||||
return self._bytes_to_hex(digest)
|
||||
|
||||
def _hmac_sha256(self, key, data: str) -> bytes:
|
||||
"""计算HMAC-SHA256"""
|
||||
if isinstance(key, str):
|
||||
key = key.encode('utf-8')
|
||||
|
||||
return hmac.new(key, data.encode('utf-8'), hashlib.sha256).digest()
|
||||
|
||||
def _bytes_to_hex(self, bytes_data: bytes) -> str:
|
||||
"""字节数组转十六进制字符串"""
|
||||
return ''.join(f"{b:02x}" for b in bytes_data)
|
||||
@@ -6,11 +6,12 @@ from core.providers.llm.base import LLMProviderBase
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
class LLMProvider(LLMProviderBase):
|
||||
def __init__(self, config):
|
||||
self.api_key = config["api_key"]
|
||||
self.mode = config.get("mode", "chat-messages")
|
||||
self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip('/')
|
||||
self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip("/")
|
||||
self.session_conversation_map = {} # 存储session_id和conversation_id的映射
|
||||
|
||||
def response(self, session_id, dialogue):
|
||||
@@ -22,57 +23,58 @@ class LLMProvider(LLMProviderBase):
|
||||
# 发起流式请求
|
||||
if self.mode == "chat-messages":
|
||||
request_json = {
|
||||
"query": last_msg["content"],
|
||||
"response_mode": "streaming",
|
||||
"user": session_id,
|
||||
"inputs": {},
|
||||
"conversation_id": conversation_id
|
||||
}
|
||||
"query": last_msg["content"],
|
||||
"response_mode": "streaming",
|
||||
"user": session_id,
|
||||
"inputs": {},
|
||||
"conversation_id": conversation_id,
|
||||
}
|
||||
elif self.mode == "workflows/run":
|
||||
request_json = {
|
||||
"inputs": {"query": last_msg["content"]},
|
||||
"response_mode": "streaming",
|
||||
"user": session_id
|
||||
"user": session_id,
|
||||
}
|
||||
elif self.mode == "completion-messages":
|
||||
request_json = {
|
||||
"inputs": {"query": last_msg["content"]},
|
||||
"response_mode": "streaming",
|
||||
"user": session_id
|
||||
"user": session_id,
|
||||
}
|
||||
|
||||
with requests.post(
|
||||
f"{self.base_url}/{self.mode}",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json=request_json,
|
||||
stream=True
|
||||
f"{self.base_url}/{self.mode}",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json=request_json,
|
||||
stream=True,
|
||||
) as r:
|
||||
if self.mode == "chat-messages":
|
||||
for line in r.iter_lines():
|
||||
if line.startswith(b'data: '):
|
||||
if line.startswith(b"data: "):
|
||||
event = json.loads(line[6:])
|
||||
# 如果没有找到conversation_id,则获取此次conversation_id
|
||||
if not conversation_id:
|
||||
conversation_id = event.get('conversation_id')
|
||||
self.session_conversation_map[session_id] = conversation_id # 更新映射
|
||||
if event.get('answer'):
|
||||
yield event['answer']
|
||||
conversation_id = event.get("conversation_id")
|
||||
self.session_conversation_map[session_id] = (
|
||||
conversation_id # 更新映射
|
||||
)
|
||||
if event.get("answer"):
|
||||
yield event["answer"]
|
||||
elif self.mode == "workflows/run":
|
||||
for line in r.iter_lines():
|
||||
# logger.bind(tag=TAG).info(f"chat message response: {line}")
|
||||
if line.startswith(b'data: '):
|
||||
if line.startswith(b"data: "):
|
||||
event = json.loads(line[6:])
|
||||
if event.get('event') == "workflow_finished":
|
||||
if event['data']['status'] == "succeeded":
|
||||
yield event['data']['outputs']['answer']
|
||||
if event.get("event") == "workflow_finished":
|
||||
if event["data"]["status"] == "succeeded":
|
||||
yield event["data"]["outputs"]["answer"]
|
||||
else:
|
||||
yield "【服务响应异常】"
|
||||
elif self.mode == "completion-messages":
|
||||
for line in r.iter_lines():
|
||||
if line.startswith(b'data: '):
|
||||
if line.startswith(b"data: "):
|
||||
event = json.loads(line[6:])
|
||||
if event.get('answer'):
|
||||
yield event['answer']
|
||||
if event.get("answer"):
|
||||
yield event["answer"]
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"Error in response generation: {e}")
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import json
|
||||
import base64
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
from core.providers.tts.base import TTSProviderBase
|
||||
|
||||
|
||||
class TTSProvider(TTSProviderBase):
|
||||
def __init__(self, config, delete_audio_file):
|
||||
super().__init__(config, delete_audio_file)
|
||||
self.appid = config.get("appid")
|
||||
self.secret_id = config.get("secret_id")
|
||||
self.secret_key = config.get("secret_key")
|
||||
self.voice = config.get("voice")
|
||||
self.api_url = "https://tts.tencentcloudapi.com" # 正确的API端点
|
||||
self.region = config.get("region")
|
||||
self.output_file = config.get("output_dir")
|
||||
|
||||
def _get_auth_headers(self, request_body):
|
||||
"""生成鉴权请求头"""
|
||||
# 获取当前UTC时间戳
|
||||
timestamp = int(time.time())
|
||||
|
||||
# 使用UTC时间计算日期
|
||||
utc_date = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime('%Y-%m-%d')
|
||||
|
||||
# 服务名称必须是 "tts"
|
||||
service = "tts"
|
||||
|
||||
# 拼接凭证范围
|
||||
credential_scope = f"{utc_date}/{service}/tc3_request"
|
||||
|
||||
# 使用TC3-HMAC-SHA256签名方法
|
||||
algorithm = "TC3-HMAC-SHA256"
|
||||
|
||||
# 构建规范请求字符串
|
||||
http_request_method = "POST"
|
||||
canonical_uri = "/"
|
||||
canonical_querystring = ""
|
||||
|
||||
# 请求头必须包含host和content-type,且按字典序排列
|
||||
canonical_headers = (
|
||||
f"content-type:application/json\n"
|
||||
f"host:tts.tencentcloudapi.com\n"
|
||||
)
|
||||
signed_headers = "content-type;host"
|
||||
|
||||
# 请求体哈希值
|
||||
payload = json.dumps(request_body)
|
||||
payload_hash = hashlib.sha256(payload.encode('utf-8')).hexdigest()
|
||||
|
||||
# 构建规范请求字符串
|
||||
canonical_request = (
|
||||
f"{http_request_method}\n"
|
||||
f"{canonical_uri}\n"
|
||||
f"{canonical_querystring}\n"
|
||||
f"{canonical_headers}\n"
|
||||
f"{signed_headers}\n"
|
||||
f"{payload_hash}"
|
||||
)
|
||||
|
||||
# 计算规范请求的哈希值
|
||||
hashed_canonical_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
|
||||
|
||||
# 构建待签名字符串
|
||||
string_to_sign = (
|
||||
f"{algorithm}\n"
|
||||
f"{timestamp}\n"
|
||||
f"{credential_scope}\n"
|
||||
f"{hashed_canonical_request}"
|
||||
)
|
||||
|
||||
# 计算签名密钥
|
||||
secret_date = self._hmac_sha256(f"TC3{self.secret_key}".encode('utf-8'), utc_date)
|
||||
secret_service = self._hmac_sha256(secret_date, service)
|
||||
secret_signing = self._hmac_sha256(secret_service, "tc3_request")
|
||||
|
||||
# 计算签名
|
||||
signature = hmac.new(
|
||||
secret_signing,
|
||||
string_to_sign.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# 构建授权头
|
||||
authorization = (
|
||||
f"{algorithm} "
|
||||
f"Credential={self.secret_id}/{credential_scope}, "
|
||||
f"SignedHeaders={signed_headers}, "
|
||||
f"Signature={signature}"
|
||||
)
|
||||
|
||||
# 构建请求头
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Host": "tts.tencentcloudapi.com",
|
||||
"Authorization": authorization,
|
||||
"X-TC-Action": "TextToVoice",
|
||||
"X-TC-Timestamp": str(timestamp),
|
||||
"X-TC-Version": "2019-08-23",
|
||||
"X-TC-Region": self.region,
|
||||
"X-TC-Language": "zh-CN"
|
||||
}
|
||||
|
||||
return headers
|
||||
|
||||
def _hmac_sha256(self, key, msg):
|
||||
"""HMAC-SHA256加密"""
|
||||
if isinstance(msg, str):
|
||||
msg = msg.encode('utf-8')
|
||||
return hmac.new(key, msg, hashlib.sha256).digest()
|
||||
|
||||
def generate_filename(self, extension=".wav"):
|
||||
return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}{extension}")
|
||||
|
||||
async def text_to_speak(self, text, output_file):
|
||||
# 构建请求体
|
||||
request_json = {
|
||||
"Text": text, # 合成语音的源文本
|
||||
"SessionId": str(uuid.uuid4()), # 会话ID,随机生成
|
||||
"VoiceType": int(self.voice), # 音色
|
||||
}
|
||||
|
||||
try:
|
||||
# 获取请求头(每次请求都重新生成,以确保时间戳和签名是最新的)
|
||||
headers = self._get_auth_headers(request_json)
|
||||
|
||||
# 发送请求
|
||||
resp = requests.post(self.api_url, json.dumps(request_json), headers=headers)
|
||||
|
||||
# 检查响应
|
||||
if resp.status_code == 200:
|
||||
response_data = resp.json()
|
||||
|
||||
# 检查是否成功
|
||||
if response_data.get("Response", {}).get("Error") is not None:
|
||||
error_info = response_data["Response"]["Error"]
|
||||
raise Exception(f"API返回错误: {error_info['Code']}: {error_info['Message']}")
|
||||
|
||||
# 提取音频数据
|
||||
audio_data = response_data["Response"].get("Audio")
|
||||
if audio_data:
|
||||
# 解码Base64音频数据并保存
|
||||
with open(output_file, "wb") as f:
|
||||
f.write(base64.b64decode(audio_data))
|
||||
else:
|
||||
raise Exception(f"{__name__}: 没有返回音频数据: {response_data}")
|
||||
else:
|
||||
raise Exception(f"{__name__} status_code: {resp.status_code} response: {resp.content}")
|
||||
except Exception as e:
|
||||
raise Exception(f"{__name__} error: {e}")
|
||||
@@ -12,50 +12,72 @@ class WebSocketServer:
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.logger = setup_logging()
|
||||
self._vad, self._asr, self._llm, self._tts, self._memory, self.intent = self._create_processing_instances()
|
||||
self._vad, self._asr, self._llm, self._tts, self._memory, self.intent = (
|
||||
self._create_processing_instances()
|
||||
)
|
||||
self.active_connections = set() # 添加全局连接记录
|
||||
|
||||
def _create_processing_instances(self):
|
||||
memory_cls_name = self.config["selected_module"].get("Memory", "nomem") # 默认使用nomem
|
||||
has_memory_cfg = self.config.get("Memory") and memory_cls_name in self.config["Memory"]
|
||||
memory_cls_name = self.config["selected_module"].get(
|
||||
"Memory", "nomem"
|
||||
) # 默认使用nomem
|
||||
has_memory_cfg = (
|
||||
self.config.get("Memory") and memory_cls_name in self.config["Memory"]
|
||||
)
|
||||
memory_cfg = self.config["Memory"][memory_cls_name] if has_memory_cfg else {}
|
||||
|
||||
"""创建处理模块实例"""
|
||||
return (
|
||||
vad.create_instance(
|
||||
self.config["selected_module"]["VAD"],
|
||||
self.config["VAD"][self.config["selected_module"]["VAD"]]
|
||||
self.config["VAD"][self.config["selected_module"]["VAD"]],
|
||||
),
|
||||
asr.create_instance(
|
||||
self.config["selected_module"]["ASR"]
|
||||
if not 'type' in self.config["ASR"][self.config["selected_module"]["ASR"]]
|
||||
else
|
||||
self.config["ASR"][self.config["selected_module"]["ASR"]]["type"],
|
||||
(
|
||||
self.config["selected_module"]["ASR"]
|
||||
if not "type"
|
||||
in self.config["ASR"][self.config["selected_module"]["ASR"]]
|
||||
else self.config["ASR"][self.config["selected_module"]["ASR"]][
|
||||
"type"
|
||||
]
|
||||
),
|
||||
self.config["ASR"][self.config["selected_module"]["ASR"]],
|
||||
self.config["delete_audio"]
|
||||
self.config["delete_audio"],
|
||||
),
|
||||
llm.create_instance(
|
||||
self.config["selected_module"]["LLM"]
|
||||
if not 'type' in self.config["LLM"][self.config["selected_module"]["LLM"]]
|
||||
else
|
||||
self.config["LLM"][self.config["selected_module"]["LLM"]]['type'],
|
||||
(
|
||||
self.config["selected_module"]["LLM"]
|
||||
if not "type"
|
||||
in self.config["LLM"][self.config["selected_module"]["LLM"]]
|
||||
else self.config["LLM"][self.config["selected_module"]["LLM"]][
|
||||
"type"
|
||||
]
|
||||
),
|
||||
self.config["LLM"][self.config["selected_module"]["LLM"]],
|
||||
),
|
||||
tts.create_instance(
|
||||
self.config["selected_module"]["TTS"]
|
||||
if not 'type' in self.config["TTS"][self.config["selected_module"]["TTS"]]
|
||||
else
|
||||
self.config["TTS"][self.config["selected_module"]["TTS"]]["type"],
|
||||
(
|
||||
self.config["selected_module"]["TTS"]
|
||||
if not "type"
|
||||
in self.config["TTS"][self.config["selected_module"]["TTS"]]
|
||||
else self.config["TTS"][self.config["selected_module"]["TTS"]][
|
||||
"type"
|
||||
]
|
||||
),
|
||||
self.config["TTS"][self.config["selected_module"]["TTS"]],
|
||||
self.config["delete_audio"]
|
||||
self.config["delete_audio"],
|
||||
),
|
||||
memory.create_instance(memory_cls_name, memory_cfg),
|
||||
intent.create_instance(
|
||||
self.config["selected_module"]["Intent"]
|
||||
if not 'type' in self.config["Intent"][self.config["selected_module"]["Intent"]]
|
||||
else
|
||||
self.config["Intent"][self.config["selected_module"]["Intent"]]["type"],
|
||||
self.config["Intent"][self.config["selected_module"]["Intent"]]
|
||||
(
|
||||
self.config["selected_module"]["Intent"]
|
||||
if not "type"
|
||||
in self.config["Intent"][self.config["selected_module"]["Intent"]]
|
||||
else self.config["Intent"][
|
||||
self.config["selected_module"]["Intent"]
|
||||
]["type"]
|
||||
),
|
||||
self.config["Intent"][self.config["selected_module"]["Intent"]],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -64,19 +86,33 @@ class WebSocketServer:
|
||||
host = server_config["ip"]
|
||||
port = server_config["port"]
|
||||
|
||||
self.logger.bind(tag=TAG).info("Server is running at ws://{}:{}", get_local_ip(), port)
|
||||
self.logger.bind(tag=TAG).info("=======上面的地址是websocket协议地址,请勿用浏览器访问=======")
|
||||
async with websockets.serve(
|
||||
self._handle_connection,
|
||||
host,
|
||||
port
|
||||
):
|
||||
self.logger.bind(tag=TAG).info(
|
||||
"Server is running at ws://{}:{}/xiaozhi/v1/", get_local_ip(), port
|
||||
)
|
||||
self.logger.bind(tag=TAG).info(
|
||||
"=======上面的地址是websocket协议地址,请勿用浏览器访问======="
|
||||
)
|
||||
self.logger.bind(tag=TAG).info(
|
||||
"如想测试websocket请用谷歌浏览器打开test目录下的test_page.html"
|
||||
)
|
||||
self.logger.bind(tag=TAG).info(
|
||||
"=============================================================\n"
|
||||
)
|
||||
async with websockets.serve(self._handle_connection, host, port):
|
||||
await asyncio.Future()
|
||||
|
||||
async def _handle_connection(self, websocket):
|
||||
"""处理新连接,每次创建独立的ConnectionHandler"""
|
||||
# 创建ConnectionHandler时传入当前server实例
|
||||
handler = ConnectionHandler(self.config, self._vad, self._asr, self._llm, self._tts, self._memory, self.intent)
|
||||
handler = ConnectionHandler(
|
||||
self.config,
|
||||
self._vad,
|
||||
self._asr,
|
||||
self._llm,
|
||||
self._tts,
|
||||
self._memory,
|
||||
self.intent,
|
||||
)
|
||||
self.active_connections.add(handler)
|
||||
try:
|
||||
await handler.handle_connection(websocket)
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
async def _get_device_status(conn, device_name, device_type, property_name):
|
||||
"""获取设备状态"""
|
||||
status = await get_iot_status(conn, device_type, property_name)
|
||||
@@ -13,6 +14,7 @@ async def _get_device_status(conn, device_name, device_type, property_name):
|
||||
raise Exception(f"你的设备不支持{device_name}控制")
|
||||
return status
|
||||
|
||||
|
||||
async def _set_device_property(conn, device_name, device_type, method_name, property_name, new_value=None, action=None, step=10):
|
||||
"""设置设备属性"""
|
||||
current_value = await _get_device_status(conn, device_name, device_type, property_name)
|
||||
@@ -32,9 +34,11 @@ async def _set_device_property(conn, device_name, device_type, method_name, prop
|
||||
await send_iot_conn(conn, device_type, method_name, {property_name: current_value})
|
||||
return current_value
|
||||
|
||||
|
||||
def _handle_device_action(conn, func, success_message, error_message, *args, **kwargs):
|
||||
"""处理设备操作的通用函数"""
|
||||
future = asyncio.run_coroutine_threadsafe(func(conn, *args, **kwargs), conn.loop)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
func(conn, *args, **kwargs), conn.loop)
|
||||
try:
|
||||
result = future.result()
|
||||
logger.bind(tag=TAG).info(f"{success_message}: {result}")
|
||||
@@ -45,6 +49,7 @@ def _handle_device_action(conn, func, success_message, error_message, *args, **k
|
||||
response = f"{error_message}: {e}"
|
||||
return ActionResponse(action=Action.RESPONSE, result=None, response=response)
|
||||
|
||||
|
||||
# 设备控制
|
||||
handle_device_function_desc = {
|
||||
"type": "function",
|
||||
@@ -52,9 +57,9 @@ handle_device_function_desc = {
|
||||
"name": "handle_device",
|
||||
"description": (
|
||||
"用户想要获取或者设置设备的音量/亮度大小,或者用户觉得声音/亮度过高或过低,或者用户想提高或降低音量/亮度。"
|
||||
"比如用户说现在亮度多少,参数为:device_type:Backlight,action:get。"
|
||||
"比如用户说现在亮度多少,参数为:device_type:Screen,action:get。"
|
||||
"比如用户说设置音量为50,参数为:device_type:Speaker,action:set,value:50。"
|
||||
"比如用户说亮度太高了,参数为:device_type:Backlight,action:lower。"
|
||||
"比如用户说亮度太高了,参数为:device_type:Screen,action:lower。"
|
||||
"比如用户说调大音量,参数为:device_type:Speaker,action:raise。"
|
||||
),
|
||||
"parameters": {
|
||||
@@ -62,7 +67,7 @@ handle_device_function_desc = {
|
||||
"properties": {
|
||||
"device_type": {
|
||||
"type": "string",
|
||||
"description": "设备类型,可选值:Speaker(音量),Backlight(亮度)"
|
||||
"description": "设备类型,可选值:Speaker(音量),Screen(亮度)"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
@@ -78,18 +83,19 @@ handle_device_function_desc = {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@register_function('handle_device', handle_device_function_desc, ToolType.IOT_CTL)
|
||||
def handle_device(conn, device_type: str, action: str, value: int = None):
|
||||
if device_type == "Speaker":
|
||||
method_name, property_name, device_name = "SetVolume", "volume", "音量"
|
||||
elif device_type == "Backlight":
|
||||
elif device_type == "Screen":
|
||||
method_name, property_name, device_name = "SetBrightness", "brightness", "亮度"
|
||||
else:
|
||||
raise Exception(f"未识别的设备类型: {device_type}")
|
||||
|
||||
|
||||
if action not in ["get", "set", "raise", "lower"]:
|
||||
raise Exception(f"未识别的动作名称: {action}")
|
||||
|
||||
|
||||
if action == "get":
|
||||
# get
|
||||
return _handle_device_action(
|
||||
|
||||
@@ -10,7 +10,7 @@ HASS_CACHE = {}
|
||||
def append_devices_to_prompt(conn):
|
||||
if conn.use_function_call_mode:
|
||||
funcs = conn.config["Intent"]["function_call"].get("functions", [])
|
||||
if "hass_get_state" in funcs or "hass_get_state" in funcs:
|
||||
if "hass_get_state" in funcs or "hass_set_state" in funcs:
|
||||
prompt = "下面是我家智能设备,可以通过homeassistant控制\n"
|
||||
devices = conn.config["plugins"]["home_assistant"].get("devices", [])
|
||||
if len(devices) == 0:
|
||||
@@ -27,9 +27,13 @@ def initialize_hass_handler(conn):
|
||||
if HASS_CACHE == {}:
|
||||
if conn.use_function_call_mode:
|
||||
funcs = conn.config["Intent"]["function_call"].get("functions", [])
|
||||
if "hass_get_state" in funcs or "hass_get_state" in funcs:
|
||||
HASS_CACHE['base_url'] = conn.config["plugins"]["home_assistant"].get("base_url")
|
||||
HASS_CACHE['api_key'] = conn.config["plugins"]["home_assistant"].get("api_key")
|
||||
if "hass_get_state" in funcs or "hass_set_state" in funcs:
|
||||
HASS_CACHE["base_url"] = conn.config["plugins"]["home_assistant"].get(
|
||||
"base_url"
|
||||
)
|
||||
HASS_CACHE["api_key"] = conn.config["plugins"]["home_assistant"].get(
|
||||
"api_key"
|
||||
)
|
||||
|
||||
check_model_key("home_assistant", HASS_CACHE['api_key'])
|
||||
check_model_key("home_assistant", HASS_CACHE["api_key"])
|
||||
return HASS_CACHE
|
||||
|
||||
@@ -23,5 +23,4 @@ bs4==0.0.2
|
||||
modelscope==1.23.2
|
||||
sherpa_onnx==1.11.0
|
||||
mcp==1.4.1
|
||||
|
||||
cnlunar==0.2.0
|
||||
|
||||