Merge branch 'refs/heads/main' into manager-api-modelAndTimbre-api

This commit is contained in:
剑雨
2025-03-21 15:04:11 +08:00
50 changed files with 1566 additions and 572 deletions
+4 -1
View File
@@ -141,8 +141,8 @@ music/
# Cython debug symbols
cython_debug/
*.iml
model.pt
tmp
.history
.DS_Store
main/xiaozhi-server/data
main/manager-web/node_modules
@@ -151,3 +151,6 @@ main/manager-web/node_modules
.private_config.yaml
.env.development
# model files
main/xiaozhi-server/models/SenseVoiceSmall/model.pt
main/xiaozhi-server/models/sherpa-onnx*
+3
View File
@@ -201,6 +201,7 @@ server:
| TTS | CosyVoiceSiliconflow | 接口调用 | 消耗 token | 需申请硅基流动 API 密钥;输出格式为 wav |
| TTS | TTS302AI | 接口调用 | 消耗 token | [点击创建密钥](https://dash.302.ai/apis/list) |
| TTS | CozeCnTTS | 接口调用 | 消耗 token | 需提供 Coze API key;输出格式为 wav |
| TTS | GizwitsTTS | 接口调用 | 消耗 token | [点击创建密钥](https://agentrouter.gizwitsapi.com) |
| TTS | ACGNTTS | 接口调用 | 消耗 token | [联系网站管理员购买密钥](www.ttson.cn) |
| TTS | OpenAITTS | 接口调用 | 消耗 token | 境外使用,境外购买 |
| TTS | FishSpeech | 接口调用 | 免费/自定义 | 本地启动 TTS 服务;启动方法见配置文件内说明 |
@@ -223,8 +224,10 @@ server:
| 类型 | 平台名称 | 使用方式 | 收费模式 | 备注 |
|:---:|:---------:|:----:|:----:|:--:|
| ASR | FunASR | 本地使用 | 免费 | |
| ASR | SherpaASR | 本地使用 | 免费 | |
| ASR | DoubaoASR | 接口调用 | 收费 | |
---
### Memory 记忆存储
+1
View File
@@ -165,6 +165,7 @@ In fact, any LLM that supports OpenAI API calls can be integrated.
| Type | Platform Name | Usage Method | Pricing Model | Remarks |
|:----:|:-------------------:|:------------:|:-------------:|:-------:|
| ASR | FunASR | Local | Free | |
| ASR | SherpaASR | Local | Free | |
| ASR | DoubaoASR | API call | Paid | |
---
@@ -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";
/**
* token header
*/
String TOKEN_HEADER = "token";
String AUTHORIZATION = "Authorization";
@@ -34,9 +34,6 @@ public class FieldMetaObjectHandler implements MetaObjectHandler {
//创建时间
strictInsertFill(metaObject, CREATE_DATE, Date.class, date);
//创建者所属部门
strictInsertFill(metaObject, DEPT_ID, Long.class, user.getDeptId());
//更新者
strictInsertFill(metaObject, UPDATER, Long.class, user.getId());
//更新时间
@@ -3,7 +3,6 @@ package xiaozhi.common.user;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 登录用户信息
@@ -14,19 +13,7 @@ import java.util.List;
public class UserDetail implements Serializable {
private Long id;
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 String token;
/**
* 部门数据权限
*/
private List<Long> deptIdList;
private Integer status;
}
@@ -1,6 +1,5 @@
package xiaozhi.common.utils;
import xiaozhi.common.constant.Constant;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils;
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.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import java.util.Date;
import java.util.Enumeration;
@@ -30,13 +31,14 @@ public class HttpContextUtils {
return ((ServletRequestAttributes) requestAttributes).getRequest();
}
public static String getToken() {
HttpServletRequest httpRequest = getHttpServletRequest();
String token = httpRequest.getHeader(Constant.TOKEN_HEADER);
//如果header中不存在token,则从参数中获取token
public static String getToken(String authorization) {
String token;
if (StringUtils.isBlank(authorization) && authorization.contains("Bearer ")) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
token = authorization.replace("Bearer ", "");
if (StringUtils.isBlank(token)) {
token = httpRequest.getParameter(Constant.TOKEN_HEADER);
throw new RenException(ErrorCode.TOKEN_NOT_EMPTY);
}
return token;
}
@@ -1,16 +1,10 @@
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 lombok.Data;
import org.apache.commons.lang3.StringUtils;
import xiaozhi.common.exception.ErrorCode;
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;
}
public boolean success() {
return code == 0;
}
public Result<T> error() {
this.code = ErrorCode.INTERNAL_SERVER_ERROR;
@@ -25,11 +25,10 @@ import xiaozhi.modules.security.user.SecurityUser;
import java.util.List;
import java.util.Map;
@Tag(name = "设备管理")
@AllArgsConstructor
@RestController
@RequestMapping("/device")
@Tag(name = "设备管理")
public class DeviceController {
private final DeviceService deviceService;
private final RedisUtils redisUtils;
@@ -70,7 +70,9 @@ public class ShiroConfig {
filterMap.put("/v3/api-docs/**", "anon");
filterMap.put("/doc.html", "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");
shiroFilter.setFilterChainDefinitionMap(filterMap);
@@ -4,20 +4,21 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.TokenDTO;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.AssertUtils;
import xiaozhi.modules.security.dao.SysUserTokenDao;
import xiaozhi.modules.security.dto.LoginDTO;
import xiaozhi.modules.security.password.PasswordUtils;
import xiaozhi.modules.security.service.CaptchaService;
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.service.SysUserService;
@@ -26,7 +27,6 @@ import java.io.IOException;
/**
* 登录控制层
*/
@Tag(name = "登录管理")
@AllArgsConstructor
@RestController
@RequestMapping("/user")
@@ -36,7 +36,6 @@ public class LoginController {
private final SysUserTokenService sysUserTokenService;
private final CaptchaService captchaService;
private static final Logger logger = LoggerFactory.getLogger(LoginController.class);
@GetMapping("/captcha")
@Operation(summary = "验证码")
@@ -79,32 +78,31 @@ public class LoginController {
}
// 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
if (userDTO != null){
if (userDTO != null) {
throw new RenException("此手机号码已经注册过");
}
userDTO = new SysUserDTO();
userDTO.setUsername(login.getUsername());
userDTO.setPassword(login.getPassword());
sysUserService.save(userDTO);
return new Result<Void>();
return new Result<>();
}
@GetMapping("/info")
@Operation(summary = "用户信息获取")
public Result<SysUserDTO> info(@RequestHeader("Authorization")String authorization) {
logger.info("the authorization:{}", authorization);
public Result<UserDetail> info() {
UserDetail user = SecurityUser.getUser();
Result<UserDetail> result = new Result<>();
result.setData(user);
return result;
}
String token;
if (StringUtils.isBlank(authorization) && authorization.contains("Bearer ")) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
token = authorization.replace("Bearer ", "");
if (StringUtils.isBlank(token)) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
SysUserDTO sysUserDTO = sysUserTokenService.getUserByToken(token);
Result result = new Result<SysUserDTO>();
return result.ok(sysUserDTO);
@PutMapping("/change-password")
@Operation(summary = "修改用户密码")
public Result<?> changePassword(@RequestBody PasswordDTO passwordDTO) {
Long userId = SecurityUser.getUserId();
sysUserTokenService.changePassword(userId, passwordDTO);
return new Result<>();
}
}
@@ -11,7 +11,6 @@ import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.springframework.web.bind.annotation.RequestMethod;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.utils.HttpContextUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.Result;
@@ -90,21 +89,10 @@ public class Oauth2Filter extends AuthenticatingFilter {
* 获取请求的token
*/
private String getRequestToken(HttpServletRequest httpRequest) {
String token;
String token = null;
//从header中获取token
String authorization = httpRequest.getHeader(Constant.AUTHORIZATION);
if (StringUtils.isBlank(authorization) && authorization.contains("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);
}
if (StringUtils.isNotBlank(authorization) && authorization.startsWith("Bearer ")) {
token = authorization.replace("Bearer ", "");
}
return token;
@@ -14,7 +14,6 @@ import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.MessageUtils;
import xiaozhi.modules.security.controller.LoginController;
import xiaozhi.modules.security.entity.SysUserTokenEntity;
import xiaozhi.modules.security.service.ShiroService;
import xiaozhi.modules.sys.entity.SysUserEntity;
@@ -82,8 +81,6 @@ public class Oauth2Realm extends AuthorizingRealm {
//转换成UserDetail对象
UserDetail userDetail = ConvertUtils.sourceToTarget(userEntity, UserDetail.class);
//获取用户对应的部门数据权限
userDetail.setDeptIdList(null);
userDetail.setToken(accessToken);
//账号锁定
@@ -1,14 +1,12 @@
package xiaozhi.modules.security.service;
import xiaozhi.common.page.PageData;
import xiaozhi.common.page.TokenDTO;
import xiaozhi.common.service.BaseService;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.security.entity.SysUserTokenEntity;
import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO;
import java.util.Map;
/**
* 用户Token
* Copyright (c) 人人开源 All rights reserved.
@@ -32,4 +30,12 @@ public interface SysUserTokenService extends BaseService<SysUserTokenEntity> {
*/
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 lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
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.oauth2.TokenGenerator;
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.service.SysUserService;
@@ -81,6 +82,9 @@ public class SysUserTokenServiceImpl extends BaseServiceImpl<SysUserTokenDao, Sy
@Override
public SysUserDTO getUserByToken(String token) {
SysUserTokenEntity userToken = baseDao.getByToken(token);
if (null == userToken) {
throw new RenException(ErrorCode.TOKEN_INVALID);
}
Date now = new Date();
if (userToken.getExpireDate().before(now)) {
@@ -97,4 +101,14 @@ public class SysUserTokenServiceImpl extends BaseServiceImpl<SysUserTokenDao, Sy
Date expireDate = DateUtil.offsetMinute(new Date(), -1);
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() {
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.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import xiaozhi.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import xiaozhi.common.entity.BaseEntity;
import java.util.Date;
@@ -32,11 +32,6 @@ public class SysUserEntity extends BaseEntity {
* 状态 0:停用 1:正常
*/
private Integer status;
/**
* 更新者
*/
@TableField(fill = FieldFill.INSERT)
private Date createDate;
/**
* 更新者
*/
@@ -47,10 +42,5 @@ public class SysUserEntity extends BaseEntity {
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateDate;
/**
* 部门名称
*/
@TableField(exist = false)
private String deptName;
}
@@ -1,6 +1,7 @@
package xiaozhi.modules.sys.service;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.entity.SysUserEntity;
@@ -18,4 +19,5 @@ public interface SysUserService extends BaseService<SysUserEntity> {
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.modules.security.password.PasswordUtils;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.entity.SysUserEntity;
import xiaozhi.modules.sys.enums.SuperAdminEnum;
@@ -81,6 +82,31 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
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() {
QueryWrapper<SysUserEntity> queryWrapper = new QueryWrapper<>();
return baseDao.selectCount(queryWrapper);
+47 -14
View File
@@ -18,20 +18,6 @@ export default {
})
}).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) {
RequestService.sendRequest().url(`${getServiceUrl()}/api/v1/user/device/bind`)
@@ -173,5 +159,52 @@ export default {
});
}).send();
},
// 获取智能体列表
getAgentList(callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/api/v1/user/agent`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail(() => {
RequestService.reAjaxFun(() => {
this.getAgentList(callback);
});
}).send();
},
getUserInfo(callback) {
RequestService.sendRequest()
.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>
<el-dialog :visible.sync="visible" width="480px" 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="width: 36px;height: 36px;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;" />
<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: 30px 20px;">
<div style="margin: 22px 15px;">
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
<div style="color: red;display: inline-block;">*</div>验证码
<div style="color: red;display: inline-block;">*</div>
<span style="font-size: 11px"> 验证码</span>
</div>
<div class="input-46" style="margin-top: 10px;">
<div class="input-46" style="margin-top: 12px;">
<el-input placeholder="请输入设备播报的6位数验证码.." v-model="deviceCode" />
</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>
@@ -53,7 +54,6 @@ export default {
<style scoped>
.input-46 {
border: 1px solid #e4e6ef;
background: #f6f8fb;
@@ -65,11 +65,26 @@ export default {
flex: 1;
border-radius: 23px;
background: #5778ff;
height: 46px;
height: 40px;
font-weight: 500;
font-size: 14px;
font-size: 12px;
color: #fff;
line-height: 46px;
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>
@@ -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>
<div class="device-item">
<div style="display: flex;justify-content: space-between;">
<div style="font-weight: 700;font-size: 24px;text-align: left;color: #3d4566;">
{{ device.mac }}
<div style="font-weight: 700;font-size: 18px;text-align: left;color: #3d4566;">
{{ device.agentName }}
</div>
<div>
<img src="@/assets/home/delete.png" alt=""
style="width: 24px;height: 24px;margin-right: 10px;" />
<img src="@/assets/home/info.png" alt="" style="width: 24px;height: 24px;" />
style="width: 18px;height: 18px;margin-right: 10px;" />
<img src="@/assets/home/info.png" alt="" style="width: 18px;height: 18px;" />
</div>
</div>
<div class="device-name">
设备型号{{ device.model }}
设备型号{{ device.ttsModelName }}
</div>
<div class="device-name">
音色模型{{ device.ttsVoiceName }}
</div>
<div style="display: flex;gap: 10px;align-items: center;">
<div class="settings-btn" @click="$emit('configure')">
@@ -23,12 +26,12 @@
<div class="settings-btn">
历史对话
</div>
<el-switch v-model="switchValue" inactive-text="OTA升级:" :width="42"
style="margin-left: auto;" />
<div class="settings-btn" @click="$emit('deviceManage')">
设备管理
</div>
</div>
<div class="version-info">
<div>最近对话{{ device.lastConversation }}</div>
<div>APP版本{{ device.appVersion }}</div>
<div>最近对话{{ device.lastConnectedAt }}</div>
</div>
</div>
</template>
@@ -46,28 +49,28 @@ export default {
</script>
<style scoped>
.device-item {
width: 455px;
width: 342px;
border-radius: 20px;
background: #fafcfe;
padding: 30px;
padding: 22px;
box-sizing: border-box;
}
.device-name {
margin: 10px 0 14px;
margin: 7px 0 10px;
font-weight: 400;
font-size: 14px;
font-size: 11px;
color: #3d4566;
text-align: left;
}
.settings-btn {
font-weight: 500;
font-size: 14px;
font-size: 10px;
color: #5778ff;
background: #e6ebff;
width: 76px;
height: 28px;
line-height: 28px;
width: 57px;
height: 21px;
line-height: 21px;
cursor: pointer;
border-radius: 14px;
}
@@ -75,9 +78,10 @@ export default {
.version-info {
display: flex;
justify-content: space-between;
margin-top: 20px;
font-size: 14px;
margin-top: 15px;
font-size: 10px;
color: #979db1;
font-weight: 400;
}
</style>
+114 -41
View File
@@ -1,49 +1,98 @@
<template>
<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;">
<img src="@/assets/xiaozhi-logo.png" alt="" style="width: 56px;height: 56px;" />
<img src="@/assets/xiaozhi-ai.png" alt="" style="width: 78px;height: 16px;" />
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 42px;height: 42px;"/>
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 58px;height: 12px;"/>
<div class="equipment-management" @click="goHome">
<img src="@/assets/home/equipment.png" alt="" style="width: 16px;height: 14px;" />
设备管理
<img alt="" src="@/assets/home/equipment.png" style="width: 12px;height: 10px;"/>
智能体管理
</div>
<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 class="equipment-management2">
设备管理
<img src="@/assets/home/close.png" alt="" style="width: 8px;height: 8px;" />
<img alt="" src="@/assets/home/close.png" style="width: 6px;height: 6px;"/>
</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">
<el-input placeholder="输入名称搜索.." v-model="serach" />
<img src="@/assets/home/search.png" alt=""
style="width: 16px;height: 16px;margin-right: 15px;cursor: pointer;" />
</div>
<img src="@/assets/home/avatar.png" alt="" style="width: 28px;height: 28px;" />
<div class="user-info">
158 3632 4642
<el-input v-model="serach" placeholder="输入名称搜索.." style="border: none; background: transparent;"
@keyup.enter.native="handleSearch"/>
<img alt="" src="@/assets/home/search.png"
style="width: 14px;height: 14px;margin-right: 11px;cursor: pointer;" @click="handleSearch"/>
</div>
<img alt="" src="@/assets/home/avatar.png" style="width: 21px;height: 21px;"/>
<el-dropdown trigger="click">
<span class="el-dropdown-link">
{{ userInfo.username || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item icon="el-icon-plus" @click.native="">个人中心</el-dropdown-item>
<el-dropdown-item icon="el-icon-circle-plus" @click.native="">修改密码</el-dropdown-item>
<el-dropdown-item icon="el-icon-circle-plus-outline" @click.native="">退出登录</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</el-header>
</template>
<script>
import userApi from '@/apis/module/user'
export default {
name: 'HeaderBar',
props: ['devices'], // 接收父组件设备列表
data() {
return { serach: '' }
return {
serach: '',
userInfo: {
username: '',
mobile: ''
}
}
},
mounted() {
this.fetchUserInfo()
},
methods: {
goHome() {
// 跳转到首页
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>
@@ -54,69 +103,93 @@ export default {
}
.equipment-management {
width: 110px;
height: 32px;
border-radius: 16px;
width: 82px;
height: 24px;
border-radius: 12px;
background: #5778ff;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
gap: 7px;
font-weight: 500;
color: #fff;
font-size: 14px;
font-size: 10px;
}
.equipment-management2 {
width: 116px;
height: 30px;
border-radius: 15px;
width: 87px;
height: 22px;
border-radius: 11px;
background: #fff;
display: flex;
justify-content: center;
font-size: 12px;
color: #979db1;
font-size: 9px;
font-weight: 400;
gap: 10px;
gap: 7px;
color: #3d4566;
margin-left: 20px;
margin-left: 2px;
align-items: center;
}
.header {
background: #f6fcfe66;
border: 1px solid #fff;
height: 53px !important;
}
.serach-box {
display: flex;
width: 306px;
height: 40px;
border-radius: 20px;
background-color: #e2f5f7;
width: 220px;
height: 30px;
border-radius: 15px;
background-color: #f6fcfe66;
border: 1px solid #e4e6ef;
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 {
font-weight: 600;
font-size: 16px;
font-size: 12px;
letter-spacing: -0.02px;
text-align: left;
color: #3d4566;
}
.console {
width: 120px;
height: 30px;
border-radius: 15px;
width: 90px;
height: 22px;
border-radius: 11px;
background: radial-gradient(50% 50% at 50% 50%, #fff 0%, #e8f0ff 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-size: 9px;
color: #979db1;
font-weight: 400;
gap: 10px;
color: #979db1;
margin-left: 20px;
gap: 7px;
margin-left: 15px;
}
</style>
.el-dropdown-link {
cursor: pointer;
color: #5778ff;
}
.el-icon-arrow-down {
font-size: 12px;
}
</style>
+9
View File
@@ -39,6 +39,15 @@ const routes = [
return import('../views/register.vue')
}
},
// 新增设备管理页面路由
{
path: '/device-management',
name: 'DeviceManagement',
component: function () {
return import('../views/DeviceManagement.vue')
}
}
]
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>
<div class="welcome">
<!-- 公共头部 -->
<HeaderBar />
<HeaderBar :devices="devices" @search-result="handleSearchResult" />
<el-main style="padding: 20px;display: flex;flex-direction: column;">
<div>
<!-- 首页内容 -->
@@ -21,23 +21,23 @@
</div>
<div class="add-device-btn" @click="showAddDialog">
<div class="left-add">
添加设备
添加智能体
</div>
<div style="width: 23px;height: 13px;background: #5778ff;margin-left: -10px;" />
<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 style="display: flex;flex-wrap: wrap;margin-top: 20px;gap: 20px;justify-content: space-between;box-sizing: border-box;">
<DeviceItem v-for="(item,index) in devices" :key="index" :device="item" @configure="goToRoleConfig" />
<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" @deviceManage="handleDeviceManage" />
</div>
</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" />
<AddWisdomBodyDialog :visible.sync="addDeviceDialogVisible" @confirm="handleWisdomBodyAdded" />
</el-main>
</div>
@@ -45,24 +45,24 @@
<script>
import DeviceItem from '@/components/DeviceItem.vue'
import AddDeviceDialog from '@/components/AddDeviceDialog.vue'
import AddWisdomBodyDialog from '@/components/AddWisdomBodyDialog.vue'
import HeaderBar from '@/components/HeaderBar.vue'
export default {
name: 'HomePage',
components: { DeviceItem, AddDeviceDialog, HeaderBar },
components: { DeviceItem, AddWisdomBodyDialog, HeaderBar },
data() {
return {
addDeviceDialogVisible: false,
// 此处模拟设备列表(10条数据)
devices: Array.from({ length: 10 }, (_, i) => ({
id: i,
mac: 'CC:ba:97:11:a6:ac',
model: 'esp32-s3-touch-amoled-1.8',
lastConversation: '6天前',
appVersion: '1.1.0'
}))
devices: [],
originalDevices: [],
}
},
mounted() {
this.fetchAgentList();
},
methods: {
showAddDialog() {
this.addDeviceDialogVisible = true
@@ -71,19 +71,39 @@ export default {
// 点击配置角色后跳转到角色配置页
this.$router.push('/role-config')
},
handleDeviceAdded(deviceCode) {
// 根据需要处理添加设备后逻辑,比如刷新设备列表等
console.log('设备验证码', deviceCode)
handleWisdomBodyAdded(res) {
console.log('新增智能体响应', res);
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>
<style scoped>
.welcome {
min-width: 1200px;
min-height: 675px;
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
flex-direction: column;
background-image: url("@/assets/home/background.png");
background-size: cover;
/* 确保背景图像覆盖整个元素 */
@@ -95,8 +115,8 @@ export default {
/* 兼容老版本Opera浏览器 */
}
.add-device {
height: 260px;
border-radius: 20px;
height: 195px;
border-radius: 15px;
position: relative;
overflow: hidden;
background: linear-gradient(
@@ -122,48 +142,48 @@ export default {
box-sizing: border-box;
/* 兼容老版本Opera浏览器 */
.hellow-text {
margin-left: 100px;
margin-left: 75px;
color: #3d4566;
font-size: 44px;
font-size: 33px;
font-weight: 700;
letter-spacing: 0;
}
.hi-hint {
font-weight: 400;
font-size: 12px;
font-size: 10px;
text-align: left;
color: #818cae;
margin-left: 100px;
margin-top: 7px;
margin-left: 75px;
margin-top: 5px;
}
}
.add-device-btn {
display: flex;
align-items: center;
margin-left: 100px;
margin-top: 20px;
margin-left: 75px;
margin-top: 15px;
cursor: pointer;
.left-add {
width: 140px;
height: 46px;
border-radius: 23px;
width: 105px;
height: 34px;
border-radius: 17px;
background: #5778ff;
color: #fff;
font-size: 14px;
font-size: 10px;
font-weight: 500;
text-align: center;
line-height: 46px;
line-height: 34px;
}
.right-add {
width: 46px;
height: 46px;
width: 34px;
height: 34px;
border-radius: 50%;
background: #5778ff;
margin-left: -8px;
margin-left: -6px;
display: flex;
justify-content: center;
align-items: center;
+30 -20
View File
@@ -4,15 +4,15 @@
<el-header>
<div
style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img src="@/assets/xiaozhi-logo.png" alt="" style="width: 45px;height: 45px;"/>
<img src="@/assets/xiaozhi-ai.png" alt="" style="width: 70px;height: 13px;"/>
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
</div>
</el-header>
<el-main style="position: relative;">
<div class="login-box">
<div
style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
<img src="@/assets/login/hi.png" alt="" style="width: 34px;height: 34px;"/>
<img alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
<div class="login-text">登录</div>
<div class="login-welcome">
WELCOME TO LOGIN
@@ -20,16 +20,16 @@
</div>
<div style="padding: 0 30px;">
<div class="input-box">
<img src="@/assets/login/username.png" alt="" class="input-icon"/>
<img alt="" class="input-icon" src="@/assets/login/username.png"/>
<el-input v-model="form.username" placeholder="请输入用户名"/>
</div>
<div class="input-box">
<img src="@/assets/login/password.png" alt="" class="input-icon"/>
<el-input v-model="form.password" type="password" placeholder="请输入密码"/>
<img alt="" class="input-icon" src="@/assets/login/password.png"/>
<el-input v-model="form.password" placeholder="请输入密码" type="password"/>
</div>
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
<img src="@/assets/login/shield.png" alt="" class="input-icon"/>
<img alt="" class="input-icon" src="@/assets/login/shield.png"/>
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
</div>
<img v-if="captchaUrl"
@@ -105,33 +105,43 @@ export default {
}
},
// 封装输入验证逻辑
validateInput(input, message) {
if (!input.trim()) {
showDanger(message);
return false;
}
return true;
},
async login() {
if (!this.form.username.trim()) { // 替换isNull校验
showDanger('用户名不能为空')
return
// 验证用户名
if (!this.validateInput(this.form.username, '用户名不能为空')) {
return;
}
if (!this.form.password.trim()) { // 替换isNull校验
showDanger('密码不能为空')
return
// 验证密码
if (!this.validateInput(this.form.password, '密码不能为空')) {
return;
}
if (!this.form.captcha.trim()) { // 替换isNull校验
showDanger('验证码不能为空')
return
// 验证验证码
if (!this.validateInput(this.form.captcha, '验证码不能为空')) {
return;
}
this.form.captchaId = this.captchaUuid
Api.user.login(this.form, ({data}) => {
console.log(data)
showSuccess('登陆成功!')
// 将令牌存储到 Vuex 中
this.$store.commit('setToken', JSON.stringify(data.data))
goToPage('/home')
})
// 重新获取验证码
setTimeout(() => {
this.fetchCaptcha()
}, 1000)
this.fetchCaptcha();
}, 1000);
},
goToRegister() {
@@ -140,6 +150,6 @@ export default {
}
}
</script>
<style scoped lang="scss">
<style lang="scss" scoped>
@import './auth.scss'; // 添加这行引用
</style>
+33 -24
View File
@@ -4,8 +4,8 @@
<!-- 保持相同的头部 -->
<el-header>
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img src="@/assets/xiaozhi-logo.png" alt="" style="width: 45px;height: 45px;"/>
<img src="@/assets/xiaozhi-ai.png" alt="" style="width: 70px;height: 13px;"/>
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
</div>
</el-header>
@@ -13,7 +13,7 @@
<div class="login-box">
<!-- 修改标题部分 -->
<div style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
<img src="@/assets/login/hi.png" alt="" style="width: 34px;height: 34px;"/>
<img alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
<div class="login-text">注册</div>
<div class="login-welcome">
WELCOME TO REGISTER
@@ -23,33 +23,33 @@
<div style="padding: 0 30px;">
<!-- 用户名输入框 -->
<div class="input-box">
<img src="@/assets/login/username.png" alt="" class="input-icon"/>
<img alt="" class="input-icon" src="@/assets/login/username.png"/>
<el-input v-model="form.username" placeholder="请输入用户名"/>
</div>
<!-- 密码输入框 -->
<div class="input-box">
<img src="@/assets/login/password.png" alt="" class="input-icon"/>
<el-input v-model="form.password" type="password" placeholder="请输入密码"/>
<img alt="" class="input-icon" src="@/assets/login/password.png"/>
<el-input v-model="form.password" placeholder="请输入密码" type="password"/>
</div>
<!-- 新增确认密码 -->
<div class="input-box">
<img src="@/assets/login/password.png" alt="" class="input-icon"/>
<el-input v-model="form.confirmPassword" type="password" placeholder="请确认密码"/>
<img alt="" class="input-icon" src="@/assets/login/password.png"/>
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password"/>
</div>
<!-- 验证码部分保持相同 -->
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
<img src="@/assets/login/shield.png" alt="" class="input-icon"/>
<img alt="" class="input-icon" src="@/assets/login/shield.png"/>
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
</div>
<img v-if="captchaUrl"
:src="captchaUrl"
alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;"
@click="fetchCaptcha"
:src="captchaUrl"
alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;"
@click="fetchCaptcha"
/>
</div>
@@ -119,24 +119,33 @@ export default {
});
},
// 封装输入验证逻辑
validateInput(input, message) {
if (!input.trim()) {
showDanger(message);
return false;
}
return true;
},
// 注册逻辑
register() {
if (!this.form.username.trim()) {
showDanger('用户名不能为空')
return
// 验证用户名
if (!this.validateInput(this.form.username, '用户名不能为空')) {
return;
}
if (!this.form.password.trim()) {
showDanger('密码不能为空')
return
// 验证密码
if (!this.validateInput(this.form.password, '密码不能为空')) {
return;
}
if (this.form.password !== this.form.confirmPassword) {
showDanger('两次输入的密码不一致')
return
}
if (!this.form.captcha.trim()) {
showDanger('验证码不能为空')
return
// 验证验证码
if (!this.validateInput(this.form.captcha, '验证码不能为空')) {
return;
}
Api.user.register(this.form, ({data}) => {
console.log(data)
if (data.code === 0) {
@@ -159,6 +168,6 @@ export default {
}
</script>
<style scoped lang="scss">
<style lang="scss" scoped>
@import './auth.scss'; // 修改为导入新建的SCSS文件
</style>
</style>
+109 -79
View File
@@ -2,45 +2,33 @@
<div class="welcome">
<!-- 公共头部 -->
<HeaderBar/>
<el-main style="padding: 20px;display: flex;flex-direction: column;">
<div style="border-radius: 20px;background: #fafcfe;">
<el-main style="padding: 16px;display: flex;flex-direction: column;">
<div style="border-radius: 16px;background: #fafcfe; border: 1px solid #e8f0ff;">
<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
style="width: 46px;height: 46px;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;"/>
style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;">
<img src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/>
</div>
{{ deviceMac }}
</div>
<div style="height: 1px;background: #e8f0ff;"/>
<el-form ref="form" :model="form" label-width="90px">
<div style="padding: 20px 30px;max-width: 990px;">
<el-form ref="form" :model="form" label-width="72px">
<div style="padding: 16px 24px;max-width: 792px;">
<el-form-item label="助手昵称:">
<div class="input-46">
<div class="input-46" style="width: 100%; max-width: 412px;">
<el-input v-model="form.name"/>
</div>
</el-form-item>
<el-form-item label="角色模版:">
<div style="display: flex;gap: 10px;">
<div class="template-item">
台湾女友
</div>
<div class="template-item">
土豆子
</div>
<div class="template-item">
英语老师
</div>
<div class="template-item">
好奇小男孩
</div>
<div class="template-item">
汪汪队队长
<div style="display: flex;gap: 8px;">
<div v-for="template in templates" :key="template" class="template-item" @click="selectTemplate(template)">
{{ template }}
</div>
</div>
</el-form-item>
<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;">
<el-select v-model="form.timbre" placeholder="请选择" style="width: 100%;">
<el-option v-for="item in options" :key="item.value" :label="item.label"
@@ -56,45 +44,39 @@
</el-form-item>
<el-form-item label="角色介绍:">
<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/>
</div>
</el-form-item>
<el-form-item label="记忆体:">
<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"/>
<div class="prompt-bottom">
<div style="display: flex;gap: 10px;align-items: center;">
<div style="color: #979db1;font-size: 14px;">当前记忆每次对话后重新生成</div>
<div style="display: flex;gap: 8px;align-items: center;">
<div style="color: #979db1;font-size: 11px;">当前记忆每次对话后重新生成</div>
<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 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>
</el-form-item>
<el-form-item label="语言模型(内测):" class="lh-form-item">
<div style="display: flex;gap: 10px;">
<div class="input-46" style="width: 100%;">
<el-select v-model="form.model" placeholder="请选择" style="width: 100%;">
<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 v-for="model in models" :key="model.label" :label="model.label" class="model-item">
<el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="select-field">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value"/>
</el-select>
</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
实时其他模型通常会增加约1秒的延迟改变模型后建议清空记忆体以免影响体验
</div>
</el-form-item>
</div>
</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>
@@ -102,12 +84,12 @@
重制
</div>
<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 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
</div>
</el-main>
@@ -128,15 +110,29 @@ export default {
timbre: "",
introduction: "",
prompt: "",
model: ""
model: {
tts: "",
vad: "",
asr: "",
llm: "",
memory:"",
intent:""
}
},
options: [{
value: '选项1',
label: '黄金糕'
}, {
value: '选项2',
label: '双皮奶'
}]
options: [
{ value: '选项1', label: '黄金糕' },
{ value: '选项2', label: '双皮奶' }
],
models: [
{ 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: {
@@ -161,6 +157,11 @@ export default {
this.$message.success('配置已重置')
}).catch(() => {
})
},
// 处理选择模板的逻辑
selectTemplate(template) {
this.form.name = template;
this.$message.success(`已选择模板:${template}`);
}
}
}
@@ -168,9 +169,11 @@ export default {
<style scoped>
.welcome {
min-width: 1200px;
min-height: 675px;
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
flex-direction: column;
background-image: url("@/assets/home/background.png");
background-size: cover;
/* 确保背景图像覆盖整个元素 */
@@ -181,71 +184,97 @@ export default {
-o-background-size: cover;
/* 兼容老版本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 {
flex: 1;
height: 46px;
border-radius: 10px;
height: 37px;
border-radius: 20px;
border: 1px solid #e4e6ef;
}
.clear-btn {
width: 60px;
height: 24px;
width: 48px;
height: 19px;
background: #fd8383;
border-radius: 12px;
line-height: 24px;
font-size: 14px;
border-radius: 10px;
line-height: 19px;
font-size: 11px;
color: #fff;
cursor: pointer;
}
.clear-text {
color: #979db1;
font-size: 14px;
font-size: 11px;
display: flex;
align-items: center;
gap: 10px;
margin-left: 20px;
gap: 8px;
margin-left: 16px;
}
.template-item {
height: 46px;
width: 100px;
border-radius: 10px;
height: 37px;
width: 76px;
border-radius: 8px;
background: #e6ebff;
line-height: 46px;
line-height: 37px;
font-weight: 400;
font-size: 14px;
font-size: 11px;
text-align: center;
color: #5778ff;
cursor: pointer;
transition: background-color 0.3s ease;
}
.template-item:hover {
background-color: #d0d8ff;
}
.prompt-bottom {
margin-bottom: 5px;
margin-bottom: 4px;
display: flex;
justify-content: space-between;
padding: 0 20px;
padding: 0 16px;
align-items: center;
}
.input-46 {
border: 1px solid #e4e6ef;
background: #f6f8fb;
border-radius: 10px;
border-radius: 8px;
height: 36px !important;
}
.save-btn,
.reset-btn {
width: 140px;
height: 46px;
border-radius: 23px;
line-height: 46px;
width: 112px;
height: 37px;
border-radius: 18px;
line-height: 37px;
box-sizing: border-box;
cursor: pointer;
font-size: 11px
}
.save-btn {
border-radius: 23px;
border-radius: 18px;
background: #5778ff;
color: #fff;
}
@@ -258,7 +287,8 @@ export default {
.textarea-box {
border: 1px solid #e4e6ef;
border-radius: 10px;
border-radius: 8px;
background: #f6f8fb;
}
</style>
</style>
+61 -23
View File
@@ -74,11 +74,11 @@ selected_module:
TTS: EdgeTTS
# 记忆模块,默认不开启记忆;如果想使用超长记忆,推荐使用mem0ai;如果注重隐私,请使用本地的mem_local_short
Memory: nomem
# 意图识别模块,默认不开启。开启后,可以播放音乐、控制音量、识别退出指令
# 意图识别模块,默认使用function_call。开启后,可以播放音乐、控制音量、识别退出指令
# 意图识别使用intent_llm,优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间
# 意图识别使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快
# 如果意图识别设置成 function_call,建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-pro-32k-functioncall-241028
Intent: nointent
# 默认免费的ChatGLMLLM就已经支持function_call但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-pro-32k-functioncall-241028
Intent: function_call
# 意图识别,是用于理解用户意图的模块,例如:播放音乐
Intent:
@@ -94,10 +94,11 @@ Intent:
type: nointent
# plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用
# 系统默认已经记载“handle_exit_intent(退出识别)”、“play_music(音乐播放)”插件,请勿重复加载
# 下面是加载查天气、角色切换的插件示例
# 下面是加载查天气、角色切换、加载查新闻的插件示例
functions:
- change_role
- get_weather
- get_news
# 插件的基础配置
plugins:
@@ -106,6 +107,14 @@ plugins:
# 想稳定一点就自行申请替换,每天有1000次免费调用
# 申请地址:https://console.qweather.com/#/apps/create-key/over
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
get_news:
default_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
category_urls:
society: "https://www.chinanews.com.cn/rss/society.xml"
world: "https://www.chinanews.com.cn/rss/world.xml"
finance: "https://www.chinanews.com.cn/rss/finance.xml"
Memory:
mem0ai:
@@ -114,10 +123,10 @@ Memory:
# 每月有1000次免费调用
api_key: 你的mem0ai api key
nomem:
# 不想使用记忆功能,可以使用nomem
# 不想使用记忆功能,可以使用nomem
type: nomem
mem_local_short:
# 本地记忆功能,通过selected_module的llm总结,数据保存在本地,不会上传到服务器
# 本地记忆功能,通过selected_module的llm总结,数据保存在本地,不会上传到服务器
type: mem_local_short
ASR:
@@ -125,6 +134,10 @@ ASR:
type: fun_local
model_dir: models/SenseVoiceSmall
output_dir: tmp/
SherpaASR:
type: sherpa_onnx_local
model_dir: models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17
output_dir: tmp/
DoubaoASR:
type: doubao
appid: 你的火山引擎语音合成服务appid
@@ -138,6 +151,7 @@ VAD:
min_silence_duration_ms: 700 # 如果说话停顿比较长,可以把这个值设置大一些
LLM:
# 所有openai类型均可以修改超参,以AliLLM为例
# 当前支持的type为openai、dify、ollama,可自行适配
AliLLM:
# 定义LLM API类型
@@ -146,6 +160,11 @@ LLM:
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
model_name: qwen-turbo
api_key: 你的deepseek web key
temperature: 0.7 # 温度值
max_tokens: 500 # 最大生成token数
top_p: 1
top_k: 50
frequency_penalty: 0 # 频率惩罚
DoubaoLLM:
# 定义LLM API类型
type: openai
@@ -181,8 +200,12 @@ LLM:
type: dify
# 建议使用本地部署的dify接口,国内部分区域访问dify公有云接口可能会受限
# 如果使用DifyLLM,配置文件里prompt(提示词)是无效的,需要在dify控制台设置提示词
base_url: https://api.dify.cn/v1
base_url: https://api.dify.ai/v1
api_key: 你的DifyLLM web key
# 使用的对话模式 可以选择工作流 workflows/run 对话模式 chat-messages 文本生成 completion-messages
# 使用workflows进行返回的时候输入参数为 query 返回参数的名字要设置为 answer
# 文本生成的默认输入参数也是query
mode: chat-messages
GeminiLLM:
type: gemini
# 谷歌Gemini API,需要先在Google Cloud控制台创建API密钥并获取api_key
@@ -190,7 +213,9 @@ LLM:
# token申请地址: https://aistudio.google.com/apikey
# 若部署地无法访问接口,需要开启科学上网
api_key: 你的gemini web key
model_name: "gemini-1.5-pro" # gemini-1.5-pro 是免费的
model_name: "gemini-2.0-flash"
http_proxy: "" #"http://127.0.0.1:10808"
https_proxy: "" #http://127.0.0.1:10808"
CozeLLM:
# 定义LLM API类型
type: coze
@@ -225,17 +250,18 @@ TTS:
# 定义TTS API类型
type: edge
voice: zh-CN-XiaoxiaoNeural
output_file: tmp/
output_dir: tmp/
DoubaoTTS:
# 定义TTS API类型
type: doubao
# 火山引擎语音合成服务,需要先在火山引擎控制台创建应用并获取appid和access_token
# 山引擎语音一定要购买花钱,起步价30元,就有100并发了。如果用免费的只有2个并发,会经常报tts错误
# 购买服务后,购买免费的音色后,可能要等半小时左右,才能使用。
# 地址https://console.volcengine.com/speech/service/8
# 普通音色在这里开通https://console.volcengine.com/speech/service/8
# 湾湾小何音色在这里开通:https://console.volcengine.com/speech/service/10007,开通后将下面的voice设置成zh_female_wanwanxiaohe_moon_bigtts
api_url: https://openspeech.bytedance.com/api/v1/tts
voice: BV001_streaming
output_file: tmp/
output_dir: tmp/
authorization: "Bearer;"
appid: 你的火山引擎语音合成服务appid
access_token: 你的火山引擎语音合成服务access_token
@@ -246,7 +272,7 @@ TTS:
# token申请地址 https://cloud.siliconflow.cn/account/ak
model: FunAudioLLM/CosyVoice2-0.5B
voice: FunAudioLLM/CosyVoice2-0.5B:alex
output_file: tmp/
output_dir: tmp/
access_token: 你的硅基流动API密钥
response_format: wav
CozeCnTTS:
@@ -254,7 +280,7 @@ TTS:
# COZECN TTS
# token申请地址 https://www.coze.cn/open/oauth/pats
voice: 7426720361733046281
output_file: tmp/
output_dir: tmp/
access_token: 你的coze web key
response_format: wav
FishSpeech:
@@ -267,7 +293,7 @@ TTS:
#--decoder-config-name firefly_gan_vq
#--compile
type: fishspeech
output_file: tmp/
output_dir: tmp/
response_format: wav
reference_id: null
reference_audio: ["/tmp/test.wav",]
@@ -291,7 +317,7 @@ TTS:
#python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/caixukun.yaml
type: gpt_sovits_v2
url: "http://127.0.0.1:9880/tts"
output_file: tmp/
output_dir: tmp/
text_lang: "auto"
ref_audio_path: "caixukun.wav"
prompt_text: ""
@@ -316,7 +342,7 @@ TTS:
#python api.py
type: gpt_sovits_v3
url: "http://127.0.0.1:9880"
output_file: tmp/
output_dir: tmp/
text_language: "auto"
refer_wav_path: "caixukun.wav"
prompt_language: "zh"
@@ -337,7 +363,7 @@ TTS:
# api_key地址:https://platform.minimaxi.com/user-center/basic-information/interface-key
# 定义TTS API类型
type: minimax
output_file: tmp/
output_dir: tmp/
group_id: 你的minimax平台groupID
api_key: 你的minimax平台接口密钥
model: "speech-01-turbo"
@@ -374,7 +400,7 @@ TTS:
# token地址:https://nls-portal.console.aliyun.com/overview
# 定义TTS API类型
type: aliyun
output_file: tmp/
output_dir: tmp/
appkey: 你的阿里云智能语音交互服务项目Appkey
token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_idaccess_key_secret
voice: xiaoyun
@@ -396,9 +422,21 @@ TTS:
type: doubao
api_url: https://api.302ai.cn/doubao/tts_hd
authorization: "Bearer "
# 湾湾小何音色
voice: "zh_female_wanwanxiaohe_moon_bigtts"
output_file: tmp/
output_dir: tmp/
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:
#在线网址:https://acgn.ttson.cn/
#token购买:www.ttson.cn
@@ -414,7 +452,7 @@ TTS:
to_lang: ZH
url: https://u95167-bd74-2aef8085.westx.seetacloud.com:8443/flashsummary/tts?token=
format: mp3
output_file: tmp/
output_dir: tmp/
emotion: 1
OpenAITTS:
# openai官方文本转语音服务,可支持全球大多数语种
@@ -428,7 +466,7 @@ TTS:
voice: onyx
# 语速范围0.25-4.0
speed: 1
output_file: tmp/
output_dir: tmp/
CustomTTS:
# 自定义的TTS接口服务,请求参数可自定义
# 要求接口使用GET方式请求,并返回音频文件
@@ -443,7 +481,7 @@ TTS:
headers: # 自定义请求头
# Authorization: Bearer xxxx
format: wav # 接口返回的音频格式
output_file: tmp/
output_dir: tmp/
# 模块测试配置
module_test:
test_sentences: # 自定义测试语句
@@ -466,4 +504,4 @@ manager:
enabled: false
ip: 0.0.0.0
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"
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():
global default_config_file
# 判断是否存在私有配置文件
"""获取配置文件路径,优先使用私有配置文件(若存在)。
Returns:
str: 配置文件路径(相对路径或默认路径)
"""
config_file = default_config_file
if os.path.exists(get_project_dir() + "data/." + default_config_file):
config_file = "data/." + default_config_file
@@ -20,9 +59,13 @@ def load_config():
"""加载配置文件"""
parser = argparse.ArgumentParser(description="Server configuration")
config_file = get_config_file()
parser.add_argument("--config_path", type=str, default=config_file)
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):
@@ -67,7 +110,7 @@ def find_missing_keys(new_config, old_config, parent_key=''):
def check_config_file():
old_config_file = get_config_file()
global default_config_file
if not old_config_file.startswith('data'):
if not 'data' in old_config_file:
return
old_config = read_config(get_project_dir() + old_config_file)
new_config = read_config(get_project_dir() + default_config_file)
+31 -22
View File
@@ -197,7 +197,7 @@ class ConnectionHandler:
self.dialogue.put(Message(role="system", content=self.prompt))
self.func_handler = FunctionHandler(self.config)
def change_system_prompt(self, prompt):
self.prompt = prompt
# 找到原来的role==system,替换原来的系统提示
@@ -303,7 +303,7 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).debug(json.dumps(self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False))
return True
def chat_with_function_calling(self, query, tool_call = False):
def chat_with_function_calling(self, query, tool_call=False):
self.logger.bind(tag=TAG).debug(f"Chat with function calling start: {query}")
"""Chat with function calling for intent detection using streaming"""
if self.isNeedAuth():
@@ -311,7 +311,7 @@ class ConnectionHandler:
future = asyncio.run_coroutine_threadsafe(self._check_and_broadcast_auth_code(), self.loop)
future.result()
return True
if not tool_call:
self.dialogue.put(Message(role="user", content=query))
@@ -320,7 +320,7 @@ class ConnectionHandler:
response_message = []
processed_chars = 0 # 跟踪已处理的字符位置
try:
start_time = time.time()
@@ -328,7 +328,7 @@ class ConnectionHandler:
future = asyncio.run_coroutine_threadsafe(self.memory.query_memory(query), self.loop)
memory_str = future.result()
#self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}")
# self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}")
# 使用支持functions的streaming接口
llm_responses = self.llm.response_with_functions(
@@ -351,8 +351,8 @@ class ConnectionHandler:
content_arguments = ""
for response in llm_responses:
content, tools_call = response
if content is not None and len(content)>0:
if len(response_message)<=0 and (content=="```" or "<tool_call>" in content):
if content is not None and len(content) > 0:
if len(response_message) <= 0 and (content == "```" or "<tool_call>" in content):
tool_call_flag = True
if tools_call is not None:
@@ -366,7 +366,7 @@ class ConnectionHandler:
if content is not None and len(content) > 0:
if tool_call_flag:
content_arguments+=content
content_arguments += content
else:
response_message.append(content)
@@ -422,16 +422,17 @@ class ConnectionHandler:
else:
function_arguments = json.loads(function_arguments)
if not bHasError:
self.logger.bind(tag=TAG).info(f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}")
self.logger.bind(tag=TAG).info(
f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}")
function_call_data = {
"name": function_name,
"id": function_id,
"arguments": function_arguments
}
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)
remaining_text = full_text[processed_chars:]
if remaining_text:
@@ -443,7 +444,7 @@ class ConnectionHandler:
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.llm_finish_task = True
@@ -452,32 +453,40 @@ class ConnectionHandler:
return True
def _handle_function_result(self, result, function_call_data, text_index):
if result.action == Action.RESPONSE: # 直接回复前端
if result.action == Action.RESPONSE: # 直接回复前端
text = result.response
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))
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
text = result.result
if text is not None and len(text) > 0:
function_id = function_call_data["id"]
function_name = function_call_data["name"]
function_arguments = function_call_data["arguments"]
self.dialogue.put(Message(role='assistant',
tool_calls=[{"id": function_id,
"function": {"arguments": function_arguments,"name": function_name},
"type": 'function',
"index": 0}]))
tool_calls=[{"id": function_id,
"function": {"arguments": function_arguments,
"name": function_name},
"type": 'function',
"index": 0}]))
self.dialogue.put(Message(role="tool", tool_call_id=function_id, content=text))
self.chat_with_function_calling(text, tool_call=True)
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.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))
def _tts_priority_thread(self):
while not self.stop_event.is_set():
+1 -32
View File
@@ -51,8 +51,6 @@ def create_iot_function(device_name, method_name, method_info):
# 生成结果信息
result = f"{device_name}{method_name}操作执行成功"
# 根据方法名尝试自动更新状态
await update_state_by_method(conn, device_name, method_name, params)
# 处理响应中可能的占位符
response = response_success
@@ -114,35 +112,6 @@ def create_iot_query_function(device_name, prop_name, prop_info):
return wrap_async_function(iot_query_function)
async def update_state_by_method(conn, device_name, method_name, params):
"""根据方法和参数自动更新设备状态"""
try:
# 规则1: 方法名为TurnOn,设置power为True
if method_name == "TurnOn":
await set_iot_status(conn, device_name, "power", True)
# 规则2: 方法名为TurnOff,设置power为False
elif method_name == "TurnOff":
await set_iot_status(conn, device_name, "power", False)
# 规则3: Set开头的方法,尝试更新对应参数
elif method_name.startswith("Set"):
# 从参数中找到可能的状态值
for param_name, param_value in params.items():
# 尝试更新对应名称的属性
await set_iot_status(conn, device_name, param_name, param_value)
# 其他方法,尝试直接从参数更新状态
else:
for param_name, param_value in params.items():
# 检查设备是否有此属性
status = await get_iot_status(conn, device_name, param_name)
if status is not None: # 属性存在
await set_iot_status(conn, device_name, param_name, param_value)
except Exception as e:
logger.bind(tag=TAG).warning(f"自动更新状态失败: {e}")
class IotDescriptor:
"""
A class to represent an IoT descriptor.
@@ -212,7 +181,7 @@ def register_device_type(descriptor):
},
"response_failure": {
"type": "string",
"description": "查询失败时的友好回复,例如:'无法获取{device_name}{prop_info['description']}'"
"description": f"查询失败时的友好回复,例如:'无法获取{device_name}{prop_info['description']}'"
}
},
"required": ["response_success", "response_failure"]
@@ -2,7 +2,7 @@ from config.logger import setup_logging
import json
from core.handle.abortHandle import handleAbortMessage
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
TAG = __name__
@@ -31,6 +31,8 @@ async def handleTextMessage(conn, message):
elif msg_json["state"] == "stop":
conn.client_have_voice = True
conn.client_voice_stop = True
if len(conn.asr_audio) > 0:
await handleAudioMessage(conn, b'')
elif msg_json["state"] == "detect":
conn.asr_server_receive = False
conn.client_have_voice = False
@@ -41,6 +43,6 @@ async def handleTextMessage(conn, message):
if "descriptors" in msg_json:
await handleIotDescriptors(conn, msg_json["descriptors"])
if "states" in msg_json:
await handleIotStatus(conn, msg_json["states"])
await handleIotStatus(conn, msg_json["states"])
except json.JSONDecodeError:
await conn.websocket.send(message)
@@ -0,0 +1,164 @@
import time
import wave
import os
import sys
import io
from config.logger import setup_logging
from typing import Optional, Tuple, List
import uuid
import opuslib_next
from core.providers.asr.base import ASRProviderBase
import numpy as np
import sherpa_onnx
from modelscope.hub.file_download import model_file_download
TAG = __name__
logger = setup_logging()
# 捕获标准输出
class CaptureOutput:
def __enter__(self):
self._output = io.StringIO()
self._original_stdout = sys.stdout
sys.stdout = self._output
def __exit__(self, exc_type, exc_value, traceback):
sys.stdout = self._original_stdout
self.output = self._output.getvalue()
self._output.close()
# 将捕获到的内容通过 logger 输出
if self.output:
logger.bind(tag=TAG).info(self.output.strip())
class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool):
self.model_dir = config.get("model_dir")
self.output_dir = config.get("output_dir")
self.delete_audio_file = delete_audio_file
# 确保输出目录存在
os.makedirs(self.output_dir, exist_ok=True)
# 初始化模型文件路径
model_files = {
"model.int8.onnx": os.path.join(self.model_dir, "model.int8.onnx"),
"tokens.txt": os.path.join(self.model_dir, "tokens.txt")
}
# 下载并检查模型文件
try:
for file_name, file_path in model_files.items():
if not os.path.isfile(file_path):
logger.bind(tag=TAG).info(f"正在下载模型文件: {file_name}")
model_file_download(
model_id="pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue",
file_path=file_name,
local_dir=self.model_dir
)
if not os.path.isfile(file_path):
raise FileNotFoundError(f"模型文件下载失败: {file_path}")
self.model_path = model_files["model.int8.onnx"]
self.tokens_path = model_files["tokens.txt"]
except Exception as e:
logger.bind(tag=TAG).error(f"模型文件处理失败: {str(e)}")
raise
with CaptureOutput():
self.model = sherpa_onnx.OfflineRecognizer.from_sense_voice(
model=self.model_path,
tokens=self.tokens_path,
num_threads=2,
sample_rate=16000,
feature_dim=80,
decoding_method="greedy_search",
debug=False,
use_itn=True,
)
def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str:
"""将Opus音频数据解码并保存为WAV文件"""
file_name = f"asr_{session_id}_{uuid.uuid4()}.wav"
file_path = os.path.join(self.output_dir, file_name)
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
with wave.open(file_path, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2) # 2 bytes = 16-bit
wf.setframerate(16000)
wf.writeframes(b"".join(pcm_data))
return file_path
def read_wave(self, wave_filename: str) -> Tuple[np.ndarray, int]:
"""
Args:
wave_filename:
Path to a wave file. It should be single channel and each sample should
be 16-bit. Its sample rate does not need to be 16kHz.
Returns:
Return a tuple containing:
- A 1-D array of dtype np.float32 containing the samples, which are
normalized to the range [-1, 1].
- sample rate of the wave file
"""
with wave.open(wave_filename) as f:
assert f.getnchannels() == 1, f.getnchannels()
assert f.getsampwidth() == 2, f.getsampwidth() # it is in bytes
num_samples = f.getnframes()
samples = f.readframes(num_samples)
samples_int16 = np.frombuffer(samples, dtype=np.int16)
samples_float32 = samples_int16.astype(np.float32)
samples_float32 = samples_float32 / 32768
return samples_float32, f.getframerate()
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
"""语音转文本主处理逻辑"""
file_path = None
try:
# 保存音频文件
start_time = time.time()
file_path = self.save_audio_to_file(opus_data, session_id)
logger.bind(tag=TAG).debug(f"音频文件保存耗时: {time.time() - start_time:.3f}s | 路径: {file_path}")
# 语音识别
start_time = time.time()
s = self.model.create_stream()
samples, sample_rate = self.read_wave(file_path)
s.accept_waveform(sample_rate, samples)
self.model.decode_stream(s)
text = s.result.text
logger.bind(tag=TAG).debug(f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}")
return text, file_path
except Exception as e:
logger.bind(tag=TAG).error(f"语音识别失败: {e}", exc_info=True)
return "", None
finally:
# 文件清理逻辑
if self.delete_audio_file and file_path and os.path.exists(file_path):
try:
os.remove(file_path)
logger.bind(tag=TAG).debug(f"已删除临时音频文件: {file_path}")
except Exception as e:
logger.bind(tag=TAG).error(f"文件删除失败: {file_path} | 错误: {e}")
@@ -9,6 +9,7 @@ logger = setup_logging()
class LLMProvider(LLMProviderBase):
def __init__(self, config):
self.api_key = config["api_key"]
self.mode = config.get("mode", "chat-messages")
self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip('/')
self.session_conversation_map = {} # 存储session_id和conversation_id的映射
@@ -19,27 +20,59 @@ class LLMProvider(LLMProviderBase):
conversation_id = self.session_conversation_map.get(session_id)
# 发起流式请求
with requests.post(
f"{self.base_url}/chat-messages",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
if self.mode == "chat-messages":
request_json = {
"query": last_msg["content"],
"response_mode": "streaming",
"user": session_id,
"inputs": {},
"conversation_id": conversation_id
},
}
elif self.mode == "workflows/run":
request_json = {
"inputs": {"query": last_msg["content"]},
"response_mode": "streaming",
"user": session_id
}
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
) as r:
for line in r.iter_lines():
if line.startswith(b'data: '):
event = json.loads(line[6:])
# 如果没有找到conversation_id,则获取此次conversation_id
if not conversation_id:
conversation_id = event.get('conversation_id')
self.session_conversation_map[session_id] = conversation_id # 更新映射
if event.get('answer'):
yield event['answer']
if self.mode == "chat-messages":
for line in r.iter_lines():
if line.startswith(b'data: '):
event = json.loads(line[6:])
# 如果没有找到conversation_id,则获取此次conversation_id
if not conversation_id:
conversation_id = event.get('conversation_id')
self.session_conversation_map[session_id] = conversation_id # 更新映射
if event.get('answer'):
yield event['answer']
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:
logger.bind(tag=TAG).error(f"Error in response generation: {e}")
@@ -1,14 +1,19 @@
import google.generativeai as genai
from core.utils.util import check_model_key
from core.providers.llm.base import LLMProviderBase
from config.logger import setup_logging
import requests
import json
TAG = __name__
logger = setup_logging()
class LLMProvider(LLMProviderBase):
def __init__(self, config):
"""初始化Gemini LLM Provider"""
self.model_name = config.get("model_name", "gemini-1.5-pro")
self.api_key = config.get("api_key")
self.http_proxy=config.get("http_proxy")
self.https_proxy = config.get("https_proxy")
have_key = check_model_key("LLM", self.api_key)
if not have_key:
@@ -16,6 +21,19 @@ class LLMProvider(LLMProviderBase):
try:
# 初始化Gemini客户端
# 配置代理(如果提供了代理配置)
self.proxies=None
if self.http_proxy is not "" or self.https_proxy is not "":
self.proxies = {
"http": self.http_proxy,
"https": self.https_proxy,
}
logger.bind(tag=TAG).info(f"Gemini set proxys:{self.proxies}")
# 使用猴子补丁修改 google-generativeai 库的请求会话
# 使用 session 对象配置 genai
genai.configure(api_key=self.api_key)
self.model = genai.GenerativeModel(self.model_name)
@@ -46,26 +64,54 @@ class LLMProvider(LLMProviderBase):
if content:
chat_history.append({
"role": role,
"parts": [content]
"parts": [{"text":content}]
})
# 获取当前消息
current_msg = dialogue[-1]["content"]
# 创建新的聊天会话
chat = self.model.start_chat(history=chat_history)
# 构建请求体
request_body = {
"contents": chat_history + [{"role": "user", "parts": [{"text":current_msg}]}],
"generationConfig": self.generation_config
}
# 发送消息并获取流式响应
response = chat.send_message(
current_msg,
stream=True,
generation_config=self.generation_config
)
# 构建请求URL
url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model_name}:generateContent?key={self.api_key}"
# 处理流式响应
for chunk in response:
if hasattr(chunk, 'text') and chunk.text:
yield chunk.text
# 构建请求头
headers = {
"Content-Type": "application/json",
}
# 发送POST请求,经测试手动 request 无法使用 stream 模式
if self.proxies:
response = requests.post(url, headers=headers, json=request_body, stream=False, proxies=self.proxies)
try:
data = response.json() # 直接解析JSON
if 'candidates' in data and data['candidates']:
yield data['candidates'][0]['content']['parts'][0]['text']
else:
yield "未找到候选回复。"
except json.JSONDecodeError as e:
yield f"JSON解码错误:{e}"
except Exception as e:
yield f"发生错误:{e}"
else:
logger.bind(tag=TAG).info(f"Gemini stream mode ")
chat = self.model.start_chat(history=chat_history)
# 发送消息并获取流式响应
response = chat.send_message(
current_msg,
stream=True,
generation_config=self.generation_config
)
# 处理流式响应
for chunk in response:
if hasattr(chunk, 'text') and chunk.text:
yield chunk.text
except Exception as e:
error_msg = str(e)
@@ -78,3 +124,13 @@ class LLMProvider(LLMProviderBase):
yield "【Gemini API key无效】"
else:
yield f"【Gemini服务响应异常: {error_msg}"
except requests.exceptions.RequestException as e:
yield f"请求失败:{e}"
except json.JSONDecodeError as e:
yield f"JSON解码错误:{e}"
except Exception as e:
yield f"发生错误:{e}"
@@ -1,7 +1,11 @@
import openai
from config.logger import setup_logging
from core.utils.util import check_model_key
from core.providers.llm.base import LLMProviderBase
TAG = __name__
logger = setup_logging()
class LLMProvider(LLMProviderBase):
def __init__(self, config):
@@ -11,6 +15,8 @@ class LLMProvider(LLMProviderBase):
self.base_url = config.get("base_url")
else:
self.base_url = config.get("url")
self.max_tokens = config.get("max_tokens", 500)
check_model_key("LLM", self.api_key)
self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url)
@@ -19,9 +25,10 @@ class LLMProvider(LLMProviderBase):
responses = self.client.chat.completions.create(
model=self.model_name,
messages=dialogue,
stream=True
stream=True,
max_tokens=self.max_tokens,
)
is_active = True
for chunk in responses:
try:
@@ -50,12 +57,12 @@ class LLMProvider(LLMProviderBase):
model=self.model_name,
messages=dialogue,
stream=True,
tools=functions,
tools=functions
)
for chunk in stream:
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls
except Exception as e:
self.logger.bind(tag=TAG).error(f"Error in function call streaming: {e}")
yield {"type": "content", "content": f"【OpenAI服务响应异常: {e}"}
logger.bind(tag=TAG).error(f"Error in function call streaming: {e}")
yield {"type": "content", "content": f"【OpenAI服务响应异常: {e}"}
@@ -5,6 +5,7 @@ import numpy as np
import opuslib_next
from pydub import AudioSegment
from abc import ABC, abstractmethod
from core.utils.tts import MarkdownCleaner
TAG = __name__
logger = setup_logging()
@@ -13,7 +14,7 @@ logger = setup_logging()
class TTSProviderBase(ABC):
def __init__(self, config, 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
def generate_filename(self):
@@ -23,6 +24,7 @@ class TTSProviderBase(ABC):
tmp_file = self.generate_filename()
try:
max_repeat_time = 5
text = MarkdownCleaner.clean_markdown(text)
while not os.path.exists(tmp_file) and max_repeat_time > 0:
asyncio.run(self.text_to_speak(text, tmp_file))
if not os.path.exists(tmp_file):
@@ -47,7 +49,8 @@ class TTSProviderBase(ABC):
file_type = os.path.splitext(audio_file_path)[1]
if file_type:
file_type = file_type.lstrip('.')
audio = AudioSegment.from_file(audio_file_path, format=file_type)
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
audio = AudioSegment.from_file(audio_file_path, format=file_type, parameters=["-nostdin"])
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
@@ -15,7 +15,7 @@ class TTSProvider(TTSProviderBase):
self.headers = config.get("headers", {})
self.params = config.get("params")
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):
return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}.{self.format}")
@@ -14,7 +14,7 @@ class TTSProvider(TTSProviderBase):
self.voice = config.get("voice", "alloy")
self.response_format = "wav"
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)
def generate_filename(self, extension=".wav"):
@@ -17,7 +17,7 @@ class TTSProvider(TTSProviderBase):
self.volume_change_dB = config.get("volume_change_dB", 0)
self.speed_factor = config.get("speed_factor", 1)
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.format = config.get("format", "mp3")
self.emotion = config.get("emotion", 1)
+96 -1
View File
@@ -1,4 +1,5 @@
import os
import re
import sys
from config.logger import setup_logging
import importlib
@@ -14,4 +15,98 @@ def create_instance(class_name, *args, **kwargs):
sys.modules[lib_name] = importlib.import_module(f'{lib_name}')
return sys.modules[lib_name].TTSProvider(*args, **kwargs)
raise ValueError(f"不支持的TTS类型: {class_name},请检查该配置的type是否设置正确")
raise ValueError(f"不支持的TTS类型: {class_name},请检查该配置的type是否设置正确")
class MarkdownCleaner:
"""
封装 Markdown 清理逻辑:直接用 MarkdownCleaner.clean_markdown(text) 即可
"""
# 公式字符
NORMAL_FORMULA_CHARS = re.compile(r'[a-zA-Z\\^_{}\+\-\(\)\[\]=]')
@staticmethod
def _replace_inline_dollar(m: re.Match) -> str:
"""
只要捕获到完整的 "$...$":
- 如果内部有典型公式字符 => 去掉两侧 $
- 否则 (纯数字/货币等) => 保留 "$...$"
"""
content = m.group(1)
if MarkdownCleaner.NORMAL_FORMULA_CHARS.search(content):
return content
else:
return m.group(0)
@staticmethod
def _replace_table_block(match: re.Match) -> str:
"""
当匹配到一个整段表格块时,回调该函数。
"""
block_text = match.group('table_block')
lines = block_text.strip('\n').split('\n')
parsed_table = []
for line in lines:
line_stripped = line.strip()
if re.match(r'^\|\s*[-:]+\s*(\|\s*[-:]+\s*)+\|?$', line_stripped):
continue
columns = [col.strip() for col in line_stripped.split('|') if col.strip() != '']
if columns:
parsed_table.append(columns)
if not parsed_table:
return ""
headers = parsed_table[0]
data_rows = parsed_table[1:] if len(parsed_table) > 1 else []
lines_for_tts = []
if len(parsed_table) == 1:
# 只有一行
only_line_str = ", ".join(parsed_table[0])
lines_for_tts.append(f"单行表格:{only_line_str}")
else:
lines_for_tts.append(f"表头是:{', '.join(headers)}")
for i, row in enumerate(data_rows, start=1):
row_str_list = []
for col_index, cell_val in enumerate(row):
if col_index < len(headers):
row_str_list.append(f"{headers[col_index]} = {cell_val}")
else:
row_str_list.append(cell_val)
lines_for_tts.append(f"{i} 行:{', '.join(row_str_list)}")
return "\n".join(lines_for_tts) + "\n"
# 预编译所有正则表达式(按执行频率排序)
# 这里要把 replace_xxx 的静态方法放在最前定义,以便在列表里能正确引用它们。
REGEXES = [
(re.compile(r'```.*?```', re.DOTALL), ''), # 代码块
(re.compile(r'^#+\s*', re.MULTILINE), ''), # 标题
(re.compile(r'(\*\*|__)(.*?)\1'), r'\2'), # 粗体
(re.compile(r'(\*|_)(?=\S)(.*?)(?<=\S)\1'), r'\2'), # 斜体
(re.compile(r'!\[.*?\]\(.*?\)'), ''), # 图片
(re.compile(r'\[(.*?)\]\(.*?\)'), r'\1'), # 链接
(re.compile(r'^\s*>+\s*', re.MULTILINE), ''), # 引用
(
re.compile(r'(?P<table_block>(?:^[^\n]*\|[^\n]*\n)+)', re.MULTILINE),
_replace_table_block
),
(re.compile(r'^\s*[*+-]\s*', re.MULTILINE), '- '), # 列表
(re.compile(r'\$\$.*?\$\$', re.DOTALL), ''), # 块级公式
(
re.compile(r'(?<![A-Za-z0-9])\$([^\n$]+)\$(?![A-Za-z0-9])'),
_replace_inline_dollar
),
(re.compile(r'\n{2,}'), '\n'), # 多余空行
]
@staticmethod
def clean_markdown(text: str) -> str:
"""
主入口方法:依序执行所有正则,移除或替换 Markdown 元素
"""
for regex, replacement in MarkdownCleaner.REGEXES:
text = regex.sub(replacement, text)
return text.strip()
+1
View File
@@ -21,6 +21,7 @@ services:
- ./data:/opt/xiaozhi-esp32-server/data
# 模型文件挂接,很重要
- ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt
# #智控台还没开发好,还不能完全使用,会报很多错误,如果是非技术人员,请不要启用智控台服务
# xiaozhi-esp32-server-web:
# image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
@@ -0,0 +1,205 @@
import random
import requests
import xml.etree.ElementTree as ET
from bs4 import BeautifulSoup
from config.logger import setup_logging
from plugins_func.register import register_function, ToolType, ActionResponse, Action
TAG = __name__
logger = setup_logging()
GET_NEWS_FUNCTION_DESC = {
"type": "function",
"function": {
"name": "get_news",
"description": (
"获取最新新闻,随机选择一条新闻进行播报。"
"用户可以指定新闻类型,如社会新闻、科技新闻、国际新闻等。"
"如果没有指定,默认播报社会新闻。"
"用户可以要求获取详细内容,此时会获取新闻的详细内容。"
),
"parameters": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "新闻类别,例如社会、科技、国际。可选参数,如果不提供则使用默认类别"
},
"detail": {
"type": "boolean",
"description": "是否获取详细内容,默认为false。如果为true,则获取上一条新闻的详细内容"
},
"lang": {
"type": "string",
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN"
}
},
"required": ["lang"]
}
}
}
def fetch_news_from_rss(rss_url):
"""从RSS源获取新闻列表"""
try:
response = requests.get(rss_url)
response.raise_for_status()
# 解析XML
root = ET.fromstring(response.content)
# 查找所有item元素(新闻条目)
news_items = []
for item in root.findall('.//item'):
title = item.find('title').text if item.find('title') is not None else "无标题"
link = item.find('link').text if item.find('link') is not None else "#"
description = item.find('description').text if item.find('description') is not None else "无描述"
pubDate = item.find('pubDate').text if item.find('pubDate') is not None else "未知时间"
news_items.append({
'title': title,
'link': link,
'description': description,
'pubDate': pubDate
})
return news_items
except Exception as e:
logger.bind(tag=TAG).error(f"获取RSS新闻失败: {e}")
return []
def fetch_news_detail(url):
"""获取新闻详情页内容并总结"""
try:
response = requests.get(url)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
# 尝试提取正文内容 (这里的选择器需要根据实际网站结构调整)
content_div = soup.select_one('.content_desc, .content, article, .article-content')
if content_div:
paragraphs = content_div.find_all('p')
content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
return content
else:
# 如果找不到特定的内容区域,尝试获取所有段落
paragraphs = soup.find_all('p')
content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
return content[:2000] # 限制长度
except Exception as e:
logger.bind(tag=TAG).error(f"获取新闻详情失败: {e}")
return "无法获取详细内容"
def map_category(category_text):
"""将用户输入的中文类别映射到配置文件中的类别键"""
if not category_text:
return None
# 类别映射字典,目前支持社会、国际、财经新闻,如需更多类型,参见配置文件
category_map = {
# 社会新闻
"社会": "society",
"社会新闻": "society",
# 国际新闻
"国际": "world",
"国际新闻": "world",
# 财经新闻
"财经": "finance",
"财经新闻": "finance",
"金融": "finance",
"经济": "finance"
}
# 转换为小写并去除空格
normalized_category = category_text.lower().strip()
# 返回映射结果,如果没有匹配项则返回原始输入
return category_map.get(normalized_category, category_text)
@register_function('get_news', GET_NEWS_FUNCTION_DESC, ToolType.SYSTEM_CTL)
def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_CN"):
"""获取新闻并随机选择一条进行播报,或获取上一条新闻的详细内容"""
try:
# 如果detail为True,获取上一条新闻的详细内容
if detail:
if not hasattr(conn, 'last_news_link') or not conn.last_news_link or 'link' not in conn.last_news_link:
return ActionResponse(Action.REQLLM, "抱歉,没有找到最近查询的新闻,请先获取一条新闻。", None)
link = conn.last_news_link.get('link')
title = conn.last_news_link.get('title', '未知标题')
if link == '#':
return ActionResponse(Action.REQLLM, "抱歉,该新闻没有可用的链接获取详细内容。", None)
logger.bind(tag=TAG).debug(f"获取新闻详情: {title}, URL={link}")
# 获取新闻详情
detail_content = fetch_news_detail(link)
if not detail_content or detail_content == "无法获取详细内容":
return ActionResponse(Action.REQLLM, f"抱歉,无法获取《{title}》的详细内容,可能是链接已失效或网站结构发生变化。", None)
# 构建详情报告
detail_report = (
f"根据下列数据,用{lang}回应用户的新闻详情查询请求:\n\n"
f"新闻标题: {title}\n"
f"详细内容: {detail_content}\n\n"
f"(请对上述新闻内容进行总结,提取关键信息,以自然、流畅的方式向用户播报,"
f"不要提及这是总结,就像是在讲述一个完整的新闻故事)"
)
return ActionResponse(Action.REQLLM, detail_report, None)
# 否则,获取新闻列表并随机选择一条
# 从配置中获取RSS URL
rss_config = conn.config["plugins"]["get_news"]
default_rss_url = rss_config.get("default_rss_url", "https://www.chinanews.com.cn/rss/society.xml")
# 将用户输入的类别映射到配置中的类别键
mapped_category = map_category(category)
# 如果提供了类别,尝试从配置中获取对应的URL
rss_url = default_rss_url
if mapped_category and mapped_category in rss_config.get("category_urls", {}):
rss_url = rss_config["category_urls"][mapped_category]
logger.bind(tag=TAG).info(f"获取新闻: 原始类别={category}, 映射类别={mapped_category}, URL={rss_url}")
# 获取新闻列表
news_items = fetch_news_from_rss(rss_url)
if not news_items:
return ActionResponse(Action.REQLLM, "抱歉,未能获取到新闻信息,请稍后再试。", None)
# 随机选择一条新闻
selected_news = random.choice(news_items)
# 保存当前新闻链接到连接对象,以便后续查询详情
if not hasattr(conn, 'last_news_link'):
conn.last_news_link = {}
conn.last_news_link = {
'link': selected_news.get('link', '#'),
'title': selected_news.get('title', '未知标题')
}
# 构建新闻报告
news_report = (
f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n"
f"新闻标题: {selected_news['title']}\n"
f"发布时间: {selected_news['pubDate']}\n"
f"新闻内容: {selected_news['description']}\n"
f"(请以自然、流畅的方式向用户播报这条新闻,可以适当总结内容,"
f"直接读出新闻即可,不需要额外多余的内容。"
f"如果用户询问更多详情,告知用户可以说'请详细介绍这条新闻'获取更多内容)"
)
return ActionResponse(Action.REQLLM, news_report, None)
except Exception as e:
logger.bind(tag=TAG).error(f"获取新闻出错: {e}")
return ActionResponse(Action.REQLLM, "抱歉,获取新闻时发生错误,请稍后再试。", None)
@@ -6,7 +6,7 @@ get_time_function_desc = {
"function": {
"name": "get_time",
"description": "获取当前时间、日期、星期几",
"parameters": {}
'parameters': {'type': 'object', 'properties': {}, 'required': []}
}
}
@@ -49,6 +49,8 @@ def raise_and_lower_the_volume(conn, action: str):
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':
+3 -1
View File
@@ -19,4 +19,6 @@ loguru==0.7.3
requests==2.32.3
cozepy==0.12.0
mem0ai==0.1.62
bs4==0.0.2
bs4==0.0.2
modelscope==1.23.2
sherpa_onnx==1.11.0