Merge branch 'main' into test-pr-sherpa-asr

This commit is contained in:
Huang
2025-03-19 23:21:23 +08:00
committed by GitHub
50 changed files with 1410 additions and 602 deletions
+20 -3
View File
@@ -57,6 +57,13 @@
</picture> </picture>
</a> </a>
</td> </td>
<td>
<a href="https://www.bilibili.com/video/BV1kgA2eYEQ9" target="_blank">
<picture>
<img alt="成本最低配置" src="docs/images/demo4.png" />
</picture>
</a>
</td>
</tr> </tr>
<tr> <tr>
<td> <td>
@@ -74,9 +81,16 @@
</a> </a>
</td> </td>
<td> <td>
<a href="https://www.bilibili.com/video/BV1kgA2eYEQ9" target="_blank"> <a href="https://www.bilibili.com/video/BV1Z8XuYZEAS" target="_blank">
<picture> <picture>
<img alt="成本最低配置" src="docs/images/demo4.png" /> <img alt="天气插件" src="docs/images/demo8.png" />
</picture>
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV178XuYfEpi" target="_blank">
<picture>
<img alt="IOT指令控制设备" src="docs/images/demo9.png" />
</picture> </picture>
</a> </a>
</td> </td>
@@ -143,12 +157,13 @@ server:
支持 EdgeTTS(默认)、火山引擎豆包 TTS 等多种 TTS 接口,满足语音合成需求。 支持 EdgeTTS(默认)、火山引擎豆包 TTS 等多种 TTS 接口,满足语音合成需求。
- **记忆功能** - **记忆功能**
支持超长记忆、本地总结记忆、无记忆三种模式,满足不同场景需求。 支持超长记忆、本地总结记忆、无记忆三种模式,满足不同场景需求。
- **IOT功能**
支持管理注册设备IOT功能,支持基于对话上下文语境下的智能物联网控制。
### 正在开发 🚧 ### 正在开发 🚧
- 多种心情模式 - 多种心情模式
- 智控台webui - 智控台webui
- iot功能
想了解具体开发进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3) 想了解具体开发进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3)
@@ -186,6 +201,7 @@ server:
| TTS | CosyVoiceSiliconflow | 接口调用 | 消耗 token | 需申请硅基流动 API 密钥;输出格式为 wav | | TTS | CosyVoiceSiliconflow | 接口调用 | 消耗 token | 需申请硅基流动 API 密钥;输出格式为 wav |
| TTS | TTS302AI | 接口调用 | 消耗 token | [点击创建密钥](https://dash.302.ai/apis/list) | | TTS | TTS302AI | 接口调用 | 消耗 token | [点击创建密钥](https://dash.302.ai/apis/list) |
| TTS | CozeCnTTS | 接口调用 | 消耗 token | 需提供 Coze API key;输出格式为 wav | | TTS | CozeCnTTS | 接口调用 | 消耗 token | 需提供 Coze API key;输出格式为 wav |
| TTS | GizwitsTTS | 接口调用 | 消耗 token | [点击创建密钥](https://agentrouter.gizwitsapi.com) |
| TTS | ACGNTTS | 接口调用 | 消耗 token | [联系网站管理员购买密钥](www.ttson.cn) | | TTS | ACGNTTS | 接口调用 | 消耗 token | [联系网站管理员购买密钥](www.ttson.cn) |
| TTS | OpenAITTS | 接口调用 | 消耗 token | 境外使用,境外购买 | | TTS | OpenAITTS | 接口调用 | 消耗 token | 境外使用,境外购买 |
| TTS | FishSpeech | 接口调用 | 免费/自定义 | 本地启动 TTS 服务;启动方法见配置文件内说明 | | TTS | FishSpeech | 接口调用 | 免费/自定义 | 本地启动 TTS 服务;启动方法见配置文件内说明 |
@@ -211,6 +227,7 @@ server:
| ASR | SherpaASR | 本地使用 | 免费 | | | ASR | SherpaASR | 本地使用 | 免费 | |
| ASR | DoubaoASR | 接口调用 | 收费 | | | ASR | DoubaoASR | 接口调用 | 收费 | |
--- ---
### Memory 记忆存储 ### Memory 记忆存储
+1 -1
View File
@@ -35,7 +35,7 @@ John2025.3.11,广州
### 1、成为普通贡献者 ### 1、成为普通贡献者
Fork 项目,提交 PR,由开发者审核后合入主分支。 Fork 项目,提交 PR,由开发者审核后合入主分支。
### 2、成为开发者 ### 2、成为开发者
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+1 -1
View File
@@ -18,6 +18,6 @@ xiaozhi-esp32-server
# manager-web 、manager-api接口协议 # manager-web 、manager-api接口协议
[manager前后端接口协议](https://app.apifox.com/invite/project?token=H_8qhgfjUeaAL0wybghgU) [manager前后端接口协议](https://app.apifox.com/invite/project?token=eXg2_tUv85q-gc3ZRowmn)
[前端页面设计图](https://codesign.qq.com/app/s/526108506410828) [前端页面设计图](https://codesign.qq.com/app/s/526108506410828)
@@ -1,99 +0,0 @@
package xiaozhi.common.aspect;
import cn.hutool.core.collection.CollUtil;
import xiaozhi.common.annotation.DataFilter;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.interceptor.DataScope;
import xiaozhi.common.user.UserDetail;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.enums.SuperAdminEnum;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
/**
* 数据过滤,切面处理类
* Copyright (c) 人人开源 All rights reserved.
* Website: https://www.renren.io
*/
@Aspect
@Component
public class DataFilterAspect {
@Pointcut("@annotation(xiaozhi.common.annotation.DataFilter)")
public void dataFilterCut() {
}
@Before("dataFilterCut()")
public void dataFilter(JoinPoint point) {
Object params = point.getArgs()[0];
if (params != null && params instanceof Map) {
UserDetail user = SecurityUser.getUser();
//如果是超级管理员,则不进行数据过滤
if (user.getSuperAdmin() == SuperAdminEnum.YES.value()) {
return;
}
try {
//否则进行数据过滤
Map map = (Map) params;
String sqlFilter = getSqlFilter(user, point);
map.put(Constant.SQL_FILTER, new DataScope(sqlFilter));
} catch (Exception e) {
}
return;
}
throw new RenException(ErrorCode.DATA_SCOPE_PARAMS_ERROR);
}
/**
* 获取数据过滤的SQL
*/
private String getSqlFilter(UserDetail user, JoinPoint point) throws Exception {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = point.getTarget().getClass().getDeclaredMethod(signature.getName(), signature.getParameterTypes());
DataFilter dataFilter = method.getAnnotation(DataFilter.class);
//获取表的别名
String tableAlias = dataFilter.tableAlias();
if (StringUtils.isNotBlank(tableAlias)) {
tableAlias += ".";
}
StringBuilder sqlFilter = new StringBuilder();
sqlFilter.append(" (");
//部门ID列表
List<Long> deptIdList = user.getDeptIdList();
if (CollUtil.isNotEmpty(deptIdList)) {
sqlFilter.append(tableAlias).append(dataFilter.deptId());
sqlFilter.append(" in(").append(StringUtils.join(deptIdList, ",")).append(")");
}
//查询本人数据
if (CollUtil.isNotEmpty(deptIdList)) {
sqlFilter.append(" or ");
}
sqlFilter.append(tableAlias).append(dataFilter.userId()).append("=").append(user.getId());
sqlFilter.append(")");
return sqlFilter.toString();
}
}
@@ -73,10 +73,6 @@ public interface Constant {
* 排序方式 * 排序方式
*/ */
String ORDER = "order"; String ORDER = "order";
/**
* token header
*/
String TOKEN_HEADER = "token";
String AUTHORIZATION = "Authorization"; String AUTHORIZATION = "Authorization";
@@ -34,9 +34,6 @@ public class FieldMetaObjectHandler implements MetaObjectHandler {
//创建时间 //创建时间
strictInsertFill(metaObject, CREATE_DATE, Date.class, date); strictInsertFill(metaObject, CREATE_DATE, Date.class, date);
//创建者所属部门
strictInsertFill(metaObject, DEPT_ID, Long.class, user.getDeptId());
//更新者 //更新者
strictInsertFill(metaObject, UPDATER, Long.class, user.getId()); strictInsertFill(metaObject, UPDATER, Long.class, user.getId());
//更新时间 //更新时间
@@ -3,7 +3,6 @@ package xiaozhi.common.user;
import lombok.Data; import lombok.Data;
import java.io.Serializable; import java.io.Serializable;
import java.util.List;
/** /**
* 登录用户信息 * 登录用户信息
@@ -14,19 +13,7 @@ import java.util.List;
public class UserDetail implements Serializable { public class UserDetail implements Serializable {
private Long id; private Long id;
private String username; private String username;
private String realName;
private String headUrl;
private Integer gender;
private String email;
private String mobile;
private Long deptId;
private String password;
private Integer status;
private Integer superAdmin; private Integer superAdmin;
private String token; private String token;
/** private Integer status;
* 部门数据权限
*/
private List<Long> deptIdList;
} }
@@ -1,6 +1,5 @@
package xiaozhi.common.utils; package xiaozhi.common.utils;
import xiaozhi.common.constant.Constant;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -8,6 +7,8 @@ import org.springframework.util.DigestUtils;
import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.ServletRequestAttributes;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import java.util.Date; import java.util.Date;
import java.util.Enumeration; import java.util.Enumeration;
@@ -30,13 +31,14 @@ public class HttpContextUtils {
return ((ServletRequestAttributes) requestAttributes).getRequest(); return ((ServletRequestAttributes) requestAttributes).getRequest();
} }
public static String getToken() { public static String getToken(String authorization) {
HttpServletRequest httpRequest = getHttpServletRequest(); String token;
String token = httpRequest.getHeader(Constant.TOKEN_HEADER); if (StringUtils.isBlank(authorization) && authorization.contains("Bearer ")) {
throw new RenException(ErrorCode.UNAUTHORIZED);
//如果header中不存在token,则从参数中获取token }
token = authorization.replace("Bearer ", "");
if (StringUtils.isBlank(token)) { if (StringUtils.isBlank(token)) {
token = httpRequest.getParameter(Constant.TOKEN_HEADER); throw new RenException(ErrorCode.TOKEN_NOT_EMPTY);
} }
return token; return token;
} }
@@ -1,16 +1,10 @@
package xiaozhi.common.utils; package xiaozhi.common.utils;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.page.PageData;
import xiaozhi.modules.security.user.SecurityUser;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import org.apache.commons.lang3.StringUtils; import xiaozhi.common.exception.ErrorCode;
import java.io.Serializable; import java.io.Serializable;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** /**
* 响应数据 * 响应数据
@@ -42,9 +36,6 @@ public class Result<T> implements Serializable {
return this; return this;
} }
public boolean success() {
return code == 0;
}
public Result<T> error() { public Result<T> error() {
this.code = ErrorCode.INTERNAL_SERVER_ERROR; this.code = ErrorCode.INTERNAL_SERVER_ERROR;
@@ -25,11 +25,10 @@ import xiaozhi.modules.security.user.SecurityUser;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@Tag(name = "设备管理")
@AllArgsConstructor @AllArgsConstructor
@RestController @RestController
@RequestMapping("/device") @RequestMapping("/device")
@Tag(name = "设备管理")
public class DeviceController { public class DeviceController {
private final DeviceService deviceService; private final DeviceService deviceService;
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
@@ -70,7 +70,9 @@ public class ShiroConfig {
filterMap.put("/v3/api-docs/**", "anon"); filterMap.put("/v3/api-docs/**", "anon");
filterMap.put("/doc.html", "anon"); filterMap.put("/doc.html", "anon");
filterMap.put("/favicon.ico", "anon"); filterMap.put("/favicon.ico", "anon");
filterMap.put("/user/**", "anon"); filterMap.put("/user/captcha", "anon");
filterMap.put("/user/login", "anon");
filterMap.put("/user/register", "anon");
filterMap.put("/**", "oauth2"); filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap); shiroFilter.setFilterChainDefinitionMap(filterMap);
@@ -4,20 +4,21 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.TokenDTO; import xiaozhi.common.page.TokenDTO;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.AssertUtils; import xiaozhi.common.validator.AssertUtils;
import xiaozhi.modules.security.dao.SysUserTokenDao;
import xiaozhi.modules.security.dto.LoginDTO; import xiaozhi.modules.security.dto.LoginDTO;
import xiaozhi.modules.security.password.PasswordUtils; import xiaozhi.modules.security.password.PasswordUtils;
import xiaozhi.modules.security.service.CaptchaService; import xiaozhi.modules.security.service.CaptchaService;
import xiaozhi.modules.security.service.SysUserTokenService; import xiaozhi.modules.security.service.SysUserTokenService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO; import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.service.SysUserService; import xiaozhi.modules.sys.service.SysUserService;
@@ -26,7 +27,6 @@ import java.io.IOException;
/** /**
* 登录控制层 * 登录控制层
*/ */
@Tag(name = "登录管理")
@AllArgsConstructor @AllArgsConstructor
@RestController @RestController
@RequestMapping("/user") @RequestMapping("/user")
@@ -36,7 +36,6 @@ public class LoginController {
private final SysUserTokenService sysUserTokenService; private final SysUserTokenService sysUserTokenService;
private final CaptchaService captchaService; private final CaptchaService captchaService;
private static final Logger logger = LoggerFactory.getLogger(LoginController.class);
@GetMapping("/captcha") @GetMapping("/captcha")
@Operation(summary = "验证码") @Operation(summary = "验证码")
@@ -79,32 +78,31 @@ public class LoginController {
} }
// 按照用户名获取用户 // 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername()); SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
if (userDTO != null){ if (userDTO != null) {
throw new RenException("此手机号码已经注册过"); throw new RenException("此手机号码已经注册过");
} }
userDTO = new SysUserDTO(); userDTO = new SysUserDTO();
userDTO.setUsername(login.getUsername()); userDTO.setUsername(login.getUsername());
userDTO.setPassword(login.getPassword()); userDTO.setPassword(login.getPassword());
sysUserService.save(userDTO); sysUserService.save(userDTO);
return new Result<Void>(); return new Result<>();
} }
@GetMapping("/info") @GetMapping("/info")
@Operation(summary = "用户信息获取") @Operation(summary = "用户信息获取")
public Result<SysUserDTO> info(@RequestHeader("Authorization")String authorization) { public Result<UserDetail> info() {
logger.info("the authorization:{}", authorization); UserDetail user = SecurityUser.getUser();
Result<UserDetail> result = new Result<>();
result.setData(user);
return result;
}
String token; @PutMapping("/change-password")
if (StringUtils.isBlank(authorization) && authorization.contains("Bearer ")) { @Operation(summary = "修改用户密码")
throw new RenException(ErrorCode.UNAUTHORIZED); public Result<?> changePassword(@RequestBody PasswordDTO passwordDTO) {
} Long userId = SecurityUser.getUserId();
token = authorization.replace("Bearer ", ""); sysUserTokenService.changePassword(userId, passwordDTO);
if (StringUtils.isBlank(token)) { return new Result<>();
throw new RenException(ErrorCode.UNAUTHORIZED);
}
SysUserDTO sysUserDTO = sysUserTokenService.getUserByToken(token);
Result result = new Result<SysUserDTO>();
return result.ok(sysUserDTO);
} }
} }
@@ -11,7 +11,6 @@ import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestMethod;
import xiaozhi.common.constant.Constant; import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.utils.HttpContextUtils; import xiaozhi.common.utils.HttpContextUtils;
import xiaozhi.common.utils.JsonUtils; import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
@@ -90,21 +89,10 @@ public class Oauth2Filter extends AuthenticatingFilter {
* 获取请求的token * 获取请求的token
*/ */
private String getRequestToken(HttpServletRequest httpRequest) { private String getRequestToken(HttpServletRequest httpRequest) {
String token; String token = null;
//从header中获取token //从header中获取token
String authorization = httpRequest.getHeader(Constant.AUTHORIZATION); String authorization = httpRequest.getHeader(Constant.AUTHORIZATION);
if (StringUtils.isBlank(authorization) && authorization.contains("Bearer ")) { if (StringUtils.isNotBlank(authorization) && authorization.startsWith("Bearer ")) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
token = authorization.replace("Bearer ", "");
//如果header中不存在token,则从参数中获取token
if (StringUtils.isBlank(token)) {
authorization = httpRequest.getParameter(Constant.AUTHORIZATION);
if (StringUtils.isBlank(authorization) && authorization.contains("Bearer ")) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
token = authorization.replace("Bearer ", ""); token = authorization.replace("Bearer ", "");
} }
return token; return token;
@@ -14,7 +14,6 @@ import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.user.UserDetail; import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.MessageUtils; import xiaozhi.common.utils.MessageUtils;
import xiaozhi.modules.security.controller.LoginController;
import xiaozhi.modules.security.entity.SysUserTokenEntity; import xiaozhi.modules.security.entity.SysUserTokenEntity;
import xiaozhi.modules.security.service.ShiroService; import xiaozhi.modules.security.service.ShiroService;
import xiaozhi.modules.sys.entity.SysUserEntity; import xiaozhi.modules.sys.entity.SysUserEntity;
@@ -82,8 +81,6 @@ public class Oauth2Realm extends AuthorizingRealm {
//转换成UserDetail对象 //转换成UserDetail对象
UserDetail userDetail = ConvertUtils.sourceToTarget(userEntity, UserDetail.class); UserDetail userDetail = ConvertUtils.sourceToTarget(userEntity, UserDetail.class);
//获取用户对应的部门数据权限
userDetail.setDeptIdList(null);
userDetail.setToken(accessToken); userDetail.setToken(accessToken);
//账号锁定 //账号锁定
@@ -1,14 +1,12 @@
package xiaozhi.modules.security.service; package xiaozhi.modules.security.service;
import xiaozhi.common.page.PageData;
import xiaozhi.common.page.TokenDTO; import xiaozhi.common.page.TokenDTO;
import xiaozhi.common.service.BaseService; import xiaozhi.common.service.BaseService;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
import xiaozhi.modules.security.entity.SysUserTokenEntity; import xiaozhi.modules.security.entity.SysUserTokenEntity;
import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO; import xiaozhi.modules.sys.dto.SysUserDTO;
import java.util.Map;
/** /**
* 用户Token * 用户Token
* Copyright (c) 人人开源 All rights reserved. * Copyright (c) 人人开源 All rights reserved.
@@ -32,4 +30,12 @@ public interface SysUserTokenService extends BaseService<SysUserTokenEntity> {
*/ */
void logout(Long userId); void logout(Long userId);
/**
* 修改密码
*
* @param userId
* @param passwordDTO
*/
void changePassword(Long userId, PasswordDTO passwordDTO);
} }
@@ -2,6 +2,7 @@ package xiaozhi.modules.security.service.impl;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.TokenDTO; import xiaozhi.common.page.TokenDTO;
@@ -12,7 +13,7 @@ import xiaozhi.modules.security.dao.SysUserTokenDao;
import xiaozhi.modules.security.entity.SysUserTokenEntity; import xiaozhi.modules.security.entity.SysUserTokenEntity;
import xiaozhi.modules.security.oauth2.TokenGenerator; import xiaozhi.modules.security.oauth2.TokenGenerator;
import xiaozhi.modules.security.service.SysUserTokenService; import xiaozhi.modules.security.service.SysUserTokenService;
import org.springframework.stereotype.Service; import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO; import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.service.SysUserService; import xiaozhi.modules.sys.service.SysUserService;
@@ -81,6 +82,9 @@ public class SysUserTokenServiceImpl extends BaseServiceImpl<SysUserTokenDao, Sy
@Override @Override
public SysUserDTO getUserByToken(String token) { public SysUserDTO getUserByToken(String token) {
SysUserTokenEntity userToken = baseDao.getByToken(token); SysUserTokenEntity userToken = baseDao.getByToken(token);
if (null == userToken) {
throw new RenException(ErrorCode.TOKEN_INVALID);
}
Date now = new Date(); Date now = new Date();
if (userToken.getExpireDate().before(now)) { if (userToken.getExpireDate().before(now)) {
@@ -97,4 +101,14 @@ public class SysUserTokenServiceImpl extends BaseServiceImpl<SysUserTokenDao, Sy
Date expireDate = DateUtil.offsetMinute(new Date(), -1); Date expireDate = DateUtil.offsetMinute(new Date(), -1);
baseDao.logout(userId, expireDate); baseDao.logout(userId, expireDate);
} }
@Override
public void changePassword(Long userId, PasswordDTO passwordDTO) {
// 修改密码
sysUserService.changePassword(userId, passwordDTO);
// 使 token 失效,后需要重新登录
Date expireDate = DateUtil.offsetMinute(new Date(), -1);
baseDao.logout(userId, expireDate);
}
} }
@@ -46,11 +46,4 @@ public class SecurityUser {
public static Long getUserId() { public static Long getUserId() {
return getUser().getId(); return getUser().getId();
} }
/**
* 获取部门ID
*/
public static Long getDeptId() {
return getUser().getDeptId();
}
} }
@@ -3,9 +3,9 @@ package xiaozhi.modules.sys.entity;
import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import xiaozhi.common.entity.BaseEntity;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import xiaozhi.common.entity.BaseEntity;
import java.util.Date; import java.util.Date;
@@ -32,11 +32,6 @@ public class SysUserEntity extends BaseEntity {
* 状态 0:停用 1:正常 * 状态 0:停用 1:正常
*/ */
private Integer status; private Integer status;
/**
* 更新者
*/
@TableField(fill = FieldFill.INSERT)
private Date createDate;
/** /**
* 更新者 * 更新者
*/ */
@@ -47,10 +42,5 @@ public class SysUserEntity extends BaseEntity {
*/ */
@TableField(fill = FieldFill.INSERT_UPDATE) @TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateDate; private Date updateDate;
/**
* 部门名称
*/
@TableField(exist = false)
private String deptName;
} }
@@ -1,6 +1,7 @@
package xiaozhi.modules.sys.service; package xiaozhi.modules.sys.service;
import xiaozhi.common.service.BaseService; import xiaozhi.common.service.BaseService;
import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO; import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.entity.SysUserEntity; import xiaozhi.modules.sys.entity.SysUserEntity;
@@ -18,4 +19,5 @@ public interface SysUserService extends BaseService<SysUserEntity> {
void delete(Long[] ids); void delete(Long[] ids);
void changePassword(Long userId, PasswordDTO passwordDTO);
} }
@@ -10,6 +10,7 @@ import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.security.password.PasswordUtils; import xiaozhi.modules.security.password.PasswordUtils;
import xiaozhi.modules.sys.dao.SysUserDao; import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO; import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.entity.SysUserEntity; import xiaozhi.modules.sys.entity.SysUserEntity;
import xiaozhi.modules.sys.enums.SuperAdminEnum; import xiaozhi.modules.sys.enums.SuperAdminEnum;
@@ -81,6 +82,31 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
baseDao.deleteBatchIds(Arrays.asList(ids)); baseDao.deleteBatchIds(Arrays.asList(ids));
} }
@Override
public void changePassword(Long userId, PasswordDTO passwordDTO) {
SysUserEntity sysUserEntity = sysUserDao.selectById(userId);
if (null == sysUserEntity) {
throw new RenException(ErrorCode.TOKEN_INVALID);
}
// 判断旧密码是否正确
if (!PasswordUtils.matches(passwordDTO.getPassword(), sysUserEntity.getPassword())) {
throw new RenException("旧密码输入错误");
}
//新密码强度
if (!isStrongPassword(passwordDTO.getNewPassword())) {
throw new RenException(ErrorCode.PASSWORD_WEAK_ERROR);
}
//密码加密
String password = PasswordUtils.encode(passwordDTO.getNewPassword());
sysUserEntity.setPassword(password);
updateById(sysUserEntity);
}
private Long getUserCount() { private Long getUserCount() {
QueryWrapper<SysUserEntity> queryWrapper = new QueryWrapper<>(); QueryWrapper<SysUserEntity> queryWrapper = new QueryWrapper<>();
return baseDao.selectCount(queryWrapper); return baseDao.selectCount(queryWrapper);
+47 -14
View File
@@ -18,20 +18,6 @@ export default {
}) })
}).send() }).send()
}, },
// 获取用户信息
getUserInfo(callback) {
RequestService.sendRequest().url(`${getServiceUrl()}/api/v1/user/info`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail(() => {
RequestService.reAjaxFun(() => {
this.getUserInfo()
})
}).send()
},
// 获取设备信息 // 获取设备信息
getHomeList(callback) { getHomeList(callback) {
RequestService.sendRequest().url(`${getServiceUrl()}/api/v1/user/device/bind`) RequestService.sendRequest().url(`${getServiceUrl()}/api/v1/user/device/bind`)
@@ -173,5 +159,52 @@ export default {
}); });
}).send(); }).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()
.url(`${getServiceUrl()}/api/v1/user/info`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('接口请求失败:', err)
RequestService.reAjaxFun(() => {
this.getUserInfo(callback)
})
}).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();
},
} }
@@ -1,21 +1,22 @@
<template> <template>
<el-dialog :visible.sync="visible" width="480px" center> <el-dialog :visible.sync="visible" width="400px" center >
<div style="margin: 0 20px 20px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> <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: 36px;height: 36px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;"> <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: 16px;height: 14px;" /> <img src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
</div> </div>
添加设备 添加设备
</div> </div>
<div style="height: 1px;background: #e8f0ff;" /> <div style="height: 1px;background: #e8f0ff;" />
<div style="margin: 30px 20px;"> <div style="margin: 22px 15px;">
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;"> <div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
<div style="color: red;display: inline-block;">*</div>验证码 <div style="color: red;display: inline-block;">*</div>
<span style="font-size: 11px"> 验证码</span>
</div> </div>
<div class="input-46" style="margin-top: 10px;"> <div class="input-46" style="margin-top: 12px;">
<el-input placeholder="请输入设备播报的6位数验证码.." v-model="deviceCode" /> <el-input placeholder="请输入设备播报的6位数验证码.." v-model="deviceCode" />
</div> </div>
</div> </div>
<div style="display: flex;margin: 0 20px;gap: 10px;"> <div style="display: flex;margin: 15px 15px;gap: 7px;">
<div class="dialog-btn" @click="confirm"> <div class="dialog-btn" @click="confirm">
确定 确定
</div> </div>
@@ -53,7 +54,6 @@ export default {
<style scoped> <style scoped>
.input-46 { .input-46 {
border: 1px solid #e4e6ef; border: 1px solid #e4e6ef;
background: #f6f8fb; background: #f6f8fb;
@@ -65,11 +65,26 @@ export default {
flex: 1; flex: 1;
border-radius: 23px; border-radius: 23px;
background: #5778ff; background: #5778ff;
height: 46px; height: 40px;
font-weight: 500; font-weight: 500;
font-size: 14px; font-size: 12px;
color: #fff; color: #fff;
line-height: 46px; line-height: 40px;
text-align: center; text-align: center;
} }
::v-deep .el-dialog {
border-radius: 15px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
::v-deep .el-dialog__headerbtn {
display: none;
}
::v-deep .el-dialog__body {
padding: 4px 6px;
}
::v-deep .el-dialog__header{
padding: 10px;
}
</style> </style>
@@ -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/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
</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 placeholder="请输入智慧体名称.." v-model="wisdomBodyName" />
</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>
</template>
<script>
import userApi from '@/apis/module/user';
export default {
name: 'AddWisdomBodyDialog',
props: {
visible: { type: Boolean, required: true }
},
data() {
return { wisdomBodyName: "" }
},
methods: {
confirm() {
if (!this.wisdomBodyName.trim()) {
this.$message.error('请输入智慧体名称');
return;
}
userApi.addAgent(this.wisdomBodyName, (res) => {
this.$message.success('添加成功');
this.$emit('confirm', res);
this.$emit('update:visible', false);
this.wisdomBodyName = "";
});
},
cancel() {
this.$emit('update:visible', false)
this.wisdomBodyName = ""
}
}
}
</script>
<style scoped>
.input-46 {
border: 1px solid #e4e6ef;
background: #f6f8fb;
border-radius: 15px;
}
.dialog-btn {
cursor: pointer;
flex: 1;
border-radius: 23px;
background: #5778ff;
height: 40px;
font-weight: 500;
font-size: 12px;
color: #fff;
line-height: 40px;
text-align: center;
}
::v-deep .el-dialog {
border-radius: 15px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
::v-deep .el-dialog__headerbtn {
display: none;
}
::v-deep .el-dialog__body {
padding: 4px 6px;
}
::v-deep .el-dialog__header{
padding: 10px;
}
</style>
+23 -19
View File
@@ -1,17 +1,20 @@
<template> <template>
<div class="device-item"> <div class="device-item">
<div style="display: flex;justify-content: space-between;"> <div style="display: flex;justify-content: space-between;">
<div style="font-weight: 700;font-size: 24px;text-align: left;color: #3d4566;"> <div style="font-weight: 700;font-size: 18px;text-align: left;color: #3d4566;">
{{ device.mac }} {{ device.agentName }}
</div> </div>
<div> <div>
<img src="@/assets/home/delete.png" alt="" <img src="@/assets/home/delete.png" alt=""
style="width: 24px;height: 24px;margin-right: 10px;" /> style="width: 18px;height: 18px;margin-right: 10px;" />
<img src="@/assets/home/info.png" alt="" style="width: 24px;height: 24px;" /> <img src="@/assets/home/info.png" alt="" style="width: 18px;height: 18px;" />
</div> </div>
</div> </div>
<div class="device-name"> <div class="device-name">
设备型号{{ device.model }} 设备型号{{ device.ttsModelName }}
</div>
<div class="device-name">
音色模型{{ device.ttsVoiceName }}
</div> </div>
<div style="display: flex;gap: 10px;align-items: center;"> <div style="display: flex;gap: 10px;align-items: center;">
<div class="settings-btn" @click="$emit('configure')"> <div class="settings-btn" @click="$emit('configure')">
@@ -23,12 +26,12 @@
<div class="settings-btn"> <div class="settings-btn">
历史对话 历史对话
</div> </div>
<el-switch v-model="switchValue" inactive-text="OTA升级:" :width="42" <div class="settings-btn" @click="$emit('deviceManage')">
style="margin-left: auto;" /> 设备管理
</div>
</div> </div>
<div class="version-info"> <div class="version-info">
<div>最近对话{{ device.lastConversation }}</div> <div>最近对话{{ device.lastConnectedAt }}</div>
<div>APP版本{{ device.appVersion }}</div>
</div> </div>
</div> </div>
</template> </template>
@@ -46,28 +49,28 @@ export default {
</script> </script>
<style scoped> <style scoped>
.device-item { .device-item {
width: 455px; width: 342px;
border-radius: 20px; border-radius: 20px;
background: #fafcfe; background: #fafcfe;
padding: 30px; padding: 22px;
box-sizing: border-box; box-sizing: border-box;
} }
.device-name { .device-name {
margin: 10px 0 14px; margin: 7px 0 10px;
font-weight: 400; font-weight: 400;
font-size: 14px; font-size: 11px;
color: #3d4566; color: #3d4566;
text-align: left; text-align: left;
} }
.settings-btn { .settings-btn {
font-weight: 500; font-weight: 500;
font-size: 14px; font-size: 10px;
color: #5778ff; color: #5778ff;
background: #e6ebff; background: #e6ebff;
width: 76px; width: 57px;
height: 28px; height: 21px;
line-height: 28px; line-height: 21px;
cursor: pointer; cursor: pointer;
border-radius: 14px; border-radius: 14px;
} }
@@ -75,9 +78,10 @@ export default {
.version-info { .version-info {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-top: 20px; margin-top: 15px;
font-size: 14px; font-size: 10px;
color: #979db1; color: #979db1;
font-weight: 400; font-weight: 400;
} }
</style> </style>
+90 -37
View File
@@ -1,31 +1,31 @@
<template> <template>
<el-header class="header"> <el-header class="header">
<div style="display: flex;justify-content: space-between;"> <div style="display: flex;justify-content: space-between;margin-top: 6px; ">
<div style="display: flex;align-items: center;gap: 10px;"> <div style="display: flex;align-items: center;gap: 10px;">
<img src="@/assets/xiaozhi-logo.png" alt="" style="width: 56px;height: 56px;" /> <img src="@/assets/xiaozhi-logo.png" alt="" style="width: 42px;height: 42px;" />
<img src="@/assets/xiaozhi-ai.png" alt="" style="width: 78px;height: 16px;" /> <img src="@/assets/xiaozhi-ai.png" alt="" style="width: 58px;height: 12px;" />
<div class="equipment-management" @click="goHome"> <div class="equipment-management" @click="goHome">
<img src="@/assets/home/equipment.png" alt="" style="width: 16px;height: 14px;" /> <img src="@/assets/home/equipment.png" alt="" style="width: 12px;height: 10px;" />
设备管理 智能体管理
</div> </div>
<div class="console"> <div class="console">
<i class="el-icon-s-grid" style="font-size: 14px;color: #979db1;" /> <i class="el-icon-s-grid" style="font-size: 10px;color: #979db1;" />
控制台 控制台
</div> </div>
<div class="equipment-management2"> <div class="equipment-management2">
设备管理 设备管理
<img src="@/assets/home/close.png" alt="" style="width: 8px;height: 8px;" /> <img src="@/assets/home/close.png" alt="" style="width: 6px;height: 6px;" />
</div> </div>
</div> </div>
<div style="display: flex;align-items: center;gap: 10px;"> <div style="display: flex;align-items: center;gap: 7px; margin-top: 2px;">
<div class="serach-box"> <div class="serach-box">
<el-input placeholder="输入名称搜索.." v-model="serach" /> <el-input placeholder="输入名称搜索.." v-model="serach" style="border: none; background: transparent;" @keyup.enter.native="handleSearch" />
<img src="@/assets/home/search.png" alt="" <img src="@/assets/home/search.png" alt=""
style="width: 16px;height: 16px;margin-right: 15px;cursor: pointer;" /> style="width: 14px;height: 14px;margin-right: 11px;cursor: pointer;" @click="handleSearch" />
</div> </div>
<img src="@/assets/home/avatar.png" alt="" style="width: 28px;height: 28px;" /> <img src="@/assets/home/avatar.png" alt="" style="width: 21px;height: 21px;" />
<div class="user-info"> <div class="user-info">
158 3632 4642 {{ userInfo.mobile || '加载中...' }}
</div> </div>
</div> </div>
</div> </div>
@@ -33,17 +33,57 @@
</template> </template>
<script> <script>
import userApi from '@/apis/module/user'
export default { export default {
name: 'HeaderBar', name: 'HeaderBar',
props: ['devices'], // 接收父组件设备列表
data() { data() {
return { serach: '' } return {
serach: '',
userInfo: {
mobile: ''
}
}
},
mounted() {
this.fetchUserInfo()
}, },
methods: { methods: {
goHome() { goHome() {
// 跳转到首页 // 跳转到首页
this.$router.push('/') this.$router.push('/')
},
// 获取用户信息
fetchUserInfo() {
userApi.getUserInfo(({data}) => {
this.userInfo = data.data
})
},
// 处理搜索
handleSearch() {
const searchValue = this.serach.trim();
let filteredDevices;
if (!searchValue) {
// 当搜索内容为空时,显示原始完整列表
filteredDevices = this.$parent.originalDevices;
} else {
// 过滤逻辑
filteredDevices = this.devices.filter(device => {
return device.agentName.includes(searchValue) ||
device.ttsModelName.includes(searchValue) ||
device.ttsVoiceName.includes(searchValue);
});
}
this.$emit('search-result', filteredDevices);
} }
} }
} }
</script> </script>
@@ -54,69 +94,82 @@ export default {
} }
.equipment-management { .equipment-management {
width: 110px; width: 82px;
height: 32px; height: 24px;
border-radius: 16px; border-radius: 12px;
background: #5778ff; background: #5778ff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 7px;
font-weight: 500; font-weight: 500;
color: #fff; color: #fff;
font-size: 14px; font-size: 10px;
} }
.equipment-management2 { .equipment-management2 {
width: 116px; width: 87px;
height: 30px; height: 22px;
border-radius: 15px; border-radius: 11px;
background: #fff; background: #fff;
display: flex; display: flex;
justify-content: center; justify-content: center;
font-size: 12px; font-size: 9px;
color: #979db1;
font-weight: 400; font-weight: 400;
gap: 10px; gap: 7px;
color: #3d4566; color: #3d4566;
margin-left: 20px; margin-left: 2px;
align-items: center; align-items: center;
} }
.header { .header {
background: #f6fcfe66; background: #f6fcfe66;
border: 1px solid #fff; border: 1px solid #fff;
height: 53px !important;
} }
.serach-box { .serach-box {
display: flex; display: flex;
width: 306px; width: 220px;
height: 40px; height: 30px;
border-radius: 20px; border-radius: 15px;
background-color: #e2f5f7; background-color: #f6fcfe66;
border: 1px solid #e4e6ef;
align-items: center; align-items: center;
padding: 0 7px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-right: 15px;
} }
.serach-box /deep/ .el-input__inner {
border-radius: 15px;
height: 100%;
width: 100%;
border: 0;
background: transparent;
padding-left: 12px;
}
.user-info { .user-info {
font-weight: 600; font-weight: 600;
font-size: 16px; font-size: 12px;
letter-spacing: -0.02px; letter-spacing: -0.02px;
text-align: left; text-align: left;
color: #3d4566; color: #3d4566;
} }
.console { .console {
width: 120px; width: 90px;
height: 30px; height: 22px;
border-radius: 15px; border-radius: 11px;
background: radial-gradient(50% 50% at 50% 50%, #fff 0%, #e8f0ff 100%); background: radial-gradient(50% 50% at 50% 50%, #fff 0%, #e8f0ff 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 12px; font-size: 9px;
color: #979db1; color: #979db1;
font-weight: 400; font-weight: 400;
gap: 10px; gap: 7px;
color: #979db1; margin-left: 15px;
margin-left: 20px;
} }
</style> </style>
+9
View File
@@ -39,6 +39,15 @@ const routes = [
return import('../views/register.vue') return import('../views/register.vue')
} }
}, },
// 新增设备管理页面路由
{
path: '/device-management',
name: 'DeviceManagement',
component: function () {
return import('../views/DeviceManagement.vue')
}
}
] ]
const router = new VueRouter({ const router = new VueRouter({
@@ -0,0 +1,169 @@
<template>
<div class="welcome">
<HeaderBar />
<el-main style="padding: 20px; display: flex; flex-direction: column;">
<div class="table-container">
<h3 class="device-list-title">设备列表</h3>
<el-button type="primary" class="add-device-btn" @click="handleAddDevice">
+ 添加设备
</el-button>
<el-table :data="deviceList" style="width: 100%; margin-top: 20px" border stripe>
<el-table-column label="设备型号" prop="model" flex></el-table-column>
<el-table-column label="固件版本" prop="firmwareVersion" width="140"></el-table-column>
<el-table-column label="Mac地址" prop="macAddress" width="220"></el-table-column>
<el-table-column label="绑定时间" prop="bindTime" width="260"></el-table-column>
<el-table-column label="最近对话" prop="lastConversation" width="100"></el-table-column>
<el-table-column label="备注" width="220">
<template slot-scope="scope">
<el-input v-if="scope.row.isEdit" v-model="scope.row.remark" size="mini" @blur="stopEditRemark(scope.$index)"></el-input>
<span v-else>
<i v-if="!scope.row.remark" class="el-icon-edit" @click="startEditRemark(scope.$index, scope.row)"></i>
<span v-else @click="startEditRemark(scope.$index, scope.row)">
{{ scope.row.remark }}
</span>
</span>
</template>
</el-table-column>
<el-table-column label="OTA升级" width="120">
<template slot-scope="scope">
<el-switch v-model="scope.row.otaSwitch" size="mini" active-color="#13ce66" inactive-color="#ff4949"></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="handleUnbind(scope.row)" style="color: #ff4949">
解绑
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div style="font-size: 12px; font-weight: 400; margin-top: auto; padding-top: 30px; color: #979db1;">
©2025 xiaozhi-esp32-server
</div>
<AddDeviceDialog :visible.sync="addDeviceDialogVisible" @added="handleDeviceAdded" />
</el-main>
</div>
</template>
<script>
import HeaderBar from "@/components/HeaderBar.vue";
import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
export default {
components: {HeaderBar, AddDeviceDialog },
data() {
return {
addDeviceDialogVisible: false,
deviceList: [
{
model: 'xingzhi-cube-0.96oled-wifi',
firmwareVersion: '1.4.6',
macAddress: 'fc:01:2c:c5:d5:7c',
bindTime: '2025-03-10 18:16:21',
lastConversation: '6 天前',
remark: '',
isEdit: false,
otaSwitch: false
},
{
model: 'xingzhi-board-1.3tft-ble',
firmwareVersion: '2.1.0',
macAddress: 'ac:12:3d:e7:f8:9a',
bindTime: '2025-03-12 09:30:15',
lastConversation: '4 天前',
remark: '测试设备',
isEdit: false,
otaSwitch: true
},
{
model: 'xingzhi-kit-0.91oled-4g',
firmwareVersion: '1.8.3',
macAddress: 'bc:45:6f:1e:2d:3c',
bindTime: '2025-03-15 14:22:08',
lastConversation: '2 天前',
remark: '生产环境设备',
isEdit: false,
otaSwitch: false
}
]
};
},
methods: {
handleAddDevice() {
// 添加设备逻辑
this.addDeviceDialogVisible = true;
},
startEditRemark(index, row) {
this.deviceList[index].isEdit = true;
},
stopEditRemark(index) {
this.deviceList[index].isEdit = false;
},
handleUnbind(device) {
// 解绑逻辑
console.log('解绑设备', device);
},
handleDeviceAdded(deviceCode) {
console.log('添加的智慧体名称:', deviceCode);
},
}
};
</script>
<style scoped>
.welcome {
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
flex-direction: column;
background-image: url("@/assets/home/background.png");
background-size: cover;
background-position: center;
-webkit-background-size: cover;
-o-background-size: cover;
}
.table-container {
background: #f9fafc;
padding: 20px;
border-radius: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
margin-top: 15px;
}
.add-device-btn {
float: right;
background: #409eff;
border: none;
border-radius: 10px;
width: 105px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
gap: 8px;
margin-bottom: 15px;
&:hover {
background: #3a8ee6;
}
}
.device-list-title {
float: left;
font-size: 18px;
font-weight: 700;
margin: 5px;
color: #2c3e50;
}
.el-icon-edit {
color: #409eff;
cursor: pointer;
font-size: 14px;
vertical-align: middle;
}
</style>
+58 -38
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="welcome"> <div class="welcome">
<!-- 公共头部 --> <!-- 公共头部 -->
<HeaderBar /> <HeaderBar :devices="devices" @search-result="handleSearchResult" />
<el-main style="padding: 20px;display: flex;flex-direction: column;"> <el-main style="padding: 20px;display: flex;flex-direction: column;">
<div> <div>
<!-- 首页内容 --> <!-- 首页内容 -->
@@ -21,23 +21,23 @@
</div> </div>
<div class="add-device-btn" @click="showAddDialog"> <div class="add-device-btn" @click="showAddDialog">
<div class="left-add"> <div class="left-add">
添加设备 添加智能体
</div> </div>
<div style="width: 23px;height: 13px;background: #5778ff;margin-left: -10px;" /> <div style="width: 23px;height: 13px;background: #5778ff;margin-left: -10px;" />
<div class="right-add"> <div class="right-add">
<i class="el-icon-right" style="font-size: 30px;color: #fff;" /> <i class="el-icon-right" style="font-size: 20px;color: #fff;" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div style="display: flex;flex-wrap: wrap;margin-top: 20px;gap: 20px;justify-content: space-between;box-sizing: border-box;"> <div style="display: flex;flex-wrap: wrap;margin-top: 20px;gap: 20px;justify-content: flex-start;box-sizing: border-box;">
<DeviceItem v-for="(item,index) in devices" :key="index" :device="item" @configure="goToRoleConfig" /> <DeviceItem v-for="(item,index) in devices" :key="index" :device="item" @configure="goToRoleConfig" @deviceManage="handleDeviceManage" />
</div> </div>
</div> </div>
<div style="font-size: 12px;font-weight: 400;margin-top: auto;padding-top: 30px;color: #979db1;"> <div style="font-size: 12px;font-weight: 400;margin-top: auto;padding-top: 30px;color: #979db1;">
©2025 xiaozhi-esp32-server ©2025 xiaozhi-esp32-server
</div> </div>
<AddDeviceDialog :visible.sync="addDeviceDialogVisible" @added="handleDeviceAdded" /> <AddWisdomBodyDialog :visible.sync="addDeviceDialogVisible" @confirm="handleWisdomBodyAdded" />
</el-main> </el-main>
</div> </div>
@@ -45,24 +45,24 @@
<script> <script>
import DeviceItem from '@/components/DeviceItem.vue' import DeviceItem from '@/components/DeviceItem.vue'
import AddDeviceDialog from '@/components/AddDeviceDialog.vue' import AddWisdomBodyDialog from '@/components/AddWisdomBodyDialog.vue'
import HeaderBar from '@/components/HeaderBar.vue' import HeaderBar from '@/components/HeaderBar.vue'
export default { export default {
name: 'HomePage', name: 'HomePage',
components: { DeviceItem, AddDeviceDialog, HeaderBar }, components: { DeviceItem, AddWisdomBodyDialog, HeaderBar },
data() { data() {
return { return {
addDeviceDialogVisible: false, addDeviceDialogVisible: false,
// 此处模拟设备列表(10条数据) devices: [],
devices: Array.from({ length: 10 }, (_, i) => ({ originalDevices: [],
id: i,
mac: 'CC:ba:97:11:a6:ac',
model: 'esp32-s3-touch-amoled-1.8',
lastConversation: '6天前',
appVersion: '1.1.0'
}))
} }
}, },
mounted() {
this.fetchAgentList();
},
methods: { methods: {
showAddDialog() { showAddDialog() {
this.addDeviceDialogVisible = true this.addDeviceDialogVisible = true
@@ -71,19 +71,39 @@ export default {
// 点击配置角色后跳转到角色配置页 // 点击配置角色后跳转到角色配置页
this.$router.push('/role-config') this.$router.push('/role-config')
}, },
handleDeviceAdded(deviceCode) { handleWisdomBodyAdded(res) {
// 根据需要处理添加设备后逻辑,比如刷新设备列表等 console.log('新增智能体响应', res);
console.log('设备验证码', deviceCode) this.fetchAgentList();
this.addDeviceDialogVisible = false;
},
handleDeviceManage() {
this.$router.push('/device-management');
},
// 获取智能体列表
fetchAgentList() {
import('@/apis/module/user').then(({ default: userApi }) => {
userApi.getAgentList(({data}) => {
this.originalDevices = data.data;
this.devices = data.data;
});
});
},
// 搜索更新智能体列表
handleSearchResult(filteredList) {
this.devices = filteredList; // 更新设备列表
} }
} }
} }
</script> </script>
<style scoped> <style scoped>
.welcome { .welcome {
min-width: 1200px; min-width: 900px;
min-height: 675px; min-height: 506px;
height: 100vh; height: 100vh;
display: flex;
flex-direction: column;
background-image: url("@/assets/home/background.png"); background-image: url("@/assets/home/background.png");
background-size: cover; background-size: cover;
/* 确保背景图像覆盖整个元素 */ /* 确保背景图像覆盖整个元素 */
@@ -95,8 +115,8 @@ export default {
/* 兼容老版本Opera浏览器 */ /* 兼容老版本Opera浏览器 */
} }
.add-device { .add-device {
height: 260px; height: 195px;
border-radius: 20px; border-radius: 15px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: linear-gradient( background: linear-gradient(
@@ -122,48 +142,48 @@ export default {
box-sizing: border-box; box-sizing: border-box;
/* 兼容老版本Opera浏览器 */ /* 兼容老版本Opera浏览器 */
.hellow-text { .hellow-text {
margin-left: 100px; margin-left: 75px;
color: #3d4566; color: #3d4566;
font-size: 44px; font-size: 33px;
font-weight: 700; font-weight: 700;
letter-spacing: 0; letter-spacing: 0;
} }
.hi-hint { .hi-hint {
font-weight: 400; font-weight: 400;
font-size: 12px; font-size: 10px;
text-align: left; text-align: left;
color: #818cae; color: #818cae;
margin-left: 100px; margin-left: 75px;
margin-top: 7px; margin-top: 5px;
} }
} }
.add-device-btn { .add-device-btn {
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: 100px; margin-left: 75px;
margin-top: 20px; margin-top: 15px;
cursor: pointer; cursor: pointer;
.left-add { .left-add {
width: 140px; width: 105px;
height: 46px; height: 34px;
border-radius: 23px; border-radius: 17px;
background: #5778ff; background: #5778ff;
color: #fff; color: #fff;
font-size: 14px; font-size: 10px;
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
line-height: 46px; line-height: 34px;
} }
.right-add { .right-add {
width: 46px; width: 34px;
height: 46px; height: 34px;
border-radius: 50%; border-radius: 50%;
background: #5778ff; background: #5778ff;
margin-left: -8px; margin-left: -6px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
+109 -79
View File
@@ -2,45 +2,33 @@
<div class="welcome"> <div class="welcome">
<!-- 公共头部 --> <!-- 公共头部 -->
<HeaderBar/> <HeaderBar/>
<el-main style="padding: 20px;display: flex;flex-direction: column;"> <el-main style="padding: 16px;display: flex;flex-direction: column;">
<div style="border-radius: 20px;background: #fafcfe;"> <div style="border-radius: 16px;background: #fafcfe; border: 1px solid #e8f0ff;">
<div <div
style="padding: 19px 30px;font-weight: 700;font-size: 24px;text-align: left;color: #3d4566;display: flex;gap: 16px;align-items: center;"> style="padding: 15px 24px;font-weight: 700;font-size: 19px;text-align: left;color: #3d4566;display: flex;gap: 13px;align-items: center;">
<div <div
style="width: 46px;height: 46px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;"> 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: 24px;height: 24px;"/> <img src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/>
</div> </div>
{{ deviceMac }} {{ deviceMac }}
</div> </div>
<div style="height: 1px;background: #e8f0ff;"/> <div style="height: 1px;background: #e8f0ff;"/>
<el-form ref="form" :model="form" label-width="90px"> <el-form ref="form" :model="form" label-width="72px">
<div style="padding: 20px 30px;max-width: 990px;"> <div style="padding: 16px 24px;max-width: 792px;">
<el-form-item label="助手昵称:"> <el-form-item label="助手昵称:">
<div class="input-46"> <div class="input-46" style="width: 100%; max-width: 412px;">
<el-input v-model="form.name"/> <el-input v-model="form.name"/>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="角色模版:"> <el-form-item label="角色模版:">
<div style="display: flex;gap: 10px;"> <div style="display: flex;gap: 8px;">
<div class="template-item"> <div v-for="template in templates" :key="template" class="template-item" @click="selectTemplate(template)">
台湾女友 {{ template }}
</div>
<div class="template-item">
土豆子
</div>
<div class="template-item">
英语老师
</div>
<div class="template-item">
好奇小男孩
</div>
<div class="template-item">
汪汪队队长
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="角色音色:"> <el-form-item label="角色音色:">
<div style="display: flex;gap: 10px;align-items: center;"> <div style="display: flex;gap: 8px;align-items: center;">
<div class="input-46" style="flex:1.4;"> <div class="input-46" style="flex:1.4;">
<el-select v-model="form.timbre" placeholder="请选择" style="width: 100%;"> <el-select v-model="form.timbre" placeholder="请选择" style="width: 100%;">
<el-option v-for="item in options" :key="item.value" :label="item.label" <el-option v-for="item in options" :key="item.value" :label="item.label"
@@ -56,45 +44,39 @@
</el-form-item> </el-form-item>
<el-form-item label="角色介绍:"> <el-form-item label="角色介绍:">
<div class="textarea-box"> <div class="textarea-box">
<el-input type="textarea" rows="6" resize="none" placeholder="请输入内容" <el-input type="textarea" rows="5" resize="none" placeholder="请输入内容"
v-model="form.introduction" maxlength="2000" show-word-limit/> v-model="form.introduction" maxlength="2000" show-word-limit/>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="记忆体:"> <el-form-item label="记忆体:">
<div class="textarea-box"> <div class="textarea-box">
<el-input type="textarea" rows="6" resize="none" placeholder="请输入内容" <el-input type="textarea" rows="5" resize="none" placeholder="请输入内容"
v-model="form.prompt" maxlength="1000"/> v-model="form.prompt" maxlength="1000"/>
<div class="prompt-bottom"> <div class="prompt-bottom">
<div style="display: flex;gap: 10px;align-items: center;"> <div style="display: flex;gap: 8px;align-items: center;">
<div style="color: #979db1;font-size: 14px;">当前记忆每次对话后重新生成</div> <div style="color: #979db1;font-size: 11px;">当前记忆每次对话后重新生成</div>
<div class="clear-btn"> <div class="clear-btn">
<i class="el-icon-delete-solid" style="font-size: 14px;"/> <i class="el-icon-delete-solid" style="font-size: 11px;"/>
清除 清除
</div> </div>
</div> </div>
<div style="color: #979db1;font-size:14px;">{{ form.prompt.length }}/1000</div> <div style="color: #979db1;font-size:11px;">{{ form.prompt.length }}/1000</div>
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="语言模型(内测):" class="lh-form-item"> <el-form-item v-for="model in models" :key="model.label" :label="model.label" class="model-item">
<div style="display: flex;gap: 10px;"> <el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="select-field">
<div class="input-46" style="width: 100%;"> <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value"/>
<el-select v-model="form.model" placeholder="请选择" style="width: 100%;"> </el-select>
<el-option v-for="item in options" :key="item.value" :label="item.label"
:value="item.value">
</el-option>
</el-select>
</div>
</div>
</el-form-item> </el-form-item>
<el-form-item label="" class="lh-form-item"> <el-form-item label="" class="lh-form-item" style="margin-top: -25px;">
<div style="color: #979db1;text-align: left;">除了Qwen <div style="color: #979db1;text-align: left;">除了Qwen
实时其他模型通常会增加约1秒的延迟改变模型后建议清空记忆体以免影响体验 实时其他模型通常会增加约1秒的延迟改变模型后建议清空记忆体以免影响体验
</div> </div>
</el-form-item> </el-form-item>
</div> </div>
</el-form> </el-form>
<div style="display: flex;padding: 20px;gap: 10px;align-items: center;"> <div style="display: flex;padding: 16px;gap: 8px;align-items: center;">
<div class="save-btn" @click="saveConfig"> <div class="save-btn" @click="saveConfig">
保存配置 保存配置
</div> </div>
@@ -102,12 +84,12 @@
重制 重制
</div> </div>
<div class="clear-text"> <div class="clear-text">
<img src="@/assets/home/red-info.png" alt="" style="width: 24px;height: 24px;"/> <img src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;"/>
保存配置后需要重启设备新的配置才会生效 保存配置后需要重启设备新的配置才会生效
</div> </div>
</div> </div>
</div> </div>
<div style="font-size: 12px;font-weight: 400;margin-top: auto;padding-top: 30px;color: #979db1;"> <div style="font-size: 12px;font-weight: 400;margin-top: auto;padding-top: 24px;color: #979db1;">
©2025 xiaozhi-esp32-server ©2025 xiaozhi-esp32-server
</div> </div>
</el-main> </el-main>
@@ -128,15 +110,29 @@ export default {
timbre: "", timbre: "",
introduction: "", introduction: "",
prompt: "", prompt: "",
model: "" model: {
tts: "",
vad: "",
asr: "",
llm: "",
memory:"",
intent:""
}
}, },
options: [{ options: [
value: '选项1', { value: '选项1', label: '黄金糕' },
label: '黄金糕' { value: '选项2', label: '双皮奶' }
}, { ],
value: '选项2', models: [
label: '双皮奶' { label: '大语言模型(LLM)', key: 'llm' },
}] { label: '语音转文本模型(ASR)', key: 'asr' },
{ label: '语音活动检测模型(VAD)', key: 'vad' },
{ label: '语音生成模型(TTS)', key: 'tts' },
{ label: '意图分类模型(Intent)', key: 'intent' },
{ label: '记忆增强模型(Memory)', key: 'memory' }
],
templates: ['台湾女友', '土豆子', '英语老师', '好奇小男孩', '汪汪队队长']
} }
}, },
methods: { methods: {
@@ -161,6 +157,11 @@ export default {
this.$message.success('配置已重置') this.$message.success('配置已重置')
}).catch(() => { }).catch(() => {
}) })
},
// 处理选择模板的逻辑
selectTemplate(template) {
this.form.name = template;
this.$message.success(`已选择模板:${template}`);
} }
} }
} }
@@ -168,9 +169,11 @@ export default {
<style scoped> <style scoped>
.welcome { .welcome {
min-width: 1200px; min-width: 900px;
min-height: 675px; min-height: 506px;
height: 100vh; height: 100vh;
display: flex;
flex-direction: column;
background-image: url("@/assets/home/background.png"); background-image: url("@/assets/home/background.png");
background-size: cover; background-size: cover;
/* 确保背景图像覆盖整个元素 */ /* 确保背景图像覆盖整个元素 */
@@ -181,71 +184,97 @@ export default {
-o-background-size: cover; -o-background-size: cover;
/* 兼容老版本Opera浏览器 */ /* 兼容老版本Opera浏览器 */
} }
.el-form-item ::v-deep .el-form-item__label {
font-size: 10px !important;
color: #3d4566 !important;
font-weight: 400;
line-height: 22px;
padding-bottom: 2px;
}
.select-field{
width: 100%;
max-width: 720px;
border: 1px solid #e4e6ef;
background: #f6f8fb;
border-radius: 8px;
height: 36px !important;
}
.audio-box { .audio-box {
flex: 1; flex: 1;
height: 46px; height: 37px;
border-radius: 10px; border-radius: 20px;
border: 1px solid #e4e6ef; border: 1px solid #e4e6ef;
} }
.clear-btn { .clear-btn {
width: 60px; width: 48px;
height: 24px; height: 19px;
background: #fd8383; background: #fd8383;
border-radius: 12px; border-radius: 10px;
line-height: 24px; line-height: 19px;
font-size: 14px; font-size: 11px;
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
} }
.clear-text { .clear-text {
color: #979db1; color: #979db1;
font-size: 14px; font-size: 11px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
margin-left: 20px; margin-left: 16px;
} }
.template-item { .template-item {
height: 46px; height: 37px;
width: 100px; width: 76px;
border-radius: 10px; border-radius: 8px;
background: #e6ebff; background: #e6ebff;
line-height: 46px; line-height: 37px;
font-weight: 400; font-weight: 400;
font-size: 14px; font-size: 11px;
text-align: center; text-align: center;
color: #5778ff; color: #5778ff;
cursor: pointer;
transition: background-color 0.3s ease;
}
.template-item:hover {
background-color: #d0d8ff;
} }
.prompt-bottom { .prompt-bottom {
margin-bottom: 5px; margin-bottom: 4px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 0 20px; padding: 0 16px;
align-items: center; align-items: center;
} }
.input-46 { .input-46 {
border: 1px solid #e4e6ef; border: 1px solid #e4e6ef;
background: #f6f8fb; background: #f6f8fb;
border-radius: 10px; border-radius: 8px;
height: 36px !important;
} }
.save-btn, .save-btn,
.reset-btn { .reset-btn {
width: 140px; width: 112px;
height: 46px; height: 37px;
border-radius: 23px; border-radius: 18px;
line-height: 46px; line-height: 37px;
box-sizing: border-box; box-sizing: border-box;
cursor: pointer; cursor: pointer;
font-size: 11px
} }
.save-btn { .save-btn {
border-radius: 23px; border-radius: 18px;
background: #5778ff; background: #5778ff;
color: #fff; color: #fff;
} }
@@ -258,7 +287,8 @@ export default {
.textarea-box { .textarea-box {
border: 1px solid #e4e6ef; border: 1px solid #e4e6ef;
border-radius: 10px; border-radius: 8px;
background: #f6f8fb; background: #f6f8fb;
} }
</style> </style>
+42 -21
View File
@@ -35,10 +35,7 @@ log:
log_file: "server.log" log_file: "server.log"
# 设置数据文件路径 # 设置数据文件路径
data_dir: data data_dir: data
iot:
Speaker:
# 设置esp32的音量,范围0-100
volume: 80
xiaozhi: xiaozhi:
type: hello type: hello
version: 1 version: 1
@@ -102,6 +99,14 @@ Intent:
- change_role - change_role
- get_weather - get_weather
# 插件的基础配置
plugins:
# 获取天气插件的配置,这里填写你的api_key
# 这个密钥是项目共用的key,用多了可能会被限制
# 想稳定一点就自行申请替换,每天有1000次免费调用
# 申请地址:https://console.qweather.com/#/apps/create-key/over
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
Memory: Memory:
mem0ai: mem0ai:
type: mem0ai type: mem0ai
@@ -109,10 +114,10 @@ Memory:
# 每月有1000次免费调用 # 每月有1000次免费调用
api_key: 你的mem0ai api key api_key: 你的mem0ai api key
nomem: nomem:
# 不想使用记忆功能,可以使用nomem # 不想使用记忆功能,可以使用nomem
type: nomem type: nomem
mem_local_short: mem_local_short:
# 本地记忆功能,通过selected_module的llm总结,数据保存在本地,不会上传到服务器 # 本地记忆功能,通过selected_module的llm总结,数据保存在本地,不会上传到服务器
type: mem_local_short type: mem_local_short
ASR: ASR:
@@ -180,8 +185,12 @@ LLM:
type: dify type: dify
# 建议使用本地部署的dify接口,国内部分区域访问dify公有云接口可能会受限 # 建议使用本地部署的dify接口,国内部分区域访问dify公有云接口可能会受限
# 如果使用DifyLLM,配置文件里prompt(提示词)是无效的,需要在dify控制台设置提示词 # 如果使用DifyLLM,配置文件里prompt(提示词)是无效的,需要在dify控制台设置提示词
base_url: https://api.dify.cn/v1 base_url: https://api.dify.ai/v1
api_key: 你的DifyLLM web key api_key: 你的DifyLLM web key
# 使用的对话模式 可以选择工作流 workflows/run 对话模式 chat-messages 文本生成 completion-messages
# 使用workflows进行返回的时候输入参数为 query 返回参数的名字要设置为 answer
# 文本生成的默认输入参数也是query
mode: chat-messages
GeminiLLM: GeminiLLM:
type: gemini type: gemini
# 谷歌Gemini API,需要先在Google Cloud控制台创建API密钥并获取api_key # 谷歌Gemini API,需要先在Google Cloud控制台创建API密钥并获取api_key
@@ -224,7 +233,7 @@ TTS:
# 定义TTS API类型 # 定义TTS API类型
type: edge type: edge
voice: zh-CN-XiaoxiaoNeural voice: zh-CN-XiaoxiaoNeural
output_file: tmp/ output_dir: tmp/
DoubaoTTS: DoubaoTTS:
# 定义TTS API类型 # 定义TTS API类型
type: doubao type: doubao
@@ -234,7 +243,7 @@ TTS:
# 地址:https://console.volcengine.com/speech/service/8 # 地址:https://console.volcengine.com/speech/service/8
api_url: https://openspeech.bytedance.com/api/v1/tts api_url: https://openspeech.bytedance.com/api/v1/tts
voice: BV001_streaming voice: BV001_streaming
output_file: tmp/ output_dir: tmp/
authorization: "Bearer;" authorization: "Bearer;"
appid: 你的火山引擎语音合成服务appid appid: 你的火山引擎语音合成服务appid
access_token: 你的火山引擎语音合成服务access_token access_token: 你的火山引擎语音合成服务access_token
@@ -245,7 +254,7 @@ TTS:
# token申请地址 https://cloud.siliconflow.cn/account/ak # token申请地址 https://cloud.siliconflow.cn/account/ak
model: FunAudioLLM/CosyVoice2-0.5B model: FunAudioLLM/CosyVoice2-0.5B
voice: FunAudioLLM/CosyVoice2-0.5B:alex voice: FunAudioLLM/CosyVoice2-0.5B:alex
output_file: tmp/ output_dir: tmp/
access_token: 你的硅基流动API密钥 access_token: 你的硅基流动API密钥
response_format: wav response_format: wav
CozeCnTTS: CozeCnTTS:
@@ -253,7 +262,7 @@ TTS:
# COZECN TTS # COZECN TTS
# token申请地址 https://www.coze.cn/open/oauth/pats # token申请地址 https://www.coze.cn/open/oauth/pats
voice: 7426720361733046281 voice: 7426720361733046281
output_file: tmp/ output_dir: tmp/
access_token: 你的coze web key access_token: 你的coze web key
response_format: wav response_format: wav
FishSpeech: FishSpeech:
@@ -266,7 +275,7 @@ TTS:
#--decoder-config-name firefly_gan_vq #--decoder-config-name firefly_gan_vq
#--compile #--compile
type: fishspeech type: fishspeech
output_file: tmp/ output_dir: tmp/
response_format: wav response_format: wav
reference_id: null reference_id: null
reference_audio: ["/tmp/test.wav",] reference_audio: ["/tmp/test.wav",]
@@ -290,7 +299,7 @@ TTS:
#python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/caixukun.yaml #python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/caixukun.yaml
type: gpt_sovits_v2 type: gpt_sovits_v2
url: "http://127.0.0.1:9880/tts" url: "http://127.0.0.1:9880/tts"
output_file: tmp/ output_dir: tmp/
text_lang: "auto" text_lang: "auto"
ref_audio_path: "caixukun.wav" ref_audio_path: "caixukun.wav"
prompt_text: "" prompt_text: ""
@@ -315,7 +324,7 @@ TTS:
#python api.py #python api.py
type: gpt_sovits_v3 type: gpt_sovits_v3
url: "http://127.0.0.1:9880" url: "http://127.0.0.1:9880"
output_file: tmp/ output_dir: tmp/
text_language: "auto" text_language: "auto"
refer_wav_path: "caixukun.wav" refer_wav_path: "caixukun.wav"
prompt_language: "zh" prompt_language: "zh"
@@ -336,7 +345,7 @@ TTS:
# api_key地址:https://platform.minimaxi.com/user-center/basic-information/interface-key # api_key地址:https://platform.minimaxi.com/user-center/basic-information/interface-key
# 定义TTS API类型 # 定义TTS API类型
type: minimax type: minimax
output_file: tmp/ output_dir: tmp/
group_id: 你的minimax平台groupID group_id: 你的minimax平台groupID
api_key: 你的minimax平台接口密钥 api_key: 你的minimax平台接口密钥
model: "speech-01-turbo" model: "speech-01-turbo"
@@ -373,7 +382,7 @@ TTS:
# token地址:https://nls-portal.console.aliyun.com/overview # token地址:https://nls-portal.console.aliyun.com/overview
# 定义TTS API类型 # 定义TTS API类型
type: aliyun type: aliyun
output_file: tmp/ output_dir: tmp/
appkey: 你的阿里云智能语音交互服务项目Appkey appkey: 你的阿里云智能语音交互服务项目Appkey
token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_idaccess_key_secret token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_idaccess_key_secret
voice: xiaoyun voice: xiaoyun
@@ -395,9 +404,21 @@ TTS:
type: doubao type: doubao
api_url: https://api.302ai.cn/doubao/tts_hd api_url: https://api.302ai.cn/doubao/tts_hd
authorization: "Bearer " authorization: "Bearer "
# 湾湾小何音色
voice: "zh_female_wanwanxiaohe_moon_bigtts" voice: "zh_female_wanwanxiaohe_moon_bigtts"
output_file: tmp/ output_dir: tmp/
access_token: "你的302API密钥" access_token: "你的302API密钥"
GizwitsTTS:
type: doubao
# 火山引擎作为基座,可以完全使用企业级火山引擎语音合成服务
# 前一万名注册的用户,将送5元体验金额
# 获取API Key地址:https://agentrouter.gizwitsapi.com/panel/token
api_url: https://bytedance.gizwitsapi.com/api/v1/tts
authorization: "Bearer "
# 湾湾小何音色
voice: "zh_female_wanwanxiaohe_moon_bigtts"
output_dir: tmp/
access_token: "你的机智云API key"
ACGNTTS: ACGNTTS:
#在线网址:https://acgn.ttson.cn/ #在线网址:https://acgn.ttson.cn/
#token购买:www.ttson.cn #token购买:www.ttson.cn
@@ -413,7 +434,7 @@ TTS:
to_lang: ZH to_lang: ZH
url: https://u95167-bd74-2aef8085.westx.seetacloud.com:8443/flashsummary/tts?token= url: https://u95167-bd74-2aef8085.westx.seetacloud.com:8443/flashsummary/tts?token=
format: mp3 format: mp3
output_file: tmp/ output_dir: tmp/
emotion: 1 emotion: 1
OpenAITTS: OpenAITTS:
# openai官方文本转语音服务,可支持全球大多数语种 # openai官方文本转语音服务,可支持全球大多数语种
@@ -427,7 +448,7 @@ TTS:
voice: onyx voice: onyx
# 语速范围0.25-4.0 # 语速范围0.25-4.0
speed: 1 speed: 1
output_file: tmp/ output_dir: tmp/
CustomTTS: CustomTTS:
# 自定义的TTS接口服务,请求参数可自定义 # 自定义的TTS接口服务,请求参数可自定义
# 要求接口使用GET方式请求,并返回音频文件 # 要求接口使用GET方式请求,并返回音频文件
@@ -442,7 +463,7 @@ TTS:
headers: # 自定义请求头 headers: # 自定义请求头
# Authorization: Bearer xxxx # Authorization: Bearer xxxx
format: wav # 接口返回的音频格式 format: wav # 接口返回的音频格式
output_file: tmp/ output_dir: tmp/
# 模块测试配置 # 模块测试配置
module_test: module_test:
test_sentences: # 自定义测试语句 test_sentences: # 自定义测试语句
@@ -465,4 +486,4 @@ manager:
enabled: false enabled: false
ip: 0.0.0.0 ip: 0.0.0.0
port: 8002 port: 8002
use_private_config: false use_private_config: false
+46 -3
View File
@@ -7,9 +7,48 @@ from core.utils.util import read_config, get_project_dir
default_config_file = "config.yaml" default_config_file = "config.yaml"
def ensure_directories(config):
"""确保所有配置路径存在"""
dirs_to_create = set()
project_dir = get_project_dir() # 获取项目根目录
# 日志文件目录
log_dir = config.get('log', {}).get('log_dir', 'tmp')
dirs_to_create.add(os.path.join(project_dir, log_dir))
# ASR/TTS模块输出目录
for module in ['ASR', 'TTS']:
for provider in config.get(module, {}).values():
output_dir = provider.get('output_dir', '')
if output_dir:
dirs_to_create.add(output_dir)
# 根据selected_module创建模型目录
selected_modules = config.get('selected_module', {})
for module_type in ['ASR', 'LLM', 'TTS']:
selected_provider = selected_modules.get(module_type)
if not selected_provider:
continue
provider_config = config.get(module_type, {}).get(selected_provider, {})
output_dir = provider_config.get('output_dir')
if output_dir:
full_model_dir = os.path.join(project_dir, output_dir)
dirs_to_create.add(full_model_dir)
# 统一创建目录(保留原data目录创建)
for dir_path in dirs_to_create:
try:
os.makedirs(dir_path, exist_ok=True)
except PermissionError:
print(f"警告:无法创建目录 {dir_path},请检查写入权限")
def get_config_file(): def get_config_file():
global default_config_file global default_config_file
# 判断是否存在私有配置文件 """获取配置文件路径,优先使用私有配置文件(若存在)。
Returns:
str: 配置文件路径(相对路径或默认路径)
"""
config_file = default_config_file config_file = default_config_file
if os.path.exists(get_project_dir() + "data/." + default_config_file): if os.path.exists(get_project_dir() + "data/." + default_config_file):
config_file = "data/." + default_config_file config_file = "data/." + default_config_file
@@ -20,9 +59,13 @@ def load_config():
"""加载配置文件""" """加载配置文件"""
parser = argparse.ArgumentParser(description="Server configuration") parser = argparse.ArgumentParser(description="Server configuration")
config_file = get_config_file() config_file = get_config_file()
parser.add_argument("--config_path", type=str, default=config_file) parser.add_argument("--config_path", type=str, default=config_file)
args = parser.parse_args() args = parser.parse_args()
return read_config(args.config_path) config = read_config(args.config_path)
# 初始化目录
ensure_directories(config)
return config
def update_config(config): def update_config(config):
@@ -67,7 +110,7 @@ def find_missing_keys(new_config, old_config, parent_key=''):
def check_config_file(): def check_config_file():
old_config_file = get_config_file() old_config_file = get_config_file()
global default_config_file global default_config_file
if not old_config_file.startswith('data'): if not 'data' in old_config_file:
return return
old_config = read_config(get_project_dir() + old_config_file) old_config = read_config(get_project_dir() + old_config_file)
new_config = read_config(get_project_dir() + default_config_file) new_config = read_config(get_project_dir() + default_config_file)
+48 -30
View File
@@ -5,13 +5,15 @@ import time
import queue import queue
import asyncio import asyncio
import traceback import traceback
from config.logger import setup_logging
import threading import threading
import websockets import websockets
from typing import Dict, Any from typing import Dict, Any
import plugins_func.loadplugins
from config.logger import setup_logging
from core.utils.dialogue import Message, Dialogue from core.utils.dialogue import Message, Dialogue
from core.handle.textHandle import handleTextMessage from core.handle.textHandle import handleTextMessage
from core.utils.util import get_string_no_punctuation_or_emoji, extract_json_from_string 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 concurrent.futures import ThreadPoolExecutor, TimeoutError
from core.handle.sendAudioHandle import sendAudioMessage from core.handle.sendAudioHandle import sendAudioMessage
from core.handle.receiveAudioHandle import handleAudioMessage from core.handle.receiveAudioHandle import handleAudioMessage
@@ -20,7 +22,6 @@ from plugins_func.register import Action
from config.private_config import PrivateConfig from config.private_config import PrivateConfig
from core.auth import AuthMiddleware, AuthenticationError from core.auth import AuthMiddleware, AuthenticationError
from core.utils.auth_code_gen import AuthCodeGenerator from core.utils.auth_code_gen import AuthCodeGenerator
import plugins_func.loadplugins
TAG = __name__ TAG = __name__
@@ -37,6 +38,8 @@ class ConnectionHandler:
self.websocket = None self.websocket = None
self.headers = None self.headers = None
self.client_ip = None
self.client_ip_info = {}
self.session_id = None self.session_id = None
self.prompt = None self.prompt = None
self.welcome_msg = None self.welcome_msg = None
@@ -95,16 +98,14 @@ class ConnectionHandler:
self.use_function_call_mode = 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.use_function_call_mode = True
self.func_handler = FunctionHandler(self.config)
async def handle_connection(self, ws): async def handle_connection(self, ws):
try: try:
# 获取并验证headers # 获取并验证headers
self.headers = dict(ws.request.headers) self.headers = dict(ws.request.headers)
# 获取客户端ip地址 # 获取客户端ip地址
client_ip = ws.remote_address[0] self.client_ip = ws.remote_address[0]
self.logger.bind(tag=TAG).info(f"{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) await self.auth.authenticate(self.headers)
@@ -148,6 +149,7 @@ class ConnectionHandler:
self.welcome_msg["session_id"] = self.session_id self.welcome_msg["session_id"] = self.session_id
await self.websocket.send(json.dumps(self.welcome_msg)) await self.websocket.send(json.dumps(self.welcome_msg))
# 异步初始化
await self.loop.run_in_executor(None, self._initialize_components) await self.loop.run_in_executor(None, self._initialize_components)
# tts 消化线程 # tts 消化线程
@@ -188,8 +190,14 @@ class ConnectionHandler:
self.prompt = self.config["prompt"] self.prompt = self.config["prompt"]
if self.private_config: if self.private_config:
self.prompt = self.private_config.private_config.get("prompt", self.prompt) self.prompt = self.private_config.private_config.get("prompt", self.prompt)
self.client_ip_info = get_ip_info(self.client_ip)
self.logger.bind(tag=TAG).info(f"Client ip info: {self.client_ip_info}")
self.prompt = self.prompt + f"\n我在:{self.client_ip_info}"
self.dialogue.put(Message(role="system", content=self.prompt)) self.dialogue.put(Message(role="system", content=self.prompt))
self.func_handler = FunctionHandler(self.config)
def change_system_prompt(self, prompt): def change_system_prompt(self, prompt):
self.prompt = prompt self.prompt = prompt
# 找到原来的role==system,替换原来的系统提示 # 找到原来的role==system,替换原来的系统提示
@@ -295,7 +303,7 @@ class ConnectionHandler:
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 return True
def chat_with_function_calling(self, query, tool_call = False): def chat_with_function_calling(self, query, tool_call=False):
self.logger.bind(tag=TAG).debug(f"Chat with function calling start: {query}") self.logger.bind(tag=TAG).debug(f"Chat with function calling start: {query}")
"""Chat with function calling for intent detection using streaming""" """Chat with function calling for intent detection using streaming"""
if self.isNeedAuth(): if self.isNeedAuth():
@@ -303,7 +311,7 @@ class ConnectionHandler:
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() future.result()
return True return True
if not tool_call: if not tool_call:
self.dialogue.put(Message(role="user", content=query)) self.dialogue.put(Message(role="user", content=query))
@@ -312,7 +320,7 @@ class ConnectionHandler:
response_message = [] response_message = []
processed_chars = 0 # 跟踪已处理的字符位置 processed_chars = 0 # 跟踪已处理的字符位置
try: try:
start_time = time.time() start_time = time.time()
@@ -320,7 +328,7 @@ class ConnectionHandler:
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() memory_str = future.result()
#self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}") # self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}")
# 使用支持functions的streaming接口 # 使用支持functions的streaming接口
llm_responses = self.llm.response_with_functions( llm_responses = self.llm.response_with_functions(
@@ -343,8 +351,8 @@ class ConnectionHandler:
content_arguments = "" content_arguments = ""
for response in llm_responses: for response in llm_responses:
content, tools_call = response content, tools_call = response
if content is not None and len(content)>0: 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 tool_call_flag = True
if tools_call is not None: if tools_call is not None:
@@ -358,7 +366,7 @@ class ConnectionHandler:
if content is not None and len(content) > 0: if content is not None and len(content) > 0:
if tool_call_flag: if tool_call_flag:
content_arguments+=content content_arguments += content
else: else:
response_message.append(content) response_message.append(content)
@@ -414,16 +422,17 @@ class ConnectionHandler:
else: else:
function_arguments = json.loads(function_arguments) function_arguments = json.loads(function_arguments)
if not bHasError: if not bHasError:
self.logger.bind(tag=TAG).info(f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}") self.logger.bind(tag=TAG).info(
f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}")
function_call_data = { function_call_data = {
"name": function_name, "name": function_name,
"id": function_id, "id": function_id,
"arguments": function_arguments "arguments": function_arguments
} }
result = self.func_handler.handle_llm_function_call(self, function_call_data) result = self.func_handler.handle_llm_function_call(self, function_call_data)
self._handle_function_result(result, function_call_data, text_index+1) self._handle_function_result(result, function_call_data, text_index + 1)
# 处理最后剩余的文本 # 处理最后剩余的文本
full_text = "".join(response_message) full_text = "".join(response_message)
remaining_text = full_text[processed_chars:] remaining_text = full_text[processed_chars:]
if remaining_text: if remaining_text:
@@ -435,7 +444,7 @@ class ConnectionHandler:
self.tts_queue.put(future) self.tts_queue.put(future)
# 存储对话内容 # 存储对话内容
if len(response_message)>0: 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.llm_finish_task = True
@@ -444,31 +453,40 @@ class ConnectionHandler:
return True return True
def _handle_function_result(self, result, function_call_data, text_index): def _handle_function_result(self, result, function_call_data, text_index):
if result.action == Action.RESPONSE: # 直接回复前端 if result.action == Action.RESPONSE: # 直接回复前端
text = result.response text = result.response
self.recode_first_last_text(text, text_index) self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index) future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future) self.tts_queue.put(future)
self.dialogue.put(Message(role="assistant", content=text)) self.dialogue.put(Message(role="assistant", content=text))
if result.action == Action.REQLLM: # 调用函数后再请求llm生成回复 elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
text = result.result text = result.result
if text is not None and len(text) > 0: if text is not None and len(text) > 0:
function_id = function_call_data["id"] function_id = function_call_data["id"]
function_name = function_call_data["name"] function_name = function_call_data["name"]
function_arguments = function_call_data["arguments"] function_arguments = function_call_data["arguments"]
self.dialogue.put(Message(role='assistant', self.dialogue.put(Message(role='assistant',
tool_calls=[{"id": function_id, tool_calls=[{"id": function_id,
"function": {"arguments": function_arguments,"name": function_name}, "function": {"arguments": function_arguments,
"type": 'function', "name": function_name},
"index": 0}])) "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) self.chat_with_function_calling(text, tool_call=True)
if result.action == Action.NOTFOUND: elif result.action == Action.NOTFOUND:
text = result.response text = result.result
self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future)
self.dialogue.put(Message(role="assistant", content=text))
else:
text = result.result
self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future)
self.dialogue.put(Message(role="assistant", content=text))
def _tts_priority_thread(self): def _tts_priority_thread(self):
while not self.stop_event.is_set(): while not self.stop_event.is_set():
@@ -50,6 +50,7 @@ class FunctionHandler:
self.function_registry.register_function("play_music") self.function_registry.register_function("play_music")
self.function_registry.register_function("plugin_loader") self.function_registry.register_function("plugin_loader")
self.function_registry.register_function("get_time") self.function_registry.register_function("get_time")
self.function_registry.register_function("raise_and_lower_the_volume")
def register_config_functions(self): def register_config_functions(self):
"""注册配置中的函数,可以不同客户端使用不同的配置""" """注册配置中的函数,可以不同客户端使用不同的配置"""
@@ -69,7 +70,7 @@ class FunctionHandler:
arguments = function_call_data["arguments"] arguments = function_call_data["arguments"]
arguments = json.loads(arguments) if arguments else {} arguments = json.loads(arguments) if arguments else {}
logger.bind(tag=TAG).info(f"调用函数: {function_name}, 参数: {arguments}") logger.bind(tag=TAG).info(f"调用函数: {function_name}, 参数: {arguments}")
if funcItem.type == ToolType.SYSTEM_CTL: if funcItem.type == ToolType.SYSTEM_CTL or funcItem.type == ToolType.IOT_CTL:
return func(conn, **arguments) return func(conn, **arguments)
elif funcItem.type == ToolType.WAIT: elif funcItem.type == ToolType.WAIT:
return func(**arguments) return func(**arguments)
+242 -97
View File
@@ -1,24 +1,120 @@
import json import json
import asyncio
from config.logger import setup_logging from config.logger import setup_logging
from plugins_func.register import device_type_registry, register_function, ActionResponse, Action, ToolType
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
def wrap_async_function(async_func):
"""包装异步函数为同步函数"""
def wrapper(*args, **kwargs):
try:
# 获取连接对象(第一个参数)
conn = args[0]
if not hasattr(conn, 'loop'):
logger.bind(tag=TAG).error("Connection对象没有loop属性")
return ActionResponse(Action.ERROR, "Connection对象没有loop属性",
"执行操作时出错: Connection对象没有loop属性")
# 使用conn对象中的事件循环
loop = conn.loop
# 在conn的事件循环中运行异步函数
future = asyncio.run_coroutine_threadsafe(async_func(*args, **kwargs), loop)
# 等待结果返回
return future.result()
except Exception as e:
logger.bind(tag=TAG).error(f"运行异步函数时出错: {e}")
return ActionResponse(Action.ERROR, str(e), f"执行操作时出错: {e}")
return wrapper
def create_iot_function(device_name, method_name, method_info):
"""
根据IOT设备描述生成通用的控制函数
"""
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}'")
# 发送控制命令
await send_iot_conn(conn, device_name, method_name, params)
# 等待一小段时间让状态更新
await asyncio.sleep(0.1)
# 生成结果信息
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))
# 如果有{value}占位符,用相关参数替换
if "{value}" in response:
response = response.replace("{value}", str(param_value))
break
return ActionResponse(Action.RESPONSE, result, response)
except Exception as e:
logger.bind(tag=TAG).error(f"执行{device_name}{method_name}操作失败: {e}")
# 操作失败时使用大模型提供的失败响应
response = response_failure
return ActionResponse(Action.ERROR, str(e), response)
return wrap_async_function(iot_control_function)
def create_iot_query_function(device_name, prop_name, prop_info):
"""
根据IOT设备属性创建查询函数
"""
async def iot_query_function(conn, response_success=None, response_failure=None):
try:
# 打印响应参数
logger.bind(tag=TAG).info(
f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'")
value = await get_iot_status(conn, device_name, prop_name)
# 查询成功,生成结果
if value is not None:
# 使用大模型提供的成功响应,并替换其中的占位符
response = response_success.replace("{value}", str(value))
return ActionResponse(Action.RESPONSE, str(value), response)
else:
# 查询失败,使用大模型提供的失败响应
response = response_failure
return ActionResponse(Action.ERROR, f"属性{prop_name}不存在", response)
except Exception as e:
logger.bind(tag=TAG).error(f"查询{device_name}{prop_name}时出错: {e}")
# 查询出错时使用大模型提供的失败响应
response = response_failure
return ActionResponse(Action.ERROR, str(e), response)
return wrap_async_function(iot_query_function)
class IotDescriptor: class IotDescriptor:
""" """
A class to represent an IoT descriptor. A class to represent an IoT descriptor.
Attributes:
----------
name : str
The name of the IoT descriptor.
description : str
A brief description of the IoT descriptor.
properties : dict
A dictionary containing properties of the IoT descriptor.
methods : dict
A dictionary containing methods of the IoT descriptor.
-------
""" """
def __init__(self, name, description, properties, methods): def __init__(self, name, description, properties, methods):
@@ -29,17 +125,7 @@ class IotDescriptor:
# 根据描述创建属性 # 根据描述创建属性
for key, value in properties.items(): for key, value in properties.items():
# "volume":{"description":"当前音量 值","type":"number"} property_item = globals()[key] = {}
"""
等价于
{
'name': 名字,
'description': 描述,
'value': 0
}
"""
# setattr(self, key, {}) # 创建一个空字典, 名字是属性名
property_item = globals()[key] = {} # 创建一个空字典, 名字是属性名
property_item['name'] = key property_item['name'] = key
property_item["description"] = value["description"] property_item["description"] = value["description"]
if value["type"] == "number": if value["type"] == "number":
@@ -52,23 +138,10 @@ class IotDescriptor:
# 根据描述创建方法 # 根据描述创建方法
for key, value in methods.items(): for key, value in methods.items():
# "SetVolume": {"description":"设置音量","parameters":{"volume":{"description":"0到100之间的整数","type":"number"}}} method = globals()[key] = {}
"""
等价于
SetVolume = {
`description`: 描述,
`volume`: {
`description`: 描述,
`value`: 0
}
}
"""
# setattr(self, key, {}) # 创建一个空字典, 名字是方法名
method = globals()[key] = {} # 创建一个空字典, 名字是方法名
method["description"] = value["description"] method["description"] = value["description"]
method['name'] = key method['name'] = key
for k, v in value["parameters"].items(): for k, v in value["parameters"].items():
# 不同的参数解析
method[k] = {} method[k] = {}
method[k]["description"] = v["description"] method[k]["description"] = v["description"]
if v["type"] == "number": if v["type"] == "number":
@@ -77,58 +150,136 @@ class IotDescriptor:
method[k]["value"] = False method[k]["value"] = False
else: else:
method[k]["value"] = "" method[k]["value"] = ""
self.methods.append(method) self.methods.append(method)
async def handleIotDescriptors(conn, descriptors): def register_device_type(descriptor):
""" """注册设备类型及其功能"""
处理物联网描述 device_name = descriptor["name"]
示例: [{ type_id = device_type_registry.generate_device_type_id(descriptor)
"name":"Speaker",
"description":"当前 AI 机器人的扬声器", # 如果该类型已注册,直接返回类型ID
"properties":{ if type_id in device_type_registry.type_functions:
"volume":{"description":"当前音量 值","type":"number"} 可以有boolean, number, string三种类型 return type_id
},
"methods":{ functions = {}
"SetVolume":{
"description":"设置音量","parameters":{"volume":{"description":"0到100之间的整数","type":"number"}} # 为每个属性创建查询函数
for prop_name, prop_info in descriptor["properties"].items():
func_name = f"get_{device_name.lower()}_{prop_name.lower()}"
func_desc = {
"type": "function",
"function": {
"name": func_name,
"description": f"查询{descriptor['description']}{prop_info['description']}",
"parameters": {
"type": "object",
"properties": {
"response_success": {
"type": "string",
"description": f"查询成功时的友好回复,必须使用{{value}}作为占位符表示查询到的值"
},
"response_failure": {
"type": "string",
"description": f"查询失败时的友好回复,例如:'无法获取{device_name}{prop_info['description']}'"
}
},
"required": ["response_success", "response_failure"]
}
} }
} }
}] query_func = create_iot_query_function(device_name, prop_name, prop_info)
descriptors: 描述列表 decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(query_func)
""" functions[func_name] = decorated_func
# 为每个方法创建控制函数
for method_name, method_info in descriptor["methods"].items():
func_name = f"{device_name.lower()}_{method_name.lower()}"
# 创建参数字典,添加原有参数
parameters = {
param_name: {
"type": param_info["type"],
"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中的名称"
}
})
# 构建必须参数列表(原有参数 + 响应参数)
required_params = list(method_info["parameters"].keys())
required_params.extend(["response_success", "response_failure"])
func_desc = {
"type": "function",
"function": {
"name": func_name,
"description": f"{descriptor['description']} - {method_info['description']}",
"parameters": {
"type": "object",
"properties": parameters,
"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)
functions[func_name] = decorated_func
device_type_registry.register_device_type(type_id, functions)
return type_id
# 用于接受前端设备推送的搜索iot描述
async def handleIotDescriptors(conn, descriptors):
"""处理物联网描述"""
functions_changed = False
for descriptor in descriptors: for descriptor in descriptors:
# 创建IOT设备描述符
iot_descriptor = IotDescriptor(descriptor["name"], descriptor["description"], descriptor["properties"], iot_descriptor = IotDescriptor(descriptor["name"], descriptor["description"], descriptor["properties"],
descriptor["methods"]) descriptor["methods"])
conn.iot_descriptors[descriptor["name"]] = iot_descriptor conn.iot_descriptors[descriptor["name"]] = iot_descriptor
# 暂时从配置文件中设置音量,后期通过意图识别控制音量 if conn.use_function_call_mode:
default_iot_volume = 100 # 注册或获取设备类型
if "iot" in conn.config: type_id = register_device_type(descriptor)
default_iot_volume = conn.config["iot"]["Speaker"]["volume"] device_functions = device_type_registry.get_device_functions(type_id)
logger.bind(tag=TAG).info(f"服务端设置音量为{default_iot_volume}")
await send_iot_conn(conn, "Speaker", "SetVolume", {"volume": default_iot_volume}) # 在连接级注册设备函数
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}")
functions_changed = True
# 如果注册了新函数,更新function描述列表
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}")
async def handleIotStatus(conn, states): async def handleIotStatus(conn, states):
""" """处理物联网状态"""
处理物联网状态
示例: [{
"name":"Speaker",
"state":{
"volume":100
}
}]
states: 状态列表
"""
for state in states: for state in states:
for key, value in conn.iot_descriptors.items(): for key, value in conn.iot_descriptors.items():
if key == state["name"]: if key == state["name"]:
for property_item in value.properties: for property_item in value.properties:
# properties为字典列表, 记录各种属性
for k, v in state["state"].items(): for k, v in state["state"].items():
# state为字典, 记录各种属性的值, 是需要记录的信息
if property_item["name"] == k: if property_item["name"] == k:
# 检查一下属性是不是相同的
if type(v) != type(property_item["value"]): 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 break
@@ -138,41 +289,35 @@ async def handleIotStatus(conn, states):
break break
break break
async def get_iot_status(conn, name, property_name): async def get_iot_status(conn, name, property_name):
""" """获取物联网状态"""
获取物联网状态
name: 设备名称 "Speaker"
property_name: 属性名称 "volume"
返回值: 属性值, 实际的属性有int, bool和str三种类型
"""
for key, value in conn.iot_descriptors.items(): for key, value in conn.iot_descriptors.items():
if key == name: if key == name:
for property_item in value.properties: for property_item in value.properties:
if property_item["name"] == property_name: if property_item["name"] == property_name:
return property_item["value"] return property_item["value"]
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
return None return None
async def send_iot_conn(conn, name, method_name, parameters):
"""
发送物联网指令
name: 设备名称 "Speaker"
method: 方法 "SetVolume"
parameters: 参数, 是一个字典 {"volume": 100}
发送示例:
{
"type": "iot",
"commands": [
{
"name" : "Speaker",
"method": "SetVolume",
"parameters": {
"volume": 100
}
}
]
}
"""
async def set_iot_status(conn, name, property_name, value):
"""设置物联网状态"""
for key, iot_descriptor in conn.iot_descriptors.items():
if key == name:
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']}的值类型不匹配")
return
property_item["value"] = value
logger.bind(tag=TAG).info(f"物联网状态更新: {name} , {property_name} = {value}")
return
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
async def send_iot_conn(conn, name, method_name, parameters):
"""发送物联网指令"""
for key, value in conn.iot_descriptors.items(): for key, value in conn.iot_descriptors.items():
if key == name: if key == name:
# 找到了设备 # 找到了设备
@@ -2,7 +2,7 @@ from config.logger import setup_logging
import json import json
from core.handle.abortHandle import handleAbortMessage from core.handle.abortHandle import handleAbortMessage
from core.handle.helloHandle import handleHelloMessage from core.handle.helloHandle import handleHelloMessage
from core.handle.receiveAudioHandle import startToChat from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
TAG = __name__ TAG = __name__
@@ -31,6 +31,8 @@ async def handleTextMessage(conn, message):
elif msg_json["state"] == "stop": elif msg_json["state"] == "stop":
conn.client_have_voice = True conn.client_have_voice = True
conn.client_voice_stop = True conn.client_voice_stop = True
if len(conn.asr_audio) > 0:
await handleAudioMessage(conn, b'')
elif msg_json["state"] == "detect": elif msg_json["state"] == "detect":
conn.asr_server_receive = False conn.asr_server_receive = False
conn.client_have_voice = False conn.client_have_voice = False
@@ -41,6 +43,6 @@ async def handleTextMessage(conn, message):
if "descriptors" in msg_json: if "descriptors" in msg_json:
await handleIotDescriptors(conn, msg_json["descriptors"]) await handleIotDescriptors(conn, msg_json["descriptors"])
if "states" in msg_json: if "states" in msg_json:
await handleIotStatus(conn, msg_json["states"]) await handleIotStatus(conn, msg_json["states"])
except json.JSONDecodeError: except json.JSONDecodeError:
await conn.websocket.send(message) await conn.websocket.send(message)
@@ -9,6 +9,7 @@ logger = setup_logging()
class LLMProvider(LLMProviderBase): class LLMProvider(LLMProviderBase):
def __init__(self, config): def __init__(self, config):
self.api_key = config["api_key"] 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的映射 self.session_conversation_map = {} # 存储session_id和conversation_id的映射
@@ -19,27 +20,59 @@ class LLMProvider(LLMProviderBase):
conversation_id = self.session_conversation_map.get(session_id) conversation_id = self.session_conversation_map.get(session_id)
# 发起流式请求 # 发起流式请求
with requests.post( if self.mode == "chat-messages":
f"{self.base_url}/chat-messages", request_json = {
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"query": last_msg["content"], "query": last_msg["content"],
"response_mode": "streaming", "response_mode": "streaming",
"user": session_id, "user": session_id,
"inputs": {}, "inputs": {},
"conversation_id": conversation_id "conversation_id": conversation_id
}, }
elif self.mode == "workflows/run":
request_json = {
"inputs": {"query": last_msg["content"]},
"response_mode": "streaming",
"user": session_id
}
elif self.mode == "completion-messages":
request_json = {
"inputs": {"query": last_msg["content"]},
"response_mode": "streaming",
"user": session_id
}
with requests.post(
f"{self.base_url}/{self.mode}",
headers={"Authorization": f"Bearer {self.api_key}"},
json=request_json,
stream=True stream=True
) as r: ) as r:
for line in r.iter_lines(): if self.mode == "chat-messages":
if line.startswith(b'data: '): for line in r.iter_lines():
event = json.loads(line[6:]) if line.startswith(b'data: '):
# 如果没有找到conversation_id,则获取此次conversation_id event = json.loads(line[6:])
if not conversation_id: # 如果没有找到conversation_id,则获取此次conversation_id
conversation_id = event.get('conversation_id') if not conversation_id:
self.session_conversation_map[session_id] = conversation_id # 更新映射 conversation_id = event.get('conversation_id')
if event.get('answer'): self.session_conversation_map[session_id] = conversation_id # 更新映射
yield event['answer'] 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: '):
event = json.loads(line[6:])
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: '):
event = json.loads(line[6:])
if event.get('answer'):
yield event['answer']
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error in response generation: {e}") logger.bind(tag=TAG).error(f"Error in response generation: {e}")
@@ -13,7 +13,7 @@ logger = setup_logging()
class TTSProviderBase(ABC): class TTSProviderBase(ABC):
def __init__(self, config, delete_audio_file): def __init__(self, config, delete_audio_file):
self.delete_audio_file = delete_audio_file self.delete_audio_file = delete_audio_file
self.output_file = config.get("output_file") self.output_file = config.get("output_dir")
@abstractmethod @abstractmethod
def generate_filename(self): def generate_filename(self):
@@ -15,7 +15,7 @@ class TTSProvider(TTSProviderBase):
self.headers = config.get("headers", {}) self.headers = config.get("headers", {})
self.params = config.get("params") self.params = config.get("params")
self.format = config.get("format", "wav") self.format = config.get("format", "wav")
self.output_file = config.get("output_file", "tmp/") self.output_file = config.get("output_dir", "tmp/")
def generate_filename(self): def generate_filename(self):
return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}.{self.format}") return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}.{self.format}")
@@ -22,7 +22,7 @@ class TTSProvider(TTSProviderBase):
self.cut_punc = config.get("cut_punc","") self.cut_punc = config.get("cut_punc","")
self.speed = config.get("speed", 1.0) self.speed = config.get("speed", 1.0)
self.inp_refs = config.get("inp_refs",[]) self.inp_refs = config.get("inp_refs",[])
self.sample_steps = config.get("inp_refs",32) self.sample_steps = config.get("sample_steps",32)
self.if_sr = config.get("if_sr",False) self.if_sr = config.get("if_sr",False)
@@ -14,7 +14,7 @@ class TTSProvider(TTSProviderBase):
self.voice = config.get("voice", "alloy") self.voice = config.get("voice", "alloy")
self.response_format = "wav" self.response_format = "wav"
self.speed = config.get("speed", 1.0) self.speed = config.get("speed", 1.0)
self.output_file = config.get("output_file", "tmp/") self.output_file = config.get("output_dir", "tmp/")
check_model_key("TTS", self.api_key) check_model_key("TTS", self.api_key)
def generate_filename(self, extension=".wav"): def generate_filename(self, extension=".wav"):
@@ -17,7 +17,7 @@ class TTSProvider(TTSProviderBase):
self.volume_change_dB = config.get("volume_change_dB", 0) self.volume_change_dB = config.get("volume_change_dB", 0)
self.speed_factor = config.get("speed_factor", 1) self.speed_factor = config.get("speed_factor", 1)
self.stream = config.get("stream", False) self.stream = config.get("stream", False)
self.output_file = config.get("output_file") self.output_file = config.get("output_dir")
self.pitch_factor = config.get("pitch_factor", 0) self.pitch_factor = config.get("pitch_factor", 0)
self.format = config.get("format", "mp3") self.format = config.get("format", "mp3")
self.emotion = config.get("emotion", 1) self.emotion = config.get("emotion", 1)
+59 -1
View File
@@ -1,11 +1,11 @@
import os import os
import json import json
from datetime import datetime
import yaml import yaml
import socket import socket
import subprocess import subprocess
import logging import logging
import re import re
import requests
def get_project_dir(): def get_project_dir():
@@ -24,6 +24,64 @@ def get_local_ip():
except Exception as e: except Exception as e:
return "127.0.0.1" return "127.0.0.1"
def is_private_ip(ip_addr):
"""
Check if an IP address is a private IP address (compatible with IPv4 and IPv6).
@param {string} ip_addr - The IP address to check.
@return {bool} True if the IP address is private, False otherwise.
"""
try:
# Validate IPv4 or IPv6 address format
if not re.match(r"^(\d{1,3}\.){3}\d{1,3}$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$", ip_addr):
return False # Invalid IP address format
# IPv4 private address ranges
if '.' in ip_addr: # IPv4 address
ip_parts = list(map(int, ip_addr.split('.')))
if ip_parts[0] == 10:
return True # 10.0.0.0/8 range
elif ip_parts[0] == 172 and 16 <= ip_parts[1] <= 31:
return True # 172.16.0.0/12 range
elif ip_parts[0] == 192 and ip_parts[1] == 168:
return True # 192.168.0.0/16 range
elif ip_addr == '127.0.0.1':
return True # Loopback address
elif ip_parts[0] == 169 and ip_parts[1] == 254:
return True # Link-local address 169.254.0.0/16
else:
return False # Not a private IPv4 address
else: # IPv6 address
ip_addr = ip_addr.lower()
if ip_addr.startswith('fc00:') or ip_addr.startswith('fd00:'):
return True # Unique Local Addresses (FC00::/7)
elif ip_addr == '::1':
return True # Loopback address
elif ip_addr.startswith('fe80:'):
return True # Link-local unicast addresses (FE80::/10)
else:
return False # Not a private IPv6 address
except (ValueError, IndexError):
return False # IP address format error or insufficient segments
def get_ip_info(ip_addr):
try:
base_url = "https://freeipapi.com/api/json"
url = base_url if is_private_ip(ip_addr) else f"{base_url}/{ip_addr}"
resp = requests.get(url).json()
ip_info = {
"city": resp.get("cityName"),
"region": resp.get("regionName"),
"country": resp.get("countryName")
}
return ip_info
except Exception as e:
logging.error(f"Error getting client ip info: {e}")
return {}
def read_config(config_path): def read_config(config_path):
with open(config_path, "r", encoding="utf-8") as file: with open(config_path, "r", encoding="utf-8") as file:
+1
View File
@@ -21,6 +21,7 @@ services:
- ./data:/opt/xiaozhi-esp32-server/data - ./data:/opt/xiaozhi-esp32-server/data
# 模型文件挂接,很重要 # 模型文件挂接,很重要
- ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt
# #智控台还没开发好,还不能完全使用,会报很多错误,如果是非技术人员,请不要启用智控台服务 # #智控台还没开发好,还不能完全使用,会报很多错误,如果是非技术人员,请不要启用智控台服务
# xiaozhi-esp32-server-web: # xiaozhi-esp32-server-web:
# image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest # image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
@@ -1,42 +1,104 @@
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from plugins_func.register import register_function,ToolType, ActionResponse, Action from config.logger import setup_logging
from plugins_func.register import register_function, ToolType, ActionResponse, Action
TAG = __name__
logger = setup_logging()
get_weather_function_desc = { GET_WEATHER_FUNCTION_DESC = {
"type": "function", "type": "function",
"function": { "function": {
"name": "get_weather", "name": "get_weather",
"description": "获取某个地点的天气,用户应先提供一个位置,比如用户说杭州天气,参数为:zhejiang/hangzhou,比如用户说北京天气怎么样,参数为:beijing/beijing。如果用户只问天气怎么样,参数是:guangdong/guangzhou", "description": (
"获取某个地点的天气,用户应提供一个位置,比如用户说杭州天气,参数为:杭州。"
"如果用户说的是省份,默认用省会城市。如果用户说的不是省份或城市而是一个地名,"
"默认用该地所在省份的省会城市。"
),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"city": { "location": {
"type": "string", "type": "string",
"description": "城市,zhejiang/hangzhou" "description": "地点名,例如杭州。可选参数,如果不提供则不传"
},
"lang": {
"type": "string",
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN"
} }
}, },
"required": [ "required": ["lang"]
"city"
]
} }
} }
} }
@register_function('get_weather', get_weather_function_desc, ToolType.WAIT) HEADERS = {
def get_weather(city: str): 'User-Agent': (
""" 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
"获取某个地点的天气,用户应先提供一个位置,\n比如用户说杭州天气,参数为:zhejiang/hangzhou\n\n比如用户说北京天气怎么样,参数为:beijing/beijing", '(KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36'
city : 城市,zhejiang/hangzhou )
""" }
url = "https://tianqi.moji.com/weather/china/"+city
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36' def fetch_city_info(location, api_key):
} url = f"https://geoapi.qweather.com/v2/city/lookup?key={api_key}&location={location}&lang=zh"
response = requests.get(url, headers=headers) response = requests.get(url, headers=HEADERS).json()
if response.status_code!=200: return response.get('location', [])[0] if response.get('location') else None
def fetch_weather_page(url):
response = requests.get(url, headers=HEADERS)
return BeautifulSoup(response.text, "html.parser") if response.ok else None
def parse_weather_info(soup):
city_name = soup.select_one("h1.c-submenu__location").get_text(strip=True)
current_abstract = soup.select_one(".c-city-weather-current .current-abstract")
current_abstract = current_abstract.get_text(strip=True) if current_abstract else "未知"
current_basic = {}
for item in soup.select(".c-city-weather-current .current-basic .current-basic___item"):
parts = item.get_text(strip=True, separator=" ").split(" ")
if len(parts) == 2:
key, value = parts[1], parts[0]
current_basic[key] = value
temps_list = []
for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据
date = row.select_one(".date-bg .date").get_text(strip=True)
temps = [span.get_text(strip=True) for span in row.select(".tmp-cont .temp")]
high_temp, low_temp = (temps[0], temps[-1]) if len(temps) >= 2 else (None, None)
temps_list.append((date, high_temp, low_temp))
return city_name, current_abstract, current_basic, temps_list
@register_function('get_weather', GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL)
def get_weather(conn, location: str = None, lang: str = "zh_CN"):
api_key = conn.config["plugins"]["get_weather"]["api_key"]
default_location = conn.config["plugins"]["get_weather"]["default_location"]
location = location or conn.client_ip_info.get("city") or default_location
logger.bind(tag=TAG).debug(f"获取天气: {location}")
city_info = fetch_city_info(location, api_key)
if not city_info:
return ActionResponse(Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None)
soup = fetch_weather_page(city_info['fxLink'])
if not soup:
return ActionResponse(Action.REQLLM, None, "请求失败") return ActionResponse(Action.REQLLM, None, "请求失败")
soup = BeautifulSoup(response.text, "html.parser")
weather = soup.find('meta', attrs={'name':'description'})["content"] city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup)
weather = weather.replace("墨迹天气", "") weather_report = f"根据下列数据,用{lang}回应用户的查询天气请求:\n{city_name}未来7天天气:\n"
return ActionResponse(Action.REQLLM, weather, None) for i, (date, high, low) in enumerate(temps_list):
if high and low:
weather_report += f"{date}: {low}{high}\n"
weather_report += (
f"当前天气: {current_abstract}\n"
f"当前天气参数: {current_basic}\n"
f"(确保只报告指定单日的气温范围,除非用户明确要求想要了解多日天气,如果未指定,默认报告今天的温度范围。"
"参数为0的值不需要报告给用户,每次都报告体感温度,根据语境选择合适的参数内容告知用户,并对参数给出相应评价)"
)
return ActionResponse(Action.REQLLM, weather_report, None)
@@ -112,7 +112,6 @@ def get_music_files(music_dir, music_ext):
def initialize_music_handler(conn): def initialize_music_handler(conn):
global MUSIC_CACHE global MUSIC_CACHE
if MUSIC_CACHE == {}: if MUSIC_CACHE == {}:
logger.bind(tag=TAG).info(f"实例化音乐:")
if "music" in conn.config: if "music" in conn.config:
MUSIC_CACHE["music_config"] = conn.config["music"] MUSIC_CACHE["music_config"] = conn.config["music"]
MUSIC_CACHE["music_dir"] = os.path.abspath( MUSIC_CACHE["music_dir"] = os.path.abspath(
@@ -0,0 +1,64 @@
from config.logger import setup_logging
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from core.handle.iotHandle import get_iot_status, send_iot_conn
import asyncio
TAG = __name__
logger = setup_logging()
raise_and_lower_the_volume_function_desc = {
"type": "function",
"function": {
"name": "raise_and_lower_the_volume",
"description": "用户觉得声音过高或过低,或者用户想提高或降低音量。比如用户说太大声了,参数为:lower,比如用户说提高音量,参数为:raise",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"description": "动作名称,要么是raise,要么是lower"
}
},
"required": ["action"]
}
}
}
@register_function('raise_and_lower_the_volume', raise_and_lower_the_volume_function_desc, ToolType.IOT_CTL)
def raise_and_lower_the_volume(conn, action: str):
"""
获取当前设备音量
"""
future = asyncio.run_coroutine_threadsafe(
_raise_and_lower_the_volume(conn, action),
conn.loop
)
try:
new_volume = future.result() # 同步等待异步操作完成
logger.bind(tag=TAG).info(f"音量操作完成: {new_volume}")
response = f"音量已调整到{new_volume}"
except Exception as e:
logger.bind(tag=TAG).error(f"音量操作失败: {e}")
response = f"音量调整失败: {e}"
return ActionResponse(action=Action.RESPONSE, result="指令已接收", response=response)
async def _raise_and_lower_the_volume(conn, action):
volume = await get_iot_status(conn, "Speaker", "volume")
if volume is None:
raise Exception("你的设备不支持音量控制")
if action == 'raise':
volume += 10
elif action == 'lower':
volume -= 10
# 限制音量范围在0到100之间
if volume < 0:
volume = 0
elif volume > 100:
volume = 100
await send_iot_conn(conn, "Speaker", "SetVolume", {"volume": volume})
return volume
@@ -11,6 +11,7 @@ class ToolType(Enum):
WAIT = (2, "调用工具,等待函数返回") WAIT = (2, "调用工具,等待函数返回")
CHANGE_SYS_PROMPT = (3, "修改系统提示词,切换角色性格或职责") CHANGE_SYS_PROMPT = (3, "修改系统提示词,切换角色性格或职责")
SYSTEM_CTL = (4, "系统控制,影响正常的对话流程,如退出、播放音乐等,需要传递conn参数") SYSTEM_CTL = (4, "系统控制,影响正常的对话流程,如退出、播放音乐等,需要传递conn参数")
IOT_CTL = (5, "IOT设备控制,需要传递conn参数")
def __init__(self, code, message): def __init__(self, code, message):
self.code = code self.code = code
@@ -18,6 +19,7 @@ class ToolType(Enum):
class Action(Enum): class Action(Enum):
ERROR = (-1, "错误")
NOTFOUND = (0, "没有找到函数") NOTFOUND = (0, "没有找到函数")
NONE = (1, "啥也不干") NONE = (1, "啥也不干")
RESPONSE = (2, "直接回复") RESPONSE = (2, "直接回复")
@@ -40,8 +42,31 @@ class FunctionItem:
self.func = func self.func = func
self.type = type self.type = type
class DeviceTypeRegistry:
"""设备类型注册表,用于管理IOT设备类型及其函数"""
def __init__(self):
self.type_functions = {} # type_signature -> {func_name: FunctionItem}
def generate_device_type_id(self, descriptor):
"""通过设备能力描述生成类型ID"""
properties = sorted(descriptor["properties"].keys())
methods = sorted(descriptor["methods"].keys())
# 使用属性和方法的组合作为设备类型的唯一标识
type_signature = f"{descriptor['name']}:{','.join(properties)}:{','.join(methods)}"
return type_signature
def get_device_functions(self, type_id):
"""获取设备类型对应的所有函数"""
return self.type_functions.get(type_id, {})
def register_device_type(self, type_id, functions):
"""注册设备类型及其函数"""
if type_id not in self.type_functions:
self.type_functions[type_id] = functions
# 初始化函数注册字典 # 初始化函数注册字典
all_function_registry = {} all_function_registry = {}
device_type_registry = DeviceTypeRegistry()
def register_function(name, desc, type=None): def register_function(name, desc, type=None):
"""注册函数到函数注册字典的装饰器""" """注册函数到函数注册字典的装饰器"""
+1 -1
View File
@@ -21,4 +21,4 @@ cozepy==0.12.0
mem0ai==0.1.62 mem0ai==0.1.62
bs4==0.0.2 bs4==0.0.2
modelscope==1.23.2 modelscope==1.23.2
sherpa_onnx==1.11.0 sherpa_onnx==1.11.0