Merge branch 'main' into manager-api-lastConnectedAtIsNull-BUG

This commit is contained in:
hrz
2025-05-07 22:45:51 +08:00
committed by GitHub
128 changed files with 6418 additions and 1162 deletions
+4
View File
@@ -197,6 +197,10 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies>
<!-- 阿里云maven仓库 -->
@@ -177,5 +177,5 @@ public interface Constant {
/**
* 版本号
*/
public static final String VERSION = "0.3.13";
public static final String VERSION = "0.3.14";
}
@@ -103,4 +103,18 @@ public class RedisKeys {
public static String getOtaDownloadCountKey(String uuid) {
return "ota:download:count:" + uuid;
}
/**
* 获取字典数据的缓存key
*/
public static String getDictDataByTypeKey(String dictType) {
return "sys:dict:data:" + dictType;
}
/**
* 获取智能体音频ID的缓存key
*/
public static String getAgentAudioIdKey(String uuid) {
return "agent:audio:id:" + uuid;
}
}
@@ -128,12 +128,10 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
return SqlHelper.retBool(result);
}
@SuppressWarnings("unchecked")
protected Class<M> currentMapperClass() {
return (Class<M>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0);
}
@SuppressWarnings("unchecked")
@Override
public Class<T> currentModelClass() {
return (Class<T>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 1);
@@ -24,7 +24,6 @@ import xiaozhi.common.utils.ConvertUtils;
public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends BaseServiceImpl<M, T>
implements CrudService<T, D> {
@SuppressWarnings("unchecked")
protected Class<D> currentDtoClass() {
return (Class<D>) ReflectionKit.getSuperClassGenericType(getClass(), CrudServiceImpl.class, 2);
}
@@ -0,0 +1,36 @@
package xiaozhi.modules.agent.controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
@Tag(name = "智能体聊天历史管理")
@RequiredArgsConstructor
@RestController
@RequestMapping("/agent/chat-history")
public class AgentChatHistoryController {
private final AgentChatHistoryBizService agentChatHistoryBizService;
/**
* 小智服务聊天上报请求
* <p>
* 小智服务聊天上报请求,包含Base64编码的音频数据和相关信息。
*
* @param request 包含上传文件及相关信息的请求对象
*/
@Operation(summary = "小智服务聊天上报请求")
@PostMapping("/report")
public Result<Boolean> uploadFile(@Valid @RequestBody AgentChatHistoryReportDTO request) {
Boolean result = agentChatHistoryBizService.report(request);
return new Result<Boolean>().ok(result);
}
}
@@ -3,8 +3,13 @@ package xiaozhi.modules.agent.controller;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -25,14 +30,20 @@ import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
import xiaozhi.modules.agent.dto.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.device.service.DeviceService;
@@ -46,6 +57,9 @@ public class AgentController {
private final AgentService agentService;
private final AgentTemplateService agentTemplateService;
private final DeviceService deviceService;
private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService;
private final RedisUtils redisUtils;
@GetMapping("/list")
@Operation(summary = "获取用户智能体列表")
@@ -80,7 +94,7 @@ public class AgentController {
@PostMapping
@Operation(summary = "创建智能体")
@RequiresPermissions("sys:role:normal")
public Result<Void> save(@RequestBody @Valid AgentCreateDTO dto) {
public Result<String> save(@RequestBody @Valid AgentCreateDTO dto) {
AgentEntity entity = ConvertUtils.sourceToTarget(dto, AgentEntity.class);
// 获取默认模板
@@ -108,7 +122,7 @@ public class AgentController {
// ID、智能体编码和排序会在Service层自动生成
agentService.insert(entity);
return new Result<>();
return new Result<String>().ok(entity.getId());
}
@PutMapping("/{id}")
@@ -178,6 +192,8 @@ public class AgentController {
public Result<Void> delete(@PathVariable String id) {
// 先删除关联的设备
deviceService.deleteByAgentId(id);
// 删除关联的聊天记录
agentChatHistoryService.deleteByAgentId(id);
// 再删除智能体
agentService.deleteById(id);
return new Result<>();
@@ -192,4 +208,71 @@ public class AgentController {
return new Result<List<AgentTemplateEntity>>().ok(list);
}
@GetMapping("/{id}/sessions")
@Operation(summary = "获取智能体会话列表")
@RequiresPermissions("sys:role:normal")
@Parameters({
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true),
})
public Result<PageData<AgentChatSessionDTO>> getAgentSessions(
@PathVariable("id") String id,
@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
params.put("agentId", id);
PageData<AgentChatSessionDTO> page = agentChatHistoryService.getSessionListByAgentId(params);
return new Result<PageData<AgentChatSessionDTO>>().ok(page);
}
@GetMapping("/{id}/chat-history/{sessionId}")
@Operation(summary = "获取智能体聊天记录")
@RequiresPermissions("sys:role:normal")
public Result<List<AgentChatHistoryDTO>> getAgentChatHistory(
@PathVariable("id") String id,
@PathVariable("sessionId") String sessionId) {
// 获取当前用户
UserDetail user = SecurityUser.getUser();
// 检查权限
if (!agentService.checkAgentPermission(id, user.getId())) {
return new Result<List<AgentChatHistoryDTO>>().error("没有权限查看该智能体的聊天记录");
}
// 查询聊天记录
List<AgentChatHistoryDTO> result = agentChatHistoryService.getChatHistoryBySessionId(id, sessionId);
return new Result<List<AgentChatHistoryDTO>>().ok(result);
}
@PostMapping("/audio/{audioId}")
@Operation(summary = "获取音频下载ID")
@RequiresPermissions("sys:role:normal")
public Result<String> getAudioId(@PathVariable("audioId") String audioId) {
byte[] audioData = agentChatAudioService.getAudio(audioId);
if (audioData == null) {
return new Result<String>().error("音频不存在");
}
String uuid = UUID.randomUUID().toString();
redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId);
return new Result<String>().ok(uuid);
}
@GetMapping("/play/{uuid}")
@Operation(summary = "播放音频")
public ResponseEntity<byte[]> playAudio(@PathVariable("uuid") String uuid) {
String audioId = (String) redisUtils.get(RedisKeys.getAgentAudioIdKey(uuid));
if (StringUtils.isBlank(audioId)) {
return ResponseEntity.notFound().build();
}
byte[] audioData = agentChatAudioService.getAudio(audioId);
if (audioData == null) {
return ResponseEntity.notFound().build();
}
redisUtils.delete(RedisKeys.getAgentAudioIdKey(uuid));
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"play.wav\"")
.body(audioData);
}
}
@@ -3,6 +3,7 @@ package xiaozhi.modules.agent.dao;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import xiaozhi.common.dao.BaseDao;
import xiaozhi.modules.agent.entity.AgentEntity;
@@ -15,4 +16,16 @@ public interface AgentDao extends BaseDao<AgentEntity> {
* @return 设备数量
*/
Integer getDeviceCountByAgentId(@Param("agentId") String agentId);
}
/**
* 根据设备MAC地址查询对应设备的默认智能体信息
*
* @param macAddress 设备MAC地址
* @return 默认智能体信息
*/
@Select(" SELECT a.* FROM ai_device d " +
" LEFT JOIN ai_agent a ON d.agent_id = a.id " +
" WHERE d.mac_address = #{macAddress} " +
" ORDER BY d.id DESC LIMIT 1")
AgentEntity getDefaultAgentByMacAddress(@Param("macAddress") String macAddress);
}
@@ -0,0 +1,18 @@
package xiaozhi.modules.agent.dao;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
/**
* {@link AgentChatAudioEntity} 智能体聊天音频数据Dao对象
*
* @author Goody
* @version 1.0, 2025/5/8
* @since 1.0.0
*/
@Mapper
public interface AiAgentChatAudioDao extends BaseMapper<AgentChatAudioEntity> {
}
@@ -0,0 +1,31 @@
package xiaozhi.modules.agent.dao;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
/**
* {@link AgentChatHistoryEntity} 智能体聊天历史记录Dao对象
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
@Mapper
public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity> {
/**
* 根据智能体ID删除音频
*
* @param agentId 智能体ID
*/
void deleteAudioByAgentId(String agentId);
/**
* 根据智能体ID删除聊天历史记录
*
* @param agentId 智能体ID
*/
void deleteHistoryByAgentId(String agentId);
}
@@ -0,0 +1,28 @@
package xiaozhi.modules.agent.dto;
import java.util.Date;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 智能体聊天记录DTO
*/
@Data
@Schema(description = "智能体聊天记录")
public class AgentChatHistoryDTO {
@Schema(description = "创建时间")
private Date createdAt;
@Schema(description = "消息类型: 1-用户, 2-智能体")
private Byte chatType;
@Schema(description = "聊天内容")
private String content;
@Schema(description = "音频ID")
private String audioId;
@Schema(description = "MAC地址")
private String macAddress;
}
@@ -0,0 +1,31 @@
package xiaozhi.modules.agent.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 小智设备聊天上报请求
*
* @author Haotian
* @version 1.0, 2025/5/8
*/
@Data
@Schema(description = "小智设备聊天上报请求")
public class AgentChatHistoryReportDTO {
@Schema(description = "MAC地址", example = "00:11:22:33:44:55")
@NotBlank
private String macAddress;
@Schema(description = "会话ID", example = "79578c31-f1fb-426a-900e-1e934215f05a")
@NotBlank
private String sessionId;
@Schema(description = "消息类型: 1-用户, 2-智能体", example = "1")
@NotNull
private Byte chatType;
@Schema(description = "聊天内容", example = "你好呀")
@NotBlank
private String content;
@Schema(description = "base64编码的opus音频数据", example = "")
private String audioBase64;
}
@@ -0,0 +1,26 @@
package xiaozhi.modules.agent.dto;
import java.time.LocalDateTime;
import lombok.Data;
/**
* 智能体会话列表DTO
*/
@Data
public class AgentChatSessionDTO {
/**
* 会话ID
*/
private String sessionId;
/**
* 会话时间
*/
private LocalDateTime createdAt;
/**
* 聊天条数
*/
private Integer chatCount;
}
@@ -0,0 +1,29 @@
package xiaozhi.modules.agent.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 智能体聊天音频数据表
*
* @author Goody
* @version 1.0, 2025/5/8
* @since 1.0.0
*/
@Data
@TableName("ai_agent_chat_audio")
public class AgentChatAudioEntity {
/**
* 主键ID
*/
@TableId(type = IdType.ASSIGN_UUID)
private String id;
/**
* 音频opus数据
*/
private byte[] audio;
}
@@ -0,0 +1,81 @@
package xiaozhi.modules.agent.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 智能体聊天记录表
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "ai_agent_chat_history")
public class AgentChatHistoryEntity {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* MAC地址
*/
@TableField(value = "mac_address")
private String macAddress;
/**
* 智能体id
*/
@TableField(value = "agent_id")
private String agentId;
/**
* 会话ID
*/
@TableField(value = "session_id")
private String sessionId;
/**
* 消息类型: 1-用户, 2-智能体
*/
@TableField(value = "chat_type")
private Byte chatType;
/**
* 聊天内容
*/
@TableField(value = "content")
private String content;
/**
* 音频base64数据
*/
@TableField(value = "audio_id")
private String audioId;
/**
* 创建时间
*/
@TableField(value = "created_at")
private Date createdAt;
/**
* 更新时间
*/
@TableField(value = "updated_at")
private Date updatedAt;
}
@@ -0,0 +1,30 @@
package xiaozhi.modules.agent.service;
import com.baomidou.mybatisplus.extension.service.IService;
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
/**
* 智能体聊天音频数据表处理service
*
* @author Goody
* @version 1.0, 2025/5/8
* @since 1.0.0
*/
public interface AgentChatAudioService extends IService<AgentChatAudioEntity> {
/**
* 保存音频数据
*
* @param audioData 音频数据
* @return 音频ID
*/
String saveAudio(byte[] audioData);
/**
* 获取音频数据
*
* @param audioId 音频ID
* @return 音频数据
*/
byte[] getAudio(String audioId);
}
@@ -0,0 +1,45 @@
package xiaozhi.modules.agent.service;
import java.util.List;
import java.util.Map;
import com.baomidou.mybatisplus.extension.service.IService;
import xiaozhi.common.page.PageData;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
/**
* 智能体聊天记录表处理service
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity> {
/**
* 根据智能体ID获取会话列表
*
* @param params 查询参数,包含agentId、page、limit
* @return 分页的会话列表
*/
PageData<AgentChatSessionDTO> getSessionListByAgentId(Map<String, Object> params);
/**
* 根据会话ID获取聊天记录列表
*
* @param agentId 智能体ID
* @param sessionId 会话ID
* @return 聊天记录列表
*/
List<AgentChatHistoryDTO> getChatHistoryBySessionId(String agentId, String sessionId);
/**
* 根据智能体ID删除聊天记录
*
* @param agentId 智能体ID
*/
void deleteByAgentId(String agentId);
}
@@ -8,38 +8,75 @@ import xiaozhi.common.service.BaseService;
import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.entity.AgentEntity;
/**
* 智能体表处理service
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
public interface AgentService extends BaseService<AgentEntity> {
/**
* 管理员获取所有智能体列表(分页)
* 获取管理员智能体列表
*
* @param params 查询参数
* @return 分页数据
*/
PageData<AgentEntity> adminAgentList(Map<String, Object> params);
/**
* 获取智能体详情
* 根据ID获取智能体
*
* @param id 智能体ID
* @return 智能体实体
*/
AgentEntity getAgentById(String id);
/**
* 删除这个用户的所有
*
* @param userId
* 插入智能体
*
* @param entity 智能体实体
* @return 是否成功
*/
boolean insert(AgentEntity entity);
/**
* 根据用户ID删除智能体
*
* @param userId 用户ID
*/
void deleteAgentByUserId(Long userId);
/**
* 获取用户智能体列表
*
* @param userId
* @return
*
* @param userId 用户ID
* @return 智能体列表
*/
List<AgentDTO> getUserAgents(Long userId);
/**
* 获取智能体设备数量
*
* 根据智能体ID获取设备数量
*
* @param agentId 智能体ID
* @return 设备数量
*/
Integer getDeviceCountByAgentId(String agentId);
}
/**
* 根据设备MAC地址查询对应设备的默认智能体信息
*
* @param macAddress 设备MAC地址
* @return 默认智能体信息,不存在时返回null
*/
AgentEntity getDefaultAgentByMacAddress(String macAddress);
/**
* 检查用户是否有权限访问智能体
*
* @param agentId 智能体ID
* @param userId 用户ID
* @return 是否有权限
*/
boolean checkAgentPermission(String agentId, Long userId);
}
@@ -0,0 +1,22 @@
package xiaozhi.modules.agent.service.biz;
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
/**
* 智能体聊天历史业务逻辑层
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
public interface AgentChatHistoryBizService {
/**
* 聊天上报方法
*
* @param agentChatHistoryReportDTO 包含聊天上报所需信息的输入对象
* 例如:设备MAC地址、文件类型、内容等
* @return 上传结果,true表示成功,false表示失败
*/
Boolean report(AgentChatHistoryReportDTO agentChatHistoryReportDTO);
}
@@ -0,0 +1,82 @@
package xiaozhi.modules.agent.service.biz.impl;
import java.util.Base64;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
/**
* {@link AgentChatHistoryBizService} impl
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizService {
private final AgentService agentService;
private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService;
/**
* 处理聊天记录上报,包括文件上传和相关信息记录
*
* @param report 包含聊天上报所需信息的输入对象
* @return 上传结果,true表示成功,false表示失败
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean report(AgentChatHistoryReportDTO report) {
String macAddress = report.getMacAddress();
Byte chatType = report.getChatType();
log.info("小智设备聊天上报请求: macAddress={}, type={}", macAddress, chatType);
// 1. base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表
String audioId = null;
if (report.getAudioBase64() != null && !report.getAudioBase64().isEmpty()) {
try {
byte[] audioData = Base64.getDecoder().decode(report.getAudioBase64());
audioId = agentChatAudioService.saveAudio(audioData);
log.info("音频数据保存成功,audioId={}", audioId);
} catch (Exception e) {
log.error("音频数据保存失败", e);
return false;
}
}
// 2. 组装上报数据
// 2.1 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
if (agentEntity == null) {
return false;
}
String agentId = agentEntity.getId();
log.info("设备 {} 对应智能体 {} 上报", macAddress, agentEntity.getId());
// 2.2 构建聊天记录实体
AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
.macAddress(macAddress)
.agentId(agentId)
.sessionId(report.getSessionId())
.chatType(report.getChatType())
.content(report.getContent())
.audioId(audioId)
.build();
// 3. 保存数据
agentChatHistoryService.save(entity);
return Boolean.TRUE;
}
}
@@ -0,0 +1,34 @@
package xiaozhi.modules.agent.service.impl;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import xiaozhi.modules.agent.dao.AiAgentChatAudioDao;
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
import xiaozhi.modules.agent.service.AgentChatAudioService;
/**
* 智能体聊天音频数据表处理service {@link AgentChatAudioService} impl
*
* @author Goody
* @version 1.0, 2025/5/8
* @since 1.0.0
*/
@Service
public class AgentChatAudioServiceImpl extends ServiceImpl<AiAgentChatAudioDao, AgentChatAudioEntity>
implements AgentChatAudioService {
@Override
public String saveAudio(byte[] audioData) {
AgentChatAudioEntity entity = new AgentChatAudioEntity();
entity.setAudio(audioData);
save(entity);
return entity.getId();
}
@Override
public byte[] getAudio(String audioId) {
AgentChatAudioEntity entity = getById(audioId);
return entity != null ? entity.getAudio() : null;
}
}
@@ -0,0 +1,85 @@
package xiaozhi.modules.agent.service.impl;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
/**
* 智能体聊天记录表处理service {@link AgentChatHistoryService} impl
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
@Service
public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryDao, AgentChatHistoryEntity>
implements AgentChatHistoryService {
@Override
public PageData<AgentChatSessionDTO> getSessionListByAgentId(Map<String, Object> params) {
String agentId = (String) params.get("agentId");
int page = Integer.parseInt(params.get(Constant.PAGE).toString());
int limit = Integer.parseInt(params.get(Constant.LIMIT).toString());
// 构建查询条件
QueryWrapper<AgentChatHistoryEntity> wrapper = new QueryWrapper<>();
wrapper.select("session_id", "MAX(created_at) as created_at", "COUNT(*) as chat_count")
.eq("agent_id", agentId)
.groupBy("session_id")
.orderByDesc("created_at");
// 执行分页查询
Page<Map<String, Object>> pageParam = new Page<>(page, limit);
IPage<Map<String, Object>> result = this.baseMapper.selectMapsPage(pageParam, wrapper);
List<AgentChatSessionDTO> records = result.getRecords().stream().map(map -> {
AgentChatSessionDTO dto = new AgentChatSessionDTO();
dto.setSessionId((String) map.get("session_id"));
dto.setCreatedAt((LocalDateTime) map.get("created_at"));
dto.setChatCount(((Number) map.get("chat_count")).intValue());
return dto;
}).collect(Collectors.toList());
return new PageData<>(records, result.getTotal());
}
@Override
public List<AgentChatHistoryDTO> getChatHistoryBySessionId(String agentId, String sessionId) {
// 构建查询条件
QueryWrapper<AgentChatHistoryEntity> wrapper = new QueryWrapper<>();
wrapper.eq("agent_id", agentId)
.eq("session_id", sessionId)
.orderByAsc("created_at");
// 查询聊天记录
List<AgentChatHistoryEntity> historyList = list(wrapper);
// 转换为DTO
return ConvertUtils.sourceToTarget(historyList, AgentChatHistoryDTO.class);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteByAgentId(String agentId) {
baseMapper.deleteAudioByAgentId(agentId);
baseMapper.deleteHistoryByAgentId(agentId);
}
}
@@ -6,13 +6,13 @@ import java.util.UUID;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.AllArgsConstructor;
import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
@@ -23,27 +23,17 @@ import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.enums.SuperAdminEnum;
import xiaozhi.modules.timbre.service.TimbreService;
@Service
@AllArgsConstructor
public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> implements AgentService {
private final AgentDao agentDao;
@Autowired
private TimbreService timbreModelService;
@Autowired
private DeviceService deviceService;
@Autowired
private ModelConfigService modelConfigService;
@Autowired
private RedisUtils redisUtils;
public AgentServiceImpl(AgentDao agentDao) {
this.agentDao = agentDao;
}
private final TimbreService timbreModelService;
private final ModelConfigService modelConfigService;
private final RedisUtils redisUtils;
@Override
public PageData<AgentEntity> adminAgentList(Map<String, Object> params) {
@@ -136,4 +126,29 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
return deviceCount != null ? deviceCount : 0;
}
}
@Override
public AgentEntity getDefaultAgentByMacAddress(String macAddress) {
if (StringUtils.isEmpty(macAddress)) {
return null;
}
return agentDao.getDefaultAgentByMacAddress(macAddress);
}
@Override
public boolean checkAgentPermission(String agentId, Long userId) {
// 获取智能体信息
AgentEntity agent = getAgentById(agentId);
if (agent == null) {
return false;
}
// 如果是超级管理员,直接返回true
if (SecurityUser.getUser().getSuperAdmin() == SuperAdminEnum.YES.value()) {
return true;
}
// 检查是否是智能体的所有者
return userId.equals(agent.getUserId());
}
}
@@ -1,6 +1,5 @@
package xiaozhi.modules.config.controller;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -8,15 +7,12 @@ import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.config.dto.AgentModelsDTO;
import xiaozhi.modules.config.service.ConfigService;
import xiaozhi.modules.sys.dto.ConfigSecretDTO;
import xiaozhi.modules.sys.service.SysParamsService;
/**
* xiaozhi-server 配置获取
@@ -29,33 +25,20 @@ import xiaozhi.modules.sys.service.SysParamsService;
@AllArgsConstructor
public class ConfigController {
private final ConfigService configService;
private final SysParamsService sysParamsService;
@PostMapping("server-base")
@Operation(summary = "获取配置")
public Result<Object> getConfig(@RequestBody ConfigSecretDTO dto) {
// 效验数据
ValidatorUtils.validateEntity(dto);
checkSecret(dto.getSecret());
public Result<Object> getConfig() {
Object config = configService.getConfig(true);
return new Result<Object>().ok(config);
}
@PostMapping("agent-models")
@Operation(summary = "获取智能体模型")
public Result<Object> getAgentModels(@RequestBody AgentModelsDTO dto) {
public Result<Object> getAgentModels(@Valid @RequestBody AgentModelsDTO dto) {
// 效验数据
ValidatorUtils.validateEntity(dto);
checkSecret(dto.getSecret());
Object models = configService.getAgentModels(dto.getMacAddress(), dto.getSelectedModule());
return new Result<Object>().ok(models);
}
private void checkSecret(String secret) {
String secretParam = sysParamsService.getValue(Constant.SERVER_SECRET, true);
// 验证密钥
if (StringUtils.isBlank(secret) || !secret.equals(secretParam)) {
throw new RenException("密钥错误");
}
}
}
@@ -10,9 +10,6 @@ import lombok.Data;
@Data
@Schema(description = "获取智能体模型配置DTO")
public class AgentModelsDTO {
@NotBlank(message = "密钥不能为空")
@Schema(description = "密钥")
private String secret;
@NotBlank(message = "设备MAC地址不能为空")
@Schema(description = "设备MAC地址")
@@ -61,15 +61,15 @@ public class ConfigServiceImpl implements ConfigService {
// 构建模块配置
buildModuleConfig(
agent.getAgentName(),
null,
null,
null,
agent.getVadModelId(),
agent.getAsrModelId(),
agent.getLlmModelId(),
agent.getTtsModelId(),
agent.getMemModelId(),
agent.getIntentModelId(),
null,
null,
null,
null,
result,
isCache);
@@ -117,18 +117,6 @@ public class ConfigServiceImpl implements ConfigService {
if (alreadySelectedAsrModelId != null && alreadySelectedAsrModelId.equals(agent.getAsrModelId())) {
agent.setAsrModelId(null);
}
String alreadySelectedLlmModelId = (String) selectedModule.get("LLM");
if (alreadySelectedLlmModelId != null && alreadySelectedLlmModelId.equals(agent.getLlmModelId())) {
agent.setLlmModelId(null);
}
String alreadySelectedMemModelId = (String) selectedModule.get("Memory");
if (alreadySelectedMemModelId != null && alreadySelectedMemModelId.equals(agent.getMemModelId())) {
agent.setMemModelId(null);
}
String alreadySelectedIntentModelId = (String) selectedModule.get("Intent");
if (alreadySelectedIntentModelId != null && alreadySelectedIntentModelId.equals(agent.getIntentModelId())) {
agent.setIntentModelId(null);
}
// 构建模块配置
buildModuleConfig(
@@ -153,7 +141,6 @@ public class ConfigServiceImpl implements ConfigService {
* @param paramsList 系统参数列表
* @return 配置信息
*/
@SuppressWarnings("unchecked")
private Object buildConfig(Map<String, Object> config) {
// 查询所有系统参数
@@ -21,9 +21,7 @@ import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.dao.AgentTemplateDao;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.model.dao.ModelConfigDao;
import xiaozhi.modules.model.dto.ModelBasicInfoDTO;
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
@@ -41,8 +39,6 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
private final ModelConfigDao modelConfigDao;
private final ModelProviderService modelProviderService;
private final RedisUtils redisUtils;
private final AgentTemplateDao agentTemplateDao;
private final AgentTemplateService agentTemplateService;
private final AgentDao agentDao;
@Override
@@ -18,6 +18,8 @@ import org.springframework.context.annotation.Configuration;
import jakarta.servlet.Filter;
import xiaozhi.modules.security.oauth2.Oauth2Filter;
import xiaozhi.modules.security.oauth2.Oauth2Realm;
import xiaozhi.modules.security.secret.ServerSecretFilter;
import xiaozhi.modules.sys.service.SysParamsService;
/**
* Shiro的配置文件
@@ -46,7 +48,7 @@ public class ShiroConfig {
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager, SysParamsService sysParamsService) {
ShiroFilterConfiguration config = new ShiroFilterConfiguration();
config.setFilterOncePerRequest(true);
@@ -54,9 +56,11 @@ public class ShiroConfig {
shiroFilter.setSecurityManager(securityManager);
shiroFilter.setShiroFilterConfiguration(config);
// oauth过滤
Map<String, Filter> filters = new HashMap<>();
// oauth过滤
filters.put("oauth2", new Oauth2Filter());
// 服务密钥过滤
filters.put("server", new ServerSecretFilter(sysParamsService));
shiroFilter.setFilters(filters);
// 添加Shiro的内置过滤器
@@ -79,8 +83,10 @@ public class ShiroConfig {
filterMap.put("/user/login", "anon");
filterMap.put("/user/pub-config", "anon");
filterMap.put("/user/register", "anon");
filterMap.put("/config/server-base", "anon");
filterMap.put("/config/agent-models", "anon");
// 将config路径使用server服务过滤器
filterMap.put("/config/**", "server");
filterMap.put("/agent/chat-history/report", "server");
filterMap.put("/agent/play/**", "anon");
filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap);
@@ -98,4 +104,4 @@ public class ShiroConfig {
advisor.setSecurityManager(securityManager);
return advisor;
}
}
}
@@ -1,5 +1,6 @@
package xiaozhi.modules.security.config;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.TimeZone;
@@ -18,6 +19,15 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import xiaozhi.common.utils.DateUtils;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@@ -53,10 +63,29 @@ public class WebMvcConfig implements WebMvcConfigurer {
// 忽略未知属性
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// 日期格式转换
// mapper.setDateFormat(new SimpleDateFormat(DateUtils.DATE_TIME_PATTERN));
// 设置时区
mapper.setTimeZone(TimeZone.getTimeZone("GMT+8"));
// 配置Java8日期时间序列化
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(java.time.LocalDateTime.class, new LocalDateTimeSerializer(
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_TIME_PATTERN)));
javaTimeModule.addSerializer(java.time.LocalDate.class, new LocalDateSerializer(
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_PATTERN)));
javaTimeModule.addSerializer(java.time.LocalTime.class,
new LocalTimeSerializer(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")));
javaTimeModule.addDeserializer(java.time.LocalDateTime.class, new LocalDateTimeDeserializer(
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_TIME_PATTERN)));
javaTimeModule.addDeserializer(java.time.LocalDate.class, new LocalDateDeserializer(
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_PATTERN)));
javaTimeModule.addDeserializer(java.time.LocalTime.class,
new LocalTimeDeserializer(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")));
mapper.registerModule(javaTimeModule);
// 配置java.util.Date的序列化和反序列化
SimpleDateFormat dateFormat = new SimpleDateFormat(DateUtils.DATE_TIME_PATTERN);
mapper.setDateFormat(dateFormat);
// Long类型转String类型
SimpleModule simpleModule = new SimpleModule();
simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
@@ -0,0 +1,105 @@
package xiaozhi.modules.security.secret;
import java.io.IOException;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.springframework.web.bind.annotation.RequestMethod;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.utils.HttpContextUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.sys.service.SysParamsService;
/**
* Config API 过滤器
*/
@Slf4j
@RequiredArgsConstructor
public class ServerSecretFilter extends AuthenticatingFilter {
private final SysParamsService sysParamsService;
@Override
protected ServerSecretToken createToken(ServletRequest request, ServletResponse response) {
// 获取请求token
String token = getRequestToken((HttpServletRequest) request);
if (StringUtils.isBlank(token)) {
log.warn("createToken:token is empty");
return null;
}
return new ServerSecretToken(token);
}
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
// 对OPTIONS请求放行
if (((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name())) {
return true;
}
return false;
}
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
// 获取token并校验
String token = getRequestToken((HttpServletRequest) servletRequest);
if (StringUtils.isBlank(token)) {
// token为空,返回401
this.sendUnauthorizedResponse((HttpServletResponse) servletResponse, "服务器密钥不能为空");
return false;
}
// 验证token是否匹配
String serverSecret = getServerSecret();
if (StringUtils.isBlank(serverSecret) || !serverSecret.equals(token)) {
// token无效,返回401
this.sendUnauthorizedResponse((HttpServletResponse) servletResponse, "无效的服务器密钥");
return false;
}
return true;
}
/**
* 发送未授权响应
*/
private void sendUnauthorizedResponse(HttpServletResponse response, String message) {
response.setContentType("application/json;charset=utf-8");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
try {
String json = JsonUtils.toJsonString(new Result<Void>().error(ErrorCode.UNAUTHORIZED, message));
response.getWriter().print(json);
} catch (IOException e) {
log.error("响应输出失败", e);
}
}
/**
* 获取请求的token
*/
private String getRequestToken(HttpServletRequest httpRequest) {
String token = null;
// 从header中获取token
String authorization = httpRequest.getHeader("Authorization");
if (StringUtils.isNotBlank(authorization) && authorization.startsWith("Bearer ")) {
token = authorization.replace("Bearer ", "");
}
return token;
}
private String getServerSecret() {
return sysParamsService.getValue(Constant.SERVER_SECRET, true);
}
}
@@ -0,0 +1,25 @@
package xiaozhi.modules.security.secret;
import org.apache.shiro.authc.AuthenticationToken;
/**
* Config API Token
*/
public class ServerSecretToken implements AuthenticationToken {
private static final long serialVersionUID = 1L;
private final String token;
public ServerSecretToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
@@ -58,7 +58,6 @@ public class AdminController {
dto.setLimit((String) params.get(Constant.LIMIT));
dto.setPage((String) params.get(Constant.PAGE));
ValidatorUtils.validateEntity(dto);
ValidatorUtils.validateEntity(dto);
PageData<AdminPageUserVO> page = sysUserService.page(dto);
return new Result<PageData<AdminPageUserVO>>().ok(page);
}
@@ -0,0 +1,105 @@
package xiaozhi.modules.sys.controller;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.sys.dto.SysDictDataDTO;
import xiaozhi.modules.sys.service.SysDictDataService;
import xiaozhi.modules.sys.vo.SysDictDataItem;
import xiaozhi.modules.sys.vo.SysDictDataVO;
/**
* 字典数据管理
*
* @author czc
* @since 2025-04-30
*/
@AllArgsConstructor
@RestController
@RequestMapping("/admin/dict/data")
@Tag(name = "字典数据管理")
public class SysDictDataController {
private final SysDictDataService sysDictDataService;
@GetMapping("/page")
@Operation(summary = "分页查询字典数据")
@RequiresPermissions("sys:role:superAdmin")
@Parameters({ @Parameter(name = "dictTypeId", description = "字典类型ID", required = true),
@Parameter(name = "dictLabel", description = "数据标签"), @Parameter(name = "dictValue", description = "数据值"),
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true) })
public Result<PageData<SysDictDataVO>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
ValidatorUtils.validateEntity(params);
// 强制校验dictTypeId是否存在
if (!params.containsKey("dictTypeId") || StringUtils.isEmpty(String.valueOf(params.get("dictTypeId")))) {
return new Result<PageData<SysDictDataVO>>().error("dictTypeId不能为空");
}
PageData<SysDictDataVO> page = sysDictDataService.page(params);
return new Result<PageData<SysDictDataVO>>().ok(page);
}
@GetMapping("/{id}")
@Operation(summary = "获取字典数据详情")
@RequiresPermissions("sys:role:superAdmin")
public Result<SysDictDataVO> get(@PathVariable("id") Long id) {
SysDictDataVO vo = sysDictDataService.get(id);
return new Result<SysDictDataVO>().ok(vo);
}
@PostMapping("/save")
@Operation(summary = "新增字典数据")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> save(@RequestBody SysDictDataDTO dto) {
ValidatorUtils.validateEntity(dto);
sysDictDataService.save(dto);
return new Result<>();
}
@PutMapping("/update")
@Operation(summary = "修改字典数据")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> update(@RequestBody SysDictDataDTO dto) {
ValidatorUtils.validateEntity(dto);
sysDictDataService.update(dto);
return new Result<>();
}
@PostMapping("/delete")
@Operation(summary = "删除字典数据")
@RequiresPermissions("sys:role:superAdmin")
@Parameter(name = "ids", description = "ID数组", required = true)
public Result<Void> delete(@RequestBody Long[] ids) {
sysDictDataService.delete(ids);
return new Result<>();
}
@GetMapping("/type/{dictType}")
@Operation(summary = "获取字典数据列表")
@RequiresPermissions("sys:role:normal")
public Result<List<SysDictDataItem>> getDictDataByType(@PathVariable("dictType") String dictType) {
List<SysDictDataItem> list = sysDictDataService.getDictDataByType(dictType);
return new Result<List<SysDictDataItem>>().ok(list);
}
}
@@ -0,0 +1,92 @@
package xiaozhi.modules.sys.controller;
import java.util.Map;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.sys.dto.SysDictTypeDTO;
import xiaozhi.modules.sys.service.SysDictTypeService;
import xiaozhi.modules.sys.vo.SysDictTypeVO;
/**
* 字典类型管理
*
* @author czc
* @since 2025-04-30
*/
@AllArgsConstructor
@RestController
@RequestMapping("/admin/dict/type")
@Tag(name = "字典类型管理")
public class SysDictTypeController {
private final SysDictTypeService sysDictTypeService;
@GetMapping("/page")
@Operation(summary = "分页查询字典类型")
@RequiresPermissions("sys:role:superAdmin")
@Parameters({ @Parameter(name = "dictType", description = "字典类型编码"),
@Parameter(name = "dictName", description = "字典类型名称"),
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true) })
public Result<PageData<SysDictTypeVO>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
ValidatorUtils.validateEntity(params);
PageData<SysDictTypeVO> page = sysDictTypeService.page(params);
return new Result<PageData<SysDictTypeVO>>().ok(page);
}
@GetMapping("/{id}")
@Operation(summary = "获取字典类型详情")
@RequiresPermissions("sys:role:superAdmin")
public Result<SysDictTypeVO> get(@PathVariable("id") Long id) {
SysDictTypeVO vo = sysDictTypeService.get(id);
return new Result<SysDictTypeVO>().ok(vo);
}
@PostMapping("/save")
@Operation(summary = "保存字典类型")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> save(@RequestBody SysDictTypeDTO dto) {
// 参数校验
ValidatorUtils.validateEntity(dto);
sysDictTypeService.save(dto);
return new Result<>();
}
@PutMapping("/update")
@Operation(summary = "修改字典类型")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> update(@RequestBody SysDictTypeDTO dto) {
// 参数校验
ValidatorUtils.validateEntity(dto);
sysDictTypeService.update(dto);
return new Result<>();
}
@PostMapping("/delete")
@Operation(summary = "删除字典类型")
@RequiresPermissions("sys:role:superAdmin")
@Parameter(name = "ids", description = "ID数组", required = true)
public Result<Void> delete(@RequestBody Long[] ids) {
sysDictTypeService.delete(ids);
return new Result<>();
}
}
@@ -5,9 +5,8 @@ import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import xiaozhi.common.dao.BaseDao;
import xiaozhi.modules.sys.dto.SysDictDataDTO;
import xiaozhi.modules.sys.entity.DictData;
import xiaozhi.modules.sys.entity.SysDictDataEntity;
import xiaozhi.modules.sys.vo.SysDictDataItem;
/**
* 字典数据
@@ -15,10 +14,13 @@ import xiaozhi.modules.sys.entity.SysDictDataEntity;
@Mapper
public interface SysDictDataDao extends BaseDao<SysDictDataEntity> {
/**
* 字典数据列表
*/
List<DictData> getDictDataList();
List<SysDictDataItem> getDictDataByType(String dictType);
List<SysDictDataDTO> getDataByTypeCode(String dictType);
/**
* 根据字典类型ID获取字典类型编码
*
* @param dictTypeId 字典类型ID
* @return 字典类型编码
*/
String getTypeByTypeId(Long dictTypeId);
}
@@ -1,11 +1,8 @@
package xiaozhi.modules.sys.dao;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import xiaozhi.common.dao.BaseDao;
import xiaozhi.modules.sys.entity.DictType;
import xiaozhi.modules.sys.entity.SysDictTypeEntity;
/**
@@ -14,9 +11,4 @@ import xiaozhi.modules.sys.entity.SysDictTypeEntity;
@Mapper
public interface SysDictTypeDao extends BaseDao<SysDictTypeEntity> {
/**
* 字典类型列表
*/
List<DictType> getDictTypeList();
}
@@ -1,13 +0,0 @@
package xiaozhi.modules.sys.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
@Schema(description = "配置密钥DTO")
public class ConfigSecretDTO {
@Schema(description = "密钥")
@NotBlank(message = "密钥不能为空")
private String secret;
}
@@ -51,5 +51,6 @@ public class SysDictTypeDTO implements Serializable {
@Schema(description = "更新时间")
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@JsonFormat(pattern = DateUtils.DATE_TIME_PATTERN)
private Date updateDate;
}
@@ -1,16 +0,0 @@
package xiaozhi.modules.sys.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
/**
* 字典数据
*/
@Data
public class DictData {
@JsonIgnore
private Long dictTypeId;
private String dictLabel;
private String dictValue;
}
@@ -1,19 +0,0 @@
package xiaozhi.modules.sys.entity;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
/**
* 字典类型
*/
@Data
public class DictType {
@JsonIgnore
private Long id;
private String dictType;
private List<DictData> dataList = new ArrayList<>();
}
@@ -18,7 +18,7 @@ import xiaozhi.common.entity.BaseEntity;
@TableName("sys_dict_type")
public class SysDictTypeEntity extends BaseEntity {
/**
* 字典类型
* 字典类型编码
*/
private String dictType;
/**
@@ -1,25 +1,70 @@
package xiaozhi.modules.sys.service;
import java.util.List;
import java.util.Map;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.sys.dto.SysDictDataDTO;
import xiaozhi.modules.sys.entity.SysDictDataEntity;
import xiaozhi.modules.sys.vo.SysDictDataItem;
import xiaozhi.modules.sys.vo.SysDictDataVO;
/**
* 数据字典
*/
public interface SysDictDataService extends BaseService<SysDictDataEntity> {
PageData<SysDictDataDTO> page(Map<String, Object> params);
/**
* 分页查询数据字典信息
*
* @param params 查询参数,包含分页信息和查询条件
* @return 返回数据字典的分页查询结果
*/
PageData<SysDictDataVO> page(Map<String, Object> params);
SysDictDataDTO get(Long id);
/**
* 根据ID获取数据字典实体
*
* @param id 数据字典实体的唯一标识
* @return 返回数据字典实体的详细信息
*/
SysDictDataVO get(Long id);
/**
* 保存新的数据字典项
*
* @param dto 数据字典项的保存数据传输对象
*/
void save(SysDictDataDTO dto);
/**
* 更新数据字典项
*
* @param dto 数据字典项的更新数据传输对象
*/
void update(SysDictDataDTO dto);
/**
* 删除数据字典项
*
* @param ids 要删除的数据字典项的ID数组
*/
void delete(Long[] ids);
/**
* 根据字典类型ID删除对应的字典数据
*
* @param dictTypeId 字典类型ID
*/
void deleteByTypeId(Long dictTypeId);
/**
* 根据字典类型获取字典数据列表
*
* @param dictType 字典类型
* @return 返回字典数据列表
*/
List<SysDictDataItem> getDictDataByType(String dictType);
}
@@ -6,25 +6,55 @@ import java.util.Map;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.sys.dto.SysDictTypeDTO;
import xiaozhi.modules.sys.entity.DictType;
import xiaozhi.modules.sys.entity.SysDictTypeEntity;
import xiaozhi.modules.sys.vo.SysDictTypeVO;
/**
* 数据字典
*/
public interface SysDictTypeService extends BaseService<SysDictTypeEntity> {
PageData<SysDictTypeDTO> page(Map<String, Object> params);
/**
* 分页查询字典类型信息
*
* @param params 查询参数,包含分页信息和查询条件
* @return 返回分页的字典类型数据
*/
PageData<SysDictTypeVO> page(Map<String, Object> params);
SysDictTypeDTO get(Long id);
/**
* 根据ID获取字典类型信息
*
* @param id 字典类型ID
* @return 返回字典类型对象
*/
SysDictTypeVO get(Long id);
/**
* 保存字典类型信息
*
* @param dto 字典类型数据传输对象
*/
void save(SysDictTypeDTO dto);
/**
* 更新字典类型信息
*
* @param dto 字典类型数据传输对象
*/
void update(SysDictTypeDTO dto);
/**
* 删除字典类型信息
*
* @param ids 要删除的字典类型ID数组
*/
void delete(Long[] ids);
List<DictType> getAllList();
List<DictType> getDictTypeList();
/**
* 列出所有字典类型信息
*
* @return 返回字典类型列表
*/
List<SysDictTypeVO> list(Map<String, Object> params);
}
@@ -1,37 +1,55 @@
package xiaozhi.modules.sys.service.impl;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.AllArgsConstructor;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.sys.dao.SysDictDataDao;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.dto.SysDictDataDTO;
import xiaozhi.modules.sys.entity.SysDictDataEntity;
import xiaozhi.modules.sys.entity.SysUserEntity;
import xiaozhi.modules.sys.service.SysDictDataService;
import xiaozhi.modules.sys.vo.SysDictDataItem;
import xiaozhi.modules.sys.vo.SysDictDataVO;
/**
* 字典类型
*/
@Service
@AllArgsConstructor
public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysDictDataEntity>
implements SysDictDataService {
private final SysUserDao sysUserDao;
private final RedisUtils redisUtils;
@Override
public PageData<SysDictDataDTO> page(Map<String, Object> params) {
IPage<SysDictDataEntity> page = baseDao.selectPage(
getPage(params, "sort", true),
getWrapper(params));
public PageData<SysDictDataVO> page(Map<String, Object> params) {
IPage<SysDictDataEntity> page = baseDao.selectPage(getPage(params, "sort", true), getWrapper(params));
return getPageData(page, SysDictDataDTO.class);
PageData<SysDictDataVO> pageData = getPageData(page, SysDictDataVO.class);
setUserName(pageData.getList());
return pageData;
}
private QueryWrapper<SysDictDataEntity> getWrapper(Map<String, Object> params) {
@@ -48,32 +66,118 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
}
@Override
public SysDictDataDTO get(Long id) {
public SysDictDataVO get(Long id) {
SysDictDataEntity entity = baseDao.selectById(id);
return ConvertUtils.sourceToTarget(entity, SysDictDataDTO.class);
return ConvertUtils.sourceToTarget(entity, SysDictDataVO.class);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void save(SysDictDataDTO dto) {
// 相同字典类型的标签不能相同
checkDictValueUnique(dto.getDictTypeId(), dto.getDictValue(), null);
SysDictDataEntity entity = ConvertUtils.sourceToTarget(dto, SysDictDataEntity.class);
insert(entity);
// 删除Redis缓存
String dictType = baseDao.getTypeByTypeId(dto.getDictTypeId());
redisUtils.delete(RedisKeys.getDictDataByTypeKey(dictType));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void update(SysDictDataDTO dto) {
// 相同字典类型的标签不能相同
checkDictValueUnique(dto.getDictTypeId(), dto.getDictValue(), String.valueOf(dto.getId()));
SysDictDataEntity entity = ConvertUtils.sourceToTarget(dto, SysDictDataEntity.class);
updateById(entity);
// 删除Redis缓存
String dictType = baseDao.getTypeByTypeId(dto.getDictTypeId());
redisUtils.delete(RedisKeys.getDictDataByTypeKey(dictType));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long[] ids) {
// 删除
deleteBatchIds(Arrays.asList(ids));
for (Long id : ids) {
SysDictDataEntity entity = baseDao.selectById(id);
// 删除Redis缓存
String dictType = baseDao.getTypeByTypeId(entity.getDictTypeId());
redisUtils.delete(RedisKeys.getDictDataByTypeKey(dictType));
// 删除
deleteById(id);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteByTypeId(Long dictTypeId) {
LambdaQueryWrapper<SysDictDataEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysDictDataEntity::getDictTypeId, dictTypeId);
baseDao.delete(wrapper);
}
/**
* 设置用户名
*
* @param sysDictDataList 字典类型集合
*/
private void setUserName(List<SysDictDataVO> sysDictDataList) {
// 收集所有用户 ID
Set<Long> userIds = sysDictDataList.stream().flatMap(vo -> Stream.of(vo.getCreator(), vo.getUpdater()))
.filter(Objects::nonNull).collect(Collectors.toSet());
// 设置更新者和创建者名称
if (!userIds.isEmpty()) {
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
// 把List转成MapMap<Long, String>
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
SysUserEntity::getUsername, (existing, replacement) -> existing));
sysDictDataList.forEach(vo -> {
vo.setCreatorName(userNameMap.get(vo.getCreator()));
vo.setUpdaterName(userNameMap.get(vo.getUpdater()));
});
}
}
private void checkDictValueUnique(Long dictTypeId, String dictValue, String excludeId) {
LambdaQueryWrapper<SysDictDataEntity> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SysDictDataEntity::getDictTypeId, dictTypeId).eq(SysDictDataEntity::getDictLabel, dictValue);
if (StringUtils.isNotBlank(excludeId)) {
queryWrapper.ne(SysDictDataEntity::getId, excludeId);
}
boolean exists = baseDao.exists(queryWrapper);
if (exists) {
throw new RenException("字典标签重复");
}
}
@Override
public List<SysDictDataItem> getDictDataByType(String dictType) {
if (StringUtils.isBlank(dictType)) {
return null;
}
// 先从Redis获取缓存
String key = RedisKeys.getDictDataByTypeKey(dictType);
List<SysDictDataItem> cachedData = (List<SysDictDataItem>) redisUtils.get(key);
if (cachedData != null) {
return cachedData;
}
// 如果缓存中没有,则从数据库获取
List<SysDictDataItem> data = baseDao.getDictDataByType(dictType);
// 存入Redis缓存
if (data != null) {
redisUtils.set(key, data);
}
return data;
}
}
@@ -3,42 +3,52 @@ package xiaozhi.modules.sys.service.impl;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.AllArgsConstructor;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.sys.dao.SysDictDataDao;
import xiaozhi.modules.sys.dao.SysDictTypeDao;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.dto.SysDictTypeDTO;
import xiaozhi.modules.sys.entity.DictData;
import xiaozhi.modules.sys.entity.DictType;
import xiaozhi.modules.sys.entity.SysDictTypeEntity;
import xiaozhi.modules.sys.entity.SysUserEntity;
import xiaozhi.modules.sys.service.SysDictDataService;
import xiaozhi.modules.sys.service.SysDictTypeService;
import xiaozhi.modules.sys.vo.SysDictTypeVO;
/**
* 字典类型
*/
@AllArgsConstructor
@Service
@AllArgsConstructor
public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysDictTypeEntity>
implements SysDictTypeService {
private final SysDictDataDao sysDictDataDao;
private final SysUserDao sysUserDao;
private final SysDictDataService sysDictDataService;
@Override
public PageData<SysDictTypeDTO> page(Map<String, Object> params) {
IPage<SysDictTypeEntity> page = baseDao.selectPage(
getPage(params, "sort", true),
getWrapper(params));
public PageData<SysDictTypeVO> page(Map<String, Object> params) {
IPage<SysDictTypeEntity> page = baseDao.selectPage(getPage(params, "sort", true), getWrapper(params));
return getPageData(page, SysDictTypeDTO.class);
PageData<SysDictTypeVO> pageData = getPageData(page, SysDictTypeVO.class);
setUserName(pageData.getList());
return pageData;
}
private QueryWrapper<SysDictTypeEntity> getWrapper(Map<String, Object> params) {
@@ -53,15 +63,21 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
}
@Override
public SysDictTypeDTO get(Long id) {
public SysDictTypeVO get(Long id) {
SysDictTypeEntity entity = baseDao.selectById(id);
if (entity == null) {
throw new RenException("字典类型不存在");
}
return ConvertUtils.sourceToTarget(entity, SysDictTypeDTO.class);
return ConvertUtils.sourceToTarget(entity, SysDictTypeVO.class);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void save(SysDictTypeDTO dto) {
// 字典类型编码不能重复
checkDictTypeUnique(dto.getDictType(), null);
SysDictTypeEntity entity = ConvertUtils.sourceToTarget(dto, SysDictTypeEntity.class);
insert(entity);
@@ -70,6 +86,9 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
@Override
@Transactional(rollbackFor = Exception.class)
public void update(SysDictTypeDTO dto) {
// 字典类型编码不能重复
checkDictTypeUnique(dto.getDictType(), String.valueOf(dto.getId()));
SysDictTypeEntity entity = ConvertUtils.sourceToTarget(dto, SysDictTypeEntity.class);
updateById(entity);
@@ -78,27 +97,57 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long[] ids) {
// 删除
// 删除对应的字典数据
for (Long id : ids) {
sysDictDataService.deleteByTypeId(id);
}
// 再删除字典类型
deleteBatchIds(Arrays.asList(ids));
}
@Override
public List<DictType> getAllList() {
List<DictType> typeList = baseDao.getDictTypeList();
List<DictData> dataList = sysDictDataDao.getDictDataList();
for (DictType type : typeList) {
for (DictData data : dataList) {
if (type.getId().equals(data.getDictTypeId())) {
type.getDataList().add(data);
}
}
public List<SysDictTypeVO> list(Map<String, Object> params) {
List<SysDictTypeEntity> entityList = baseDao.selectList(getWrapper(params));
List<SysDictTypeVO> sysDictTypeVOList = ConvertUtils.sourceToTarget(entityList, SysDictTypeVO.class);
// 设置用户名
setUserName(sysDictTypeVOList);
return sysDictTypeVOList;
}
/**
* 设置用户名
*
* @param sysDictTypeList 字典类型集合
*/
private void setUserName(List<SysDictTypeVO> sysDictTypeList) {
// 收集所有用户 ID
Set<Long> userIds = sysDictTypeList.stream().flatMap(vo -> Stream.of(vo.getCreator(), vo.getUpdater()))
.filter(Objects::nonNull).collect(Collectors.toSet());
// 设置更新者和创建者名称
if (!userIds.isEmpty()) {
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
// 把List转成MapMap<Long, String>
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
SysUserEntity::getUsername, (existing, replacement) -> existing));
sysDictTypeList.forEach(vo -> {
vo.setCreatorName(userNameMap.get(vo.getCreator()));
vo.setUpdaterName(userNameMap.get(vo.getUpdater()));
});
}
return typeList;
}
@Override
public List<DictType> getDictTypeList() {
return baseDao.getDictTypeList();
private void checkDictTypeUnique(String dictType, String excludeId) {
LambdaQueryWrapper<SysDictTypeEntity> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SysDictTypeEntity::getDictType, dictType);
if (StringUtils.isNotBlank(excludeId)) {
queryWrapper.ne(SysDictTypeEntity::getId, excludeId);
}
boolean exists = baseDao.exists(queryWrapper);
if (exists) {
throw new RenException("字典类型编码重复");
}
}
}
@@ -169,6 +169,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
String deviceCount = deviceService.selectCountByUserId(user.getId()).toString();
adminPageUserVO.setDeviceCount(deviceCount);
adminPageUserVO.setStatus(user.getStatus());
adminPageUserVO.setCreateDate(user.getCreateDate());
return adminPageUserVO;
}).toList();
return new PageData<>(list, page.getTotal());
@@ -20,7 +20,9 @@ public class SysUserUtilServiceImpl extends BaseServiceImpl<SysUserDao, SysUserE
@Override
public void assignUsername(Long userId, Consumer<String> setter) {
String userIdKey = RedisKeys.getUserIdKey(userId);
String username = redisUtils.get(userIdKey).toString();
Object value = redisUtils.get(userIdKey);
String username = (value != null) ? value.toString() : null;
if(username != null){
setter.accept(username);
}else {
@@ -8,6 +8,7 @@ import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.socket.WebSocketHttpHeaders;
import org.springframework.web.socket.client.WebSocketClient;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
@@ -45,8 +46,9 @@ public class WebSocketValidator {
try {
WebSocketClient client = new StandardWebSocketClient();
CompletableFuture<Boolean> future = new CompletableFuture<>();
WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
client.doHandshake(new WebSocketTestHandler(future), null, URI.create(url));
client.execute(new WebSocketTestHandler(future), headers, URI.create(url));
// 等待最多5秒获取连接结果
return future.get(5, TimeUnit.SECONDS);
@@ -1,5 +1,7 @@
package xiaozhi.modules.sys.vo;
import java.util.Date;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -23,4 +25,7 @@ public class AdminPageUserVO {
@Schema(description = "用户id")
private String userid;
@Schema(description = "注册时间")
private Date createDate;
}
@@ -0,0 +1,20 @@
package xiaozhi.modules.sys.vo;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 字典数据VO
*/
@Data
@Schema(description = "字典数据项")
public class SysDictDataItem implements Serializable {
@Schema(description = "字典标签")
private String name;
@Schema(description = "字典值")
private String key;
}
@@ -0,0 +1,50 @@
package xiaozhi.modules.sys.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 字典数据VO
*/
@Data
@Schema(description = "字典数据VO")
public class SysDictDataVO implements Serializable {
@Schema(description = "主键")
private Long id;
@Schema(description = "字典类型ID")
private Long dictTypeId;
@Schema(description = "字典标签")
private String dictLabel;
@Schema(description = "字典值")
private String dictValue;
@Schema(description = "备注")
private String remark;
@Schema(description = "排序")
private Integer sort;
@Schema(description = "创建者")
private Long creator;
@Schema(description = "创建者名称")
private String creatorName;
@Schema(description = "创建时间")
private Date createDate;
@Schema(description = "更新者")
private Long updater;
@Schema(description = "更新者名称")
private String updaterName;
@Schema(description = "更新时间")
private Date updateDate;
}
@@ -0,0 +1,47 @@
package xiaozhi.modules.sys.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 字典类型VO
*/
@Data
@Schema(description = "字典类型VO")
public class SysDictTypeVO implements Serializable {
@Schema(description = "主键")
private Long id;
@Schema(description = "字典类型")
private String dictType;
@Schema(description = "字典名称")
private String dictName;
@Schema(description = "备注")
private String remark;
@Schema(description = "排序")
private Integer sort;
@Schema(description = "创建者")
private Long creator;
@Schema(description = "创建者名称")
private String creatorName;
@Schema(description = "创建时间")
private Date createDate;
@Schema(description = "更新者")
private Long updater;
@Schema(description = "更新者名称")
private String updaterName;
@Schema(description = "更新时间")
private Date updateDate;
}
@@ -0,0 +1,504 @@
-- 增加FunASR服务语音识别模型供应器和配置
DELETE FROM `ai_model_provider` WHERE `id` = 'SYSTEM_ASR_FunASRServer';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_FunASRServer', 'ASR', 'fun_server', 'FunASR服务语音识别', '[{"key":"host","label":"服务地址","type":"string"},{"key":"port","label":"端口号","type":"number"}]', 4, 1, NOW(), 1, NOW());
DELETE FROM `ai_model_config` WHERE `id` = 'ASR_FunASRServer';
INSERT INTO `ai_model_config` VALUES ('ASR_FunASRServer', 'ASR', 'FunASRServer', 'FunASR服务语音识别', 0, 1, '{\"type\": \"fun_server\", \"host\": \"127.0.0.1\", \"port\": 10096}', NULL, NULL, 5, NULL, NULL, NULL, NULL);
-- 修改ai_model_config表的remark字段类型为TEXT
ALTER TABLE `ai_model_config` MODIFY COLUMN `remark` TEXT COMMENT '备注';
-- 更新ASR模型配置的说明文档
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md',
`remark` = '独立部署FunASR,使用FunASR的API服务,只需要五句话
第一句:mkdir -p ./funasr-runtime-resources/models
第二句:sudo docker run -d -p 10096:10095 --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12
上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime
不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 &
上一句话执行后会进入到容器,继续第五句:tail -f log.txt
第五句话执行完后,会看到模型下载日志,下载完后就可以连接使用了
以上是使用CPU推理,如果有GPU,详细参考:https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md' WHERE `id` = 'ASR_FunASRServer';
-- 更新FunASR本地模型配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/modelscope/FunASR',
`remark` = 'FunASR本地模型配置说明:
1. 需要下载模型文件到xiaozhi-server/models/SenseVoiceSmall目录
2. 支持中日韩粤语音识别
3. 本地推理,无需网络连接
4. 待识别文件保存在tmp/目录' WHERE `id` = 'ASR_FunASR';
-- 更新SherpaASR配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/k2-fsa/sherpa-onnx',
`remark` = 'SherpaASR配置说明:
1. 运行时自动下载模型文件到models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17目录
2. 支持中文、英文、日语、韩语、粤语等多种语言
3. 本地推理,无需网络连接
4. 输出文件保存在tmp/目录' WHERE `id` = 'ASR_SherpaASR';
-- 更新豆包ASR配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/speech/app',
`remark` = '豆包ASR配置说明:
1. 需要在火山引擎控制台创建应用并获取appid和access_token
2. 支持中文语音识别
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://console.volcengine.com/speech/app
2. 创建新应用
3. 获取appid和access_token
4. 填入配置文件中' WHERE `id` = 'ASR_DoubaoASR';
-- 更新腾讯ASR配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.cloud.tencent.com/cam/capi',
`remark` = '腾讯ASR配置说明:
1. 需要在腾讯云控制台创建应用并获取appid、secret_id和secret_key
2. 支持中文语音识别
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://console.cloud.tencent.com/cam/capi 获取密钥
2. 访问 https://console.cloud.tencent.com/asr/resourcebundle 领取免费资源
3. 获取appid、secret_id和secret_key
4. 填入配置文件中' WHERE `id` = 'ASR_TencentASR';
-- 更新TTS模型配置说明
-- EdgeTTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/rany2/edge-tts',
`remark` = 'EdgeTTS配置说明:
1. 使用微软Edge TTS服务
2. 支持多种语言和音色
3. 免费使用,无需注册
4. 需要网络连接
5. 输出文件保存在tmp/目录' WHERE `id` = 'TTS_EdgeTTS';
-- 豆包TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/speech/service/8',
`remark` = '豆包TTS配置说明:
1. 访问 https://console.volcengine.com/speech/service/8
2. 需要在火山引擎控制台创建应用并获取appid和access_token
3. 山引擎语音一定要购买花钱,起步价30元,就有100并发了。如果用免费的只有2个并发,会经常报tts错误
4. 购买服务后,购买免费的音色后,可能要等半小时左右,才能使用。
5. 填入配置文件中' WHERE `id` = 'TTS_DoubaoTTS';
-- 硅基流动TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://cloud.siliconflow.cn/account/ak',
`remark` = '硅基流动TTS配置说明:
1. 访问 https://cloud.siliconflow.cn/account/ak
2. 注册并获取API密钥
3. 填入配置文件中' WHERE `id` = 'TTS_CosyVoiceSiliconflow';
-- Coze中文TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://www.coze.cn/open/oauth/pats',
`remark` = 'Coze中文TTS配置说明:
1. 访问 https://www.coze.cn/open/oauth/pats
2. 获取个人令牌
3. 填入配置文件中' WHERE `id` = 'TTS_CozeCnTTS';
-- FishSpeech配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/fishaudio/fish-speech',
`remark` = 'FishSpeech配置说明:
1. 需要本地部署FishSpeech服务
2. 支持自定义音色
3. 本地推理,无需网络连接
4. 输出文件保存在tmp/目录
5. 运行服务示例命令:python -m tools.api_server --listen 0.0.0.0:8080 --llama-checkpoint-path "checkpoints/fish-speech-1.5" --decoder-checkpoint-path "checkpoints/fish-speech-1.5/firefly-gan-vq-fsq-8x1024-21hz-generator.pth" --decoder-config-name firefly_gan_vq --compile' WHERE `id` = 'TTS_FishSpeech';
-- GPT-SoVITS V2配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/RVC-Boss/GPT-SoVITS',
`remark` = 'GPT-SoVITS V2配置说明:
1. 需要本地部署GPT-SoVITS服务
2. 支持自定义音色克隆
3. 本地推理,无需网络连接
4. 输出文件保存在tmp/目录
部署步骤:
1. 运行服务示例命令:python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/demo.yaml' WHERE `id` = 'TTS_GPT_SOVITS_V2';
-- GPT-SoVITS V3配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/RVC-Boss/GPT-SoVITS',
`remark` = 'GPT-SoVITS V3配置说明:
1. 需要本地部署GPT-SoVITS V3服务
2. 支持自定义音色克隆
3. 本地推理,无需网络连接
4. 输出文件保存在tmp/目录' WHERE `id` = 'TTS_GPT_SOVITS_V3';
-- MiniMax TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://platform.minimaxi.com/',
`remark` = 'MiniMax TTS配置说明:
1. 需要在MiniMax平台创建账户并充值
2. 支持多种音色,当前配置使用female-shaonv
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://platform.minimaxi.com/ 注册账号
2. 访问 https://platform.minimaxi.com/user-center/payment/balance 充值
3. 访问 https://platform.minimaxi.com/user-center/basic-information 获取group_id
4. 访问 https://platform.minimaxi.com/user-center/basic-information/interface-key 获取api_key
5. 填入配置文件中' WHERE `id` = 'TTS_MinimaxTTS';
-- 阿里云TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://nls-portal.console.aliyun.com/',
`remark` = '阿里云TTS配置说明:
1. 需要在阿里云平台开通智能语音交互服务
2. 支持多种音色,当前配置使用xiaoyun
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://nls-portal.console.aliyun.com/ 开通服务
2. 访问 https://nls-portal.console.aliyun.com/applist 获取appkey
3. 访问 https://nls-portal.console.aliyun.com/overview 获取token
4. 填入配置文件中
注意:token是临时的24小时有效,长期使用需要配置access_key_id和access_key_secret' WHERE `id` = 'TTS_AliyunTTS';
-- 腾讯TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.cloud.tencent.com/cam/capi',
`remark` = '腾讯TTS配置说明:
1. 需要在腾讯云平台开通智能语音交互服务
2. 支持多种音色,当前配置使用101001
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://console.cloud.tencent.com/cam/capi 获取密钥
2. 访问 https://console.cloud.tencent.com/tts/resourcebundle 领取免费资源
3. 创建新应用
4. 获取appid、secret_id和secret_key
5. 填入配置文件中' WHERE `id` = 'TTS_TencentTTS';
-- 302AI TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://dash.302.ai/',
`remark` = '302AI TTS配置说明:
1. 需要在302平台创建账户并获取API密钥
2. 支持多种音色,当前配置使用湾湾小何音色
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://dash.302.ai/ 注册账号
2. 访问 https://dash.302.ai/apis/list 获取API密钥
3. 填入配置文件中
价格:$35/百万字符' WHERE `id` = 'TTS_TTS302AI';
-- 机智云TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://agentrouter.gizwitsapi.com/panel/token',
`remark` = '机智云TTS配置说明:
1. 需要在机智云平台获取API密钥
2. 支持多种音色,当前配置使用湾湾小何音色
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://agentrouter.gizwitsapi.com/panel/token 获取API密钥
2. 填入配置文件中
注意:前一万名注册的用户,将送5元体验金额' WHERE `id` = 'TTS_GizwitsTTS';
-- ACGN TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://acgn.ttson.cn/',
`remark` = 'ACGN TTS配置说明:
1. 需要在ttson平台购买token
2. 支持多种角色音色,当前配置使用角色ID:1695
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://acgn.ttson.cn/ 查看角色列表
2. 访问 www.ttson.cn 购买token
3. 填入配置文件中
开发相关疑问请提交至网站上的qq' WHERE `id` = 'TTS_ACGNTTS';
-- OpenAI TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://platform.openai.com/api-keys',
`remark` = 'OpenAI TTS配置说明:
1. 需要在OpenAI平台获取API密钥
2. 支持多种音色,当前配置使用onyx
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://platform.openai.com/api-keys 获取API密钥
2. 填入配置文件中
注意:国内需要使用代理访问' WHERE `id` = 'TTS_OpenAITTS';
-- 自定义TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = '自定义TTS配置说明:
1. 支持自定义TTS接口服务
2. 使用GET方式请求
3. 需要网络连接
4. 输出文件保存在tmp/目录
配置说明:
1. 在params中配置请求参数
2. 在headers中配置请求头
3. 设置返回音频格式' WHERE `id` = 'TTS_CustomTTS';
-- 火山引擎边缘大模型网关TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/vei/aigateway/',
`remark` = '火山引擎边缘大模型网关TTS配置说明:
1. 访问 https://console.volcengine.com/vei/aigateway/
2. 创建网关访问密钥,搜索并勾选 Doubao-语音合成
3. 如果需要使用LLM,一并勾选 Doubao-pro-32k-functioncall
4. 访问 https://console.volcengine.com/vei/aigateway/tokens-list 获取密钥
5. 填入配置文件中
音色列表参考:https://www.volcengine.com/docs/6561/1257544' WHERE `id` = 'TTS_VolcesAiGatewayTTS';
-- 更新LLM模型配置说明
-- ChatGLM配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://bigmodel.cn/usercenter/proj-mgmt/apikeys',
`remark` = 'ChatGLM配置说明:
1. 访问 https://bigmodel.cn/usercenter/proj-mgmt/apikeys
2. 注册并获取API密钥
3. 填入配置文件中' WHERE `id` = 'LLM_ChatGLMLLM';
-- Ollama配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://ollama.com/',
`remark` = 'Ollama配置说明:
1. 安装Ollama服务
2. 运行命令:ollama pull qwen2.5
3. 确保服务运行在http://localhost:11434' WHERE `id` = 'LLM_OllamaLLM';
-- 通义千问配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://bailian.console.aliyun.com/?apiKey=1#/api-key',
`remark` = '通义千问配置说明:
1. 访问 https://bailian.console.aliyun.com/?apiKey=1#/api-key
2. 获取API密钥
3. 填入配置文件中,当前配置使用qwen-turbo模型
4. 支持自定义参数:temperature=0.7, max_tokens=500, top_p=1, top_k=50' WHERE `id` = 'LLM_AliLLM';
-- 通义百炼配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://bailian.console.aliyun.com/?apiKey=1#/api-key',
`remark` = '通义百炼配置说明:
1. 访问 https://bailian.console.aliyun.com/?apiKey=1#/api-key
2. 获取app_id和api_key
3. 填入配置文件中' WHERE `id` = 'LLM_AliAppLLM';
-- 豆包大模型配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement',
`remark` = '豆包大模型配置说明:
1. 访问 https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement
2. 开通Doubao-1.5-pro服务
3. 访问 https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey 获取API密钥
4. 填入配置文件中
5. 当前建议使用doubao-1-5-pro-32k-250115
注意:有免费额度500000token' WHERE `id` = 'LLM_DoubaoLLM';
-- DeepSeek配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://platform.deepseek.com/',
`remark` = 'DeepSeek配置说明:
1. 访问 https://platform.deepseek.com/
2. 注册并获取API密钥
3. 填入配置文件中' WHERE `id` = 'LLM_DeepSeekLLM';
-- Dify配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://cloud.dify.ai/',
`remark` = 'Dify配置说明:
1. 访问 https://cloud.dify.ai/
2. 注册并获取API密钥
3. 填入配置文件中
4. 支持多种对话模式:workflows/run, chat-messages, completion-messages
5. 平台设置的角色定义会失效,需要在Dify控制台设置
注意:建议使用本地部署的Dify接口,国内部分区域访问公有云接口可能受限' WHERE `id` = 'LLM_DifyLLM';
-- Gemini配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://aistudio.google.com/apikey',
`remark` = 'Gemini配置说明:
1. 使用谷歌Gemini API服务
2. 当前配置使用gemini-2.0-flash模型
3. 需要网络连接
4. 支持配置代理
申请步骤:
1. 访问 https://aistudio.google.com/apikey
2. 创建API密钥
3. 填入配置文件中
注意:若在中国境内使用,请遵守《生成式人工智能服务管理暂行办法》' WHERE `id` = 'LLM_GeminiLLM';
-- Coze配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://www.coze.cn/open/oauth/pats',
`remark` = 'Coze配置说明:
1. 使用Coze平台服务
2. 需要bot_id、user_id和个人令牌
3. 需要网络连接
申请步骤:
1. 访问 https://www.coze.cn/open/oauth/pats
2. 获取个人令牌
3. 手动计算bot_id和user_id
4. 填入配置文件中' WHERE `id` = 'LLM_CozeLLM';
-- LM Studio配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://lmstudio.ai/',
`remark` = 'LM Studio配置说明:
1. 使用本地部署的LM Studio服务
2. 当前配置使用deepseek-r1-distill-llama-8b@q4_k_m模型
3. 本地推理,无需网络连接
4. 需要预先下载模型
部署步骤:
1. 安装LM Studio
2. 从社区下载模型
3. 确保服务运行在http://localhost:1234/v1' WHERE `id` = 'LLM_LMStudioLLM';
-- FastGPT配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://cloud.tryfastgpt.ai/account/apikey',
`remark` = 'FastGPT配置说明:
1. 使用FastGPT平台服务
2. 需要网络连接
3. 配置文件中的prompt无效,需要在FastGPT控制台设置
4. 支持自定义变量
申请步骤:
1. 访问 https://cloud.tryfastgpt.ai/account/apikey
2. 获取API密钥
3. 填入配置文件中' WHERE `id` = 'LLM_FastgptLLM';
-- Xinference配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/xorbitsai/inference',
`remark` = 'Xinference配置说明:
1. 使用本地部署的Xinference服务
2. 当前配置使用qwen2.5:72b-AWQ模型
3. 本地推理,无需网络连接
4. 需要预先启动对应模型
部署步骤:
1. 安装Xinference
2. 启动服务并加载模型
3. 确保服务运行在http://localhost:9997' WHERE `id` = 'LLM_XinferenceLLM';
-- Xinference小模型配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/xorbitsai/inference',
`remark` = 'Xinference小模型配置说明:
1. 使用本地部署的Xinference服务
2. 当前配置使用qwen2.5:3b-AWQ模型
3. 本地推理,无需网络连接
4. 用于意图识别
部署步骤:
1. 安装Xinference
2. 启动服务并加载模型
3. 确保服务运行在http://localhost:9997' WHERE `id` = 'LLM_XinferenceSmallLLM';
-- 火山引擎边缘大模型网关LLM配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/vei/aigateway/',
`remark` = '火山引擎边缘大模型网关LLM配置说明:
1. 使用火山引擎边缘大模型网关服务
2. 需要网关访问密钥
3. 需要网络连接
4. 支持function_call功能
申请步骤:
1. 访问 https://console.volcengine.com/vei/aigateway/
2. 创建网关访问密钥,搜索并勾选 Doubao-pro-32k-functioncall
3. 如果需要使用语音合成,一并勾选 Doubao-语音合成
4. 访问 https://console.volcengine.com/vei/aigateway/tokens-list 获取密钥
5. 填入配置文件中' WHERE `id` = 'LLM_VolcesAiGatewayLLM';
-- 更新Memory模型配置说明
-- 无记忆配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = '无记忆配置说明:
1. 不保存对话历史
2. 每次对话都是独立的
3. 无需额外配置
4. 适合对隐私要求高的场景' WHERE `id` = 'Memory_nomem';
-- 本地短期记忆配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = '本地短期记忆配置说明:
1. 使用本地存储保存对话历史
2. 通过selected_module的llm总结对话内容
3. 数据保存在本地,不会上传到服务器
4. 适合注重隐私的场景
5. 无需额外配置' WHERE `id` = 'Memory_mem_local_short';
-- Mem0AI记忆配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://app.mem0.ai/dashboard/api-keys',
`remark` = 'Mem0AI记忆配置说明:
1. 使用Mem0AI服务保存对话历史
2. 需要API密钥
3. 需要网络连接
4. 每月有1000次免费调用
申请步骤:
1. 访问 https://app.mem0.ai/dashboard/api-keys
2. 获取API密钥
3. 填入配置文件中' WHERE `id` = 'Memory_mem0ai';
-- 更新Intent模型配置说明
-- 无意图识别配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = '无意图识别配置说明:
1. 不进行意图识别
2. 所有对话直接传递给LLM处理
3. 无需额外配置
4. 适合简单对话场景' WHERE `id` = 'Intent_nointent';
-- LLM意图识别配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = 'LLM意图识别配置说明:
1. 使用独立的LLM进行意图识别
2. 默认使用selected_module.LLM的模型
3. 可以配置使用独立的LLM(如免费的ChatGLMLLM
4. 通用性强,但会增加处理时间
5. 不支持控制音量大小等iot操作
配置说明:
1. 在llm字段中指定使用的LLM模型
2. 如果不指定,则使用selected_module.LLM的模型' WHERE `id` = 'Intent_intent_llm';
-- 函数调用意图识别配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = '函数调用意图识别配置说明:
1. 使用LLM的function_call功能进行意图识别
2. 需要所选择的LLM支持function_call
3. 按需调用工具,处理速度快
4. 支持所有iot指令
5. 默认已加载以下功能:
- handle_exit_intent(退出识别)
- play_music(音乐播放)
- change_role(角色切换)
- get_weather(天气查询)
- get_news(新闻查询)
配置说明:
1. 在functions字段中配置需要加载的功能模块
2. 系统默认已加载基础功能,无需重复配置
3. 可以添加自定义功能模块' WHERE `id` = 'Intent_function_call';
-- 更新VAD模型配置说明
-- SileroVAD配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/snakers4/silero-vad',
`remark` = 'SileroVAD配置说明:
1. 使用SileroVAD模型进行语音活动检测
2. 本地推理,无需网络连接
3. 需要下载模型文件到models/snakers4_silero-vad目录
4. 可配置参数:
- threshold: 0.5(语音检测阈值)
- min_silence_duration_ms: 700(最小静音持续时间,单位毫秒)
5. 如果说话停顿比较长,可以适当增加min_silence_duration_ms的值' WHERE `id` = 'VAD_SileroVAD';
@@ -0,0 +1,85 @@
update `ai_model_provider` set `fields` =
'[{"key": "api_url","label": "API地址","type": "string"},{"key": "voice","label": "音色","type": "string"},{"key": "output_dir","label": "输出目录","type": "string"},{"key": "authorization","label": "授权","type": "string"},{"key": "appid","label": "应用ID","type": "string"},{"key": "access_token","label": "访问令牌","type": "string"},{"key": "cluster","label": "集群","type": "string"},{"key": "speed_ratio","label": "语速","type": "number"},{"key": "volume_ratio","label": "音量","type": "number"},{"key": "pitch_ratio","label": "音高","type": "number"}]'
where `id` = 'SYSTEM_TTS_doubao';
-- 添加阿里云ASR供应器
delete from `ai_model_provider` where `id` = 'SYSTEM_ASR_AliyunASR';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_AliyunASR', 'ASR', 'aliyun', '阿里云语音识别', '[{"key":"appkey","label":"应用AppKey","type":"string"},{"key":"token","label":"临时Token","type":"string"},{"key":"access_key_id","label":"AccessKey ID","type":"string"},{"key":"access_key_secret","label":"AccessKey Secret","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 5, 1, NOW(), 1, NOW());
-- 添加阿里云ASR模型配置
delete from `ai_model_config` where `id` = 'ASR_AliyunASR';
INSERT INTO `ai_model_config` VALUES ('ASR_AliyunASR', 'ASR', 'AliyunASR', '阿里云语音识别', 0, 1, '{\"type\": \"aliyun\", \"appkey\": \"\", \"token\": \"\", \"access_key_id\": \"\", \"access_key_secret\": \"\", \"output_dir\": \"tmp/\"}', NULL, NULL, 6, NULL, NULL, NULL, NULL);
-- 更新阿里云ASR模型配置的说明文档
UPDATE `ai_model_config` SET
`doc_link` = 'https://nls-portal.console.aliyun.com/',
`remark` = '阿里云ASR配置说明:
1. 访问 https://nls-portal.console.aliyun.com/ 开通服务
2. 访问 https://nls-portal.console.aliyun.com/applist 获取appkey
3. 访问 https://nls-portal.console.aliyun.com/overview 获取token
4. 获取access_key_id和access_key_secret
5. 填入配置文件中' WHERE `id` = 'ASR_AliyunASR';
-- 插入固件类型字典类型
delete from `sys_dict_type` where `id` = 101;
INSERT INTO `sys_dict_type` (`id`, `dict_type`, `dict_name`, `remark`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
(101, 'FIRMWARE_TYPE', '固件类型', '固件类型字典', 0, 1, NOW(), 1, NOW());
-- 插入固件类型字典数据
delete from `sys_dict_data` where `dict_type_id` = 101;
INSERT INTO `sys_dict_data` (`id`, `dict_type_id`, `dict_label`, `dict_value`, `remark`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
(101001, 101, '面包板新版接线(WiFi', 'bread-compact-wifi', '面包板新版接线(WiFi', 1, 1, NOW(), 1, NOW()),
(101002, 101, '面包板新版接线(WiFi+ LCD', 'bread-compact-wifi-lcd', '面包板新版接线(WiFi+ LCD', 2, 1, NOW(), 1, NOW()),
(101003, 101, '面包板新版接线(ML307 AT', 'bread-compact-ml307', '面包板新版接线(ML307 AT', 3, 1, NOW(), 1, NOW()),
(101004, 101, '面包板(WiFi ESP32 DevKit', 'bread-compact-esp32', '面包板(WiFi ESP32 DevKit', 4, 1, NOW(), 1, NOW()),
(101005, 101, '面包板(WiFi+ LCD ESP32 DevKit', 'bread-compact-esp32-lcd', '面包板(WiFi+ LCD ESP32 DevKit', 5, 1, NOW(), 1, NOW()),
(101006, 101, 'DFRobot 行空板 k10', 'df-k10', 'DFRobot 行空板 k10', 6, 1, NOW(), 1, NOW()),
(101007, 101, 'ESP32 CGC', 'esp32-cgc', 'ESP32 CGC', 7, 1, NOW(), 1, NOW()),
(101008, 101, 'ESP BOX 3', 'esp-box-3', 'ESP BOX 3', 8, 1, NOW(), 1, NOW()),
(101009, 101, 'ESP BOX', 'esp-box', 'ESP BOX', 9, 1, NOW(), 1, NOW()),
(101010, 101, 'ESP BOX Lite', 'esp-box-lite', 'ESP BOX Lite', 10, 1, NOW(), 1, NOW()),
(101011, 101, 'Kevin Box 1', 'kevin-box-1', 'Kevin Box 1', 11, 1, NOW(), 1, NOW()),
(101012, 101, 'Kevin Box 2', 'kevin-box-2', 'Kevin Box 2', 12, 1, NOW(), 1, NOW()),
(101013, 101, 'Kevin C3', 'kevin-c3', 'Kevin C3', 13, 1, NOW(), 1, NOW()),
(101014, 101, 'Kevin SP V3开发板', 'kevin-sp-v3-dev', 'Kevin SP V3开发板', 14, 1, NOW(), 1, NOW()),
(101015, 101, 'Kevin SP V4开发板', 'kevin-sp-v4-dev', 'Kevin SP V4开发板', 15, 1, NOW(), 1, NOW()),
(101016, 101, '鱼鹰科技3.13LCD开发板', 'kevin-yuying-313lcd', '鱼鹰科技3.13LCD开发板', 16, 1, NOW(), 1, NOW()),
(101017, 101, '立创·实战派ESP32-S3开发板', 'lichuang-dev', '立创·实战派ESP32-S3开发板', 17, 1, NOW(), 1, NOW()),
(101018, 101, '立创·实战派ESP32-C3开发板', 'lichuang-c3-dev', '立创·实战派ESP32-C3开发板', 18, 1, NOW(), 1, NOW()),
(101019, 101, '神奇按钮 Magiclick_2.4', 'magiclick-2p4', '神奇按钮 Magiclick_2.4', 19, 1, NOW(), 1, NOW()),
(101020, 101, '神奇按钮 Magiclick_2.5', 'magiclick-2p5', '神奇按钮 Magiclick_2.5', 20, 1, NOW(), 1, NOW()),
(101021, 101, '神奇按钮 Magiclick_C3', 'magiclick-c3', '神奇按钮 Magiclick_C3', 21, 1, NOW(), 1, NOW()),
(101022, 101, '神奇按钮 Magiclick_C3_v2', 'magiclick-c3-v2', '神奇按钮 Magiclick_C3_v2', 22, 1, NOW(), 1, NOW()),
(101023, 101, 'M5Stack CoreS3', 'm5stack-core-s3', 'M5Stack CoreS3', 23, 1, NOW(), 1, NOW()),
(101024, 101, 'AtomS3 + Echo Base', 'atoms3-echo-base', 'AtomS3 + Echo Base', 24, 1, NOW(), 1, NOW()),
(101025, 101, 'AtomS3R + Echo Base', 'atoms3r-echo-base', 'AtomS3R + Echo Base', 25, 1, NOW(), 1, NOW()),
(101026, 101, 'AtomS3R CAM/M12 + Echo Base', 'atoms3r-cam-m12-echo-base', 'AtomS3R CAM/M12 + Echo Base', 26, 1, NOW(), 1, NOW()),
(101027, 101, 'AtomMatrix + Echo Base', 'atommatrix-echo-base', 'AtomMatrix + Echo Base', 27, 1, NOW(), 1, NOW()),
(101028, 101, '虾哥 Mini C3', 'xmini-c3', '虾哥 Mini C3', 28, 1, NOW(), 1, NOW()),
(101029, 101, 'ESP32S3_KORVO2_V3开发板', 'esp32s3-korvo2-v3', 'ESP32S3_KORVO2_V3开发板', 29, 1, NOW(), 1, NOW()),
(101030, 101, 'ESP-SparkBot开发板', 'esp-sparkbot', 'ESP-SparkBot开发板', 30, 1, NOW(), 1, NOW()),
(101031, 101, 'ESP-Spot-S3', 'esp-spot-s3', 'ESP-Spot-S3', 31, 1, NOW(), 1, NOW()),
(101032, 101, 'Waveshare ESP32-S3-Touch-AMOLED-1.8', 'esp32-s3-touch-amoled-1.8', 'Waveshare ESP32-S3-Touch-AMOLED-1.8', 32, 1, NOW(), 1, NOW()),
(101033, 101, 'Waveshare ESP32-S3-Touch-LCD-1.85C', 'esp32-s3-touch-lcd-1.85c', 'Waveshare ESP32-S3-Touch-LCD-1.85C', 33, 1, NOW(), 1, NOW()),
(101034, 101, 'Waveshare ESP32-S3-Touch-LCD-1.85', 'esp32-s3-touch-lcd-1.85', 'Waveshare ESP32-S3-Touch-LCD-1.85', 34, 1, NOW(), 1, NOW()),
(101035, 101, 'Waveshare ESP32-S3-Touch-LCD-1.46', 'esp32-s3-touch-lcd-1.46', 'Waveshare ESP32-S3-Touch-LCD-1.46', 35, 1, NOW(), 1, NOW()),
(101036, 101, 'Waveshare ESP32-S3-Touch-LCD-3.5', 'esp32-s3-touch-lcd-3.5', 'Waveshare ESP32-S3-Touch-LCD-3.5', 36, 1, NOW(), 1, NOW()),
(101037, 101, '土豆子', 'tudouzi', '土豆子', 37, 1, NOW(), 1, NOW()),
(101038, 101, 'LILYGO T-Circle-S3', 'lilygo-t-circle-s3', 'LILYGO T-Circle-S3', 38, 1, NOW(), 1, NOW()),
(101039, 101, 'LILYGO T-CameraPlus-S3', 'lilygo-t-cameraplus-s3', 'LILYGO T-CameraPlus-S3', 39, 1, NOW(), 1, NOW()),
(101040, 101, 'Movecall Moji 小智AI衍生版', 'movecall-moji-esp32s3', 'Movecall Moji 小智AI衍生版', 40, 1, NOW(), 1, NOW()),
(101041, 101, 'Movecall CuiCan 璀璨·AI吊坠', 'movecall-cuican-esp32s3', 'Movecall CuiCan 璀璨·AI吊坠', 41, 1, NOW(), 1, NOW()),
(101042, 101, '正点原子DNESP32S3开发板', 'atk-dnesp32s3', '正点原子DNESP32S3开发板', 42, 1, NOW(), 1, NOW()),
(101043, 101, '正点原子DNESP32S3-BOX', 'atk-dnesp32s3-box', '正点原子DNESP32S3-BOX', 43, 1, NOW(), 1, NOW()),
(101044, 101, '嘟嘟开发板CHATX(wifi)', 'du-chatx', '嘟嘟开发板CHATX(wifi)', 44, 1, NOW(), 1, NOW()),
(101045, 101, '太极小派esp32s3', 'taiji-pi-s3', '太极小派esp32s3', 45, 1, NOW(), 1, NOW()),
(101046, 101, '无名科技星智0.85(WIFI)', 'xingzhi-cube-0.85tft-wifi', '无名科技星智0.85(WIFI)', 46, 1, NOW(), 1, NOW()),
(101047, 101, '无名科技星智0.85(ML307)', 'xingzhi-cube-0.85tft-ml307', '无名科技星智0.85(ML307)', 47, 1, NOW(), 1, NOW()),
(101048, 101, '无名科技星智0.96(WIFI)', 'xingzhi-cube-0.96oled-wifi', '无名科技星智0.96(WIFI)', 48, 1, NOW(), 1, NOW()),
(101049, 101, '无名科技星智0.96(ML307)', 'xingzhi-cube-0.96oled-ml307', '无名科技星智0.96(ML307)', 49, 1, NOW(), 1, NOW()),
(101050, 101, '无名科技星智1.54(WIFI)', 'xingzhi-cube-1.54tft-wifi', '无名科技星智1.54(WIFI)', 50, 1, NOW(), 1, NOW()),
(101051, 101, '无名科技星智1.54(ML307)', 'xingzhi-cube-1.54tft-ml307', '无名科技星智1.54(ML307)', 51, 1, NOW(), 1, NOW()),
(101052, 101, 'SenseCAP Watcher', 'sensecap-watcher', 'SenseCAP Watcher', 52, 1, NOW(), 1, NOW()),
(101053, 101, '四博智联AI陪伴盒子', 'doit-s3-aibox', '四博智联AI陪伴盒子', 53, 1, NOW(), 1, NOW()),
(101054, 101, '元控·青春', 'mixgo-nova', '元控·青春', 54, 1, NOW(), 1, NOW());
@@ -0,0 +1,27 @@
-- 初始化智能体聊天记录
DROP TABLE IF EXISTS ai_chat_history;
DROP TABLE IF EXISTS ai_chat_message;
DROP TABLE IF EXISTS ai_agent_chat_history;
CREATE TABLE ai_agent_chat_history
(
id BIGINT AUTO_INCREMENT COMMENT '主键ID' PRIMARY KEY,
mac_address VARCHAR(50) COMMENT 'MAC地址',
agent_id VARCHAR(32) COMMENT '智能体id',
session_id VARCHAR(50) COMMENT '会话ID',
chat_type TINYINT(3) COMMENT '消息类型: 1-用户, 2-智能体',
content VARCHAR(1024) COMMENT '聊天内容',
audio_id VARCHAR(32) COMMENT '音频ID',
created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL COMMENT '创建时间',
updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
INDEX idx_ai_agent_chat_history_mac (mac_address),
INDEX idx_ai_agent_chat_history_session_id (session_id),
INDEX idx_ai_agent_chat_history_agent_id (agent_id),
INDEX idx_ai_agent_chat_history_agent_session_created (agent_id, session_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '智能体聊天记录表';
DROP TABLE IF EXISTS ai_agent_chat_audio;
CREATE TABLE ai_agent_chat_audio
(
id VARCHAR(32) COMMENT '主键ID' PRIMARY KEY,
audio LONGBLOB COMMENT '音频opus数据'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '智能体聊天音频数据表';
@@ -78,4 +78,25 @@ databaseChangeLog:
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504251422.sql
path: classpath:db/changelog/202504251422.sql
- changeSet:
id: 202504291043
author: jiangkunyin
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504291043.sql
- changeSet:
id: 202504301341
author: Goody
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504301341.sql
- changeSet:
id: 202505022134
author: Goody
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505022134.sql
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="xiaozhi.modules.agent.dao.AiAgentChatHistoryDao">
<resultMap id="BaseResultMap" type="xiaozhi.modules.agent.entity.AgentChatHistoryEntity">
<!--@mbg.generated-->
<!--@Table ai_agent_chat_history-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="mac_address" jdbcType="VARCHAR" property="macAddress" />
<result column="agent_id" jdbcType="VARCHAR" property="agentId" />
<result column="session_id" jdbcType="VARCHAR" property="sessionId" />
<result column="sort" jdbcType="BIGINT" property="sort" />
<result column="chat_type" jdbcType="TINYINT" property="chatType" />
<result column="content" jdbcType="VARCHAR" property="content" />
<result column="audio" jdbcType="LONGVARCHAR" property="audio" />
<result column="audio_url" jdbcType="VARCHAR" property="audioUrl" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
<result column="updated_at" jdbcType="TIMESTAMP" property="updatedAt" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, mac_address, agent_id, session_id, sort, chat_type, content, audio, audio_url,
created_at, updated_at
</sql>
<delete id="deleteAudioByAgentId">
DELETE FROM ai_agent_chat_audio
WHERE id IN (
SELECT audio_id
FROM ai_agent_chat_history
WHERE agent_id = #{agentId}
);
</delete>
<delete id="deleteHistoryByAgentId">
DELETE FROM ai_agent_chat_history
WHERE agent_id = #{agentId};
</delete>
</mapper>
@@ -2,12 +2,17 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="xiaozhi.modules.sys.dao.SysDictDataDao">
<select id="getDictDataList" resultType="xiaozhi.modules.sys.entity.DictData">
select dict_type_id, dict_label, dict_value from sys_dict_data order by dict_type_id, sort
<select id="getDictDataByType" resultType="xiaozhi.modules.sys.vo.SysDictDataItem">
SELECT d.dict_label AS `name`, d.dict_value AS `key`
FROM sys_dict_data d
LEFT JOIN sys_dict_type t ON d.dict_type_id = t.id
WHERE t.dict_type = #{dictType}
ORDER BY d.sort ASC
</select>
<select id="getDataByTypeCode" resultType="xiaozhi.modules.sys.dto.SysDictDataDTO">
select * from sys_dict_data WHERE DICT_TYPE_ID =(SELECT id FROM SYS_DICT_TYPE WHERE DICT_TYPE = #{dictType}) order by sort;
<select id="getTypeByTypeId" resultType="java.lang.String">
SELECT dict_type
FROM sys_dict_type
WHERE id = #{dictTypeId}
</select>
</mapper>
@@ -3,8 +3,4 @@
<mapper namespace="xiaozhi.modules.sys.dao.SysDictTypeDao">
<select id="getDictTypeList" resultType="xiaozhi.modules.sys.entity.DictType">
select id, dict_type from sys_dict_type order by dict_type, sort
</select>
</mapper>
+4 -3
View File
@@ -2,11 +2,11 @@
import admin from './module/admin.js'
import agent from './module/agent.js'
import device from './module/device.js'
import dict from './module/dict.js'
import model from './module/model.js'
import ota from './module/ota.js'
import timbre from "./module/timbre.js"
import user from './module/user.js'
import ota from './module/ota.js'
/**
* 接口地址
* 开发时自动读取使用.env.development文件
@@ -32,5 +32,6 @@ export default {
device,
model,
timbre,
ota
ota,
dict
}
+46
View File
@@ -97,4 +97,50 @@ export default {
});
}).send();
},
// 获取智能体会话列表
getAgentSessions(agentId, params, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${agentId}/sessions`)
.method('GET')
.data(params)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail(() => {
RequestService.reAjaxFun(() => {
this.getAgentSessions(agentId, params, callback);
});
}).send();
},
// 获取智能体聊天记录
getAgentChatHistory(agentId, sessionId, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${agentId}/chat-history/${sessionId}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail(() => {
RequestService.reAjaxFun(() => {
this.getAgentChatHistory(agentId, sessionId, callback);
});
}).send();
},
// 获取音频下载ID
getAudioId(audioId, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/audio/${audioId}`)
.method('POST')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail(() => {
RequestService.reAjaxFun(() => {
this.getAudioId(audioId, callback);
});
}).send();
},
}
+227
View File
@@ -0,0 +1,227 @@
import { getServiceUrl } from '../api';
import RequestService from '../httpRequest';
export default {
// 获取字典类型列表
getDictTypeList(params, callback) {
const queryParams = new URLSearchParams({
dictType: params.dictType || '',
dictName: params.dictName || '',
page: params.page || 1,
limit: params.limit || 10
}).toString();
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/type/page?${queryParams}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('获取字典类型列表失败:', err)
this.$message.error(err.msg || '获取字典类型列表失败')
RequestService.reAjaxFun(() => {
this.getDictTypeList(params, callback)
})
}).send()
},
// 获取字典类型详情
getDictTypeDetail(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/type/${id}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('获取字典类型详情失败:', err)
this.$message.error(err.msg || '获取字典类型详情失败')
RequestService.reAjaxFun(() => {
this.getDictTypeDetail(id, callback)
})
}).send()
},
// 新增字典类型
addDictType(data, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/type/save`)
.method('POST')
.data(data)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('新增字典类型失败:', err)
this.$message.error(err.msg || '新增字典类型失败')
RequestService.reAjaxFun(() => {
this.addDictType(data, callback)
})
}).send()
},
// 更新字典类型
updateDictType(data, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/type/update`)
.method('PUT')
.data(data)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('更新字典类型失败:', err)
this.$message.error(err.msg || '更新字典类型失败')
RequestService.reAjaxFun(() => {
this.updateDictType(data, callback)
})
}).send()
},
// 删除字典类型
deleteDictType(ids, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/type/delete`)
.method('POST')
.data(ids)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('删除字典类型失败:', err)
this.$message.error(err.msg || '删除字典类型失败')
RequestService.reAjaxFun(() => {
this.deleteDictType(ids, callback)
})
}).send()
},
// 获取字典数据列表
getDictDataList(params, callback) {
const queryParams = new URLSearchParams({
dictTypeId: params.dictTypeId,
dictLabel: params.dictLabel || '',
dictValue: params.dictValue || '',
page: params.page || 1,
limit: params.limit || 10
}).toString();
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/page?${queryParams}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('获取字典数据列表失败:', err)
this.$message.error(err.msg || '获取字典数据列表失败')
RequestService.reAjaxFun(() => {
this.getDictDataList(params, callback)
})
}).send()
},
// 获取字典数据详情
getDictDataDetail(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/${id}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('获取字典数据详情失败:', err)
this.$message.error(err.msg || '获取字典数据详情失败')
RequestService.reAjaxFun(() => {
this.getDictDataDetail(id, callback)
})
}).send()
},
// 新增字典数据
addDictData(data, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/save`)
.method('POST')
.data(data)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('新增字典数据失败:', err)
this.$message.error(err.msg || '新增字典数据失败')
RequestService.reAjaxFun(() => {
this.addDictData(data, callback)
})
}).send()
},
// 更新字典数据
updateDictData(data, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/update`)
.method('PUT')
.data(data)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('更新字典数据失败:', err)
this.$message.error(err.msg || '更新字典数据失败')
RequestService.reAjaxFun(() => {
this.updateDictData(data, callback)
})
}).send()
},
// 删除字典数据
deleteDictData(ids, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/delete`)
.method('POST')
.data(ids)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('删除字典数据失败:', err)
this.$message.error(err.msg || '删除字典数据失败')
RequestService.reAjaxFun(() => {
this.deleteDictData(ids, callback)
})
}).send()
},
// 获取字典数据列表
getDictDataByType(dictType) {
return new Promise((resolve, reject) => {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/type/${dictType}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
if (res.data && res.data.code === 0) {
resolve(res.data)
} else {
reject(new Error(res.data?.msg || '获取字典数据列表失败'))
}
})
.fail((err) => {
console.error('获取字典数据列表失败:', err)
reject(err)
}).send()
})
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

@@ -0,0 +1,447 @@
<template>
<el-dialog :title="'与' + agentName + '的聊天记录' + (currentMacAddress ? '[' + currentMacAddress + ']' : '')"
:visible.sync="dialogVisible" width="80%" :before-close="handleClose" custom-class="chat-history-dialog">
<div class="chat-container">
<div class="session-list" @scroll="handleScroll">
<div v-for="session in sessions" :key="session.sessionId" class="session-item"
:class="{ active: currentSessionId === session.sessionId }" @click="selectSession(session)">
<img :src="getUserAvatar(session.sessionId)" class="avatar" />
<div class="session-info">
<div class="session-time">{{ formatTime(session.createdAt) }}</div>
<div class="message-count">{{ session.chatCount > 99 ? '99' : session.chatCount }}</div>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-if="!hasMore" class="no-more">没有更多记录了</div>
</div>
<div class="chat-content">
<div v-if="currentSessionId" class="messages">
<div v-for="(message, index) in messagesWithTime" :key="message.id">
<div v-if="message.type === 'time'" class="time-divider">
{{ message.content }}
</div>
<div v-else class="message-item" :class="{ 'user-message': message.chatType === 1 }">
<img :src="message.chatType === 1 ? getUserAvatar(currentSessionId) : require('@/assets/xiaozhi-logo.png')"
class="avatar" />
<div class="message-content">
{{ message.content }}
<i v-if="message.audioId" :class="getAudioIconClass(message)"
@click="playAudio(message)" class="audio-icon"></i>
</div>
</div>
</div>
</div>
<div v-else class="no-session-selected">
请选择会话查看聊天记录
</div>
</div>
</div>
</el-dialog>
</template>
<script>
import Api from '@/apis/api';
export default {
name: 'ChatHistoryDialog',
props: {
visible: {
type: Boolean,
default: false
},
agentId: {
type: String,
required: true
},
agentName: {
type: String,
required: true
}
},
data() {
return {
dialogVisible: false,
sessions: [],
messages: [],
currentSessionId: '',
currentMacAddress: '',
page: 1,
limit: 20,
loading: false,
hasMore: true,
scrollTimer: null,
isFirstLoad: true,
playingAudioId: null,
audioElement: null
};
},
watch: {
visible(val) {
this.dialogVisible = val;
if (val) {
this.resetData();
this.loadSessions();
}
},
dialogVisible(val) {
if (!val) {
this.$emit('update:visible', false);
}
}
},
computed: {
messagesWithTime() {
if (!this.messages || this.messages.length === 0) return [];
const result = [];
const TIME_INTERVAL = 60 * 1000; // 1分钟的时间间隔(毫秒)
// 添加第一条消息的时间标记
if (this.messages[0]) {
result.push({
type: 'time',
content: this.formatTime(this.messages[0].createdAt),
id: `time-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
});
}
// 处理消息列表
for (let i = 0; i < this.messages.length; i++) {
const currentMessage = this.messages[i];
result.push(currentMessage);
// 检查是否需要添加时间标记
if (i < this.messages.length - 1) {
const currentTime = new Date(currentMessage.createdAt).getTime();
const nextTime = new Date(this.messages[i + 1].createdAt).getTime();
if (nextTime - currentTime > TIME_INTERVAL) {
result.push({
type: 'time',
content: this.formatTime(this.messages[i + 1].createdAt),
id: `time-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
});
}
}
}
return result;
}
},
methods: {
resetData() {
this.sessions = [];
this.messages = [];
this.currentSessionId = '';
this.currentMacAddress = '';
this.page = 1;
this.loading = false;
this.hasMore = true;
this.isFirstLoad = true;
},
handleClose() {
this.dialogVisible = false;
},
loadSessions() {
if (this.loading || (!this.isFirstLoad && !this.hasMore)) {
return;
}
this.loading = true;
const params = {
page: this.page,
limit: this.limit
};
Api.agent.getAgentSessions(this.agentId, params, (res) => {
if (res.data && res.data.data && Array.isArray(res.data.data.list)) {
const list = res.data.data.list;
this.hasMore = list.length === this.limit;
this.sessions = [...this.sessions, ...list];
this.page++;
if (this.sessions.length > 0 && !this.currentSessionId) {
this.selectSession(this.sessions[0]);
}
}
this.loading = false;
this.isFirstLoad = false;
});
},
selectSession(session) {
this.currentSessionId = session.sessionId;
Api.agent.getAgentChatHistory(this.agentId, session.sessionId, (res) => {
if (res.data && res.data.data) {
this.messages = res.data.data;
if (this.messages.length > 0 && this.messages[0].macAddress) {
this.currentMacAddress = this.messages[0].macAddress;
}
}
});
},
handleScroll(e) {
if (this.scrollTimer) {
clearTimeout(this.scrollTimer);
}
this.scrollTimer = setTimeout(() => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
// 当滚动到底部时加载更多
if (scrollHeight - scrollTop <= clientHeight + 50) {
this.loadSessions();
}
}, 200);
},
formatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
if (date >= today) {
return `今天 ${hours}:${minutes}`;
} else if (date >= yesterday) {
return `昨天 ${hours}:${minutes}`;
} else {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
},
getAudioIconClass(message) {
if (this.playingAudioId === message.audioId) {
return 'el-icon-loading';
}
return 'el-icon-video-play';
},
playAudio(message) {
if (this.playingAudioId === message.audioId) {
// 如果正在播放当前音频,则停止播放
if (this.audioElement) {
this.audioElement.pause();
this.audioElement = null;
}
this.playingAudioId = null;
return;
}
// 停止当前正在播放的音频
if (this.audioElement) {
this.audioElement.pause();
this.audioElement = null;
}
// 先获取音频下载ID
this.playingAudioId = message.audioId;
Api.agent.getAudioId(message.audioId, (res) => {
if (res.data && res.data.data) {
// 使用获取到的下载ID播放音频
this.audioElement = new Audio(Api.getServiceUrl() + `/agent/play/${res.data.data}`);
this.audioElement.onended = () => {
this.playingAudioId = null;
this.audioElement = null;
};
this.audioElement.play();
}
});
},
getUserAvatar(sessionId) {
// 从 sessionId 中提取所有数字
const numbers = sessionId.match(/\d+/g);
if (!numbers) return require('@/assets/user-avatar1.png');
// 将所有数字相加
const sum = numbers.reduce((acc, num) => acc + parseInt(num), 0);
// 计算模5并加1,得到1-5之间的数字
const avatarIndex = (sum % 5) + 1;
// 返回对应的头像图片
return require(`@/assets/user-avatar${avatarIndex}.png`);
}
}
};
</script>
<style scoped>
.chat-container {
display: flex;
height: 100%;
}
.session-list {
width: 250px;
border-right: 1px solid #eee;
overflow-y: auto;
padding: 10px;
}
.session-item {
display: flex;
align-items: center;
padding: 10px;
cursor: pointer;
border-radius: 8px;
margin-bottom: 10px;
}
.session-item:hover {
background-color: #f5f5f5;
}
.session-item.active {
background-color: #e6f7ff;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
}
.session-info {
flex: 1;
}
.session-time {
font-size: 14px;
color: #272727;
float: left;
height: 30px;
line-height: 30px;
width: calc(100% - 30px);
/* 为消息数量留出空间 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-count {
font-size: 14px;
color: #fff;
background-color: #b4b4b4;
border-radius: 20px;
float: left;
width: 20px;
height: 20px;
line-height: 20px;
margin-top: 5px;
margin-left: 5px;
}
.chat-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.message-item {
display: flex;
margin-bottom: 20px;
}
.message-item.user-message {
flex-direction: row-reverse;
}
.message-content {
max-width: 60%;
padding: 10px 15px;
border-radius: 8px;
background-color: #f0f0f0;
margin: 0 10px;
text-align: left;
line-height: 20px;
position: relative;
display: flex;
align-items: center;
}
.audio-icon {
font-size: 20px;
cursor: pointer;
margin: 0 5px;
color: #1890ff;
}
.user-message .message-content {
background-color: #1890ff;
color: white;
flex-direction: row-reverse;
}
.user-message .audio-icon {
color: white;
}
.loading,
.no-more {
text-align: center;
padding: 10px 10px 30px 10px;
color: #999;
}
.no-session-selected {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: #999;
}
.time-divider {
text-align: center;
margin: 10px 0;
color: #999;
font-size: 12px;
}
.time-divider::before,
.time-divider::after {
content: '';
display: inline-block;
width: 30%;
height: 1px;
background-color: #eee;
vertical-align: middle;
margin: 0 10px;
}
</style>
<style>
.chat-history-dialog {
display: flex;
flex-direction: column;
min-width: 700px;
margin: 0 !important;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 90vh;
max-width: 85vw;
border-radius: 12px;
overflow: hidden;
}
.chat-history-dialog .el-dialog__header {
background-color: #e6f7ff;
padding: 15px 20px;
}
.chat-history-dialog .el-dialog__body {
padding: 0;
overflow: hidden;
height: calc(90vh - 54px);
/* 减去标题栏的高度 */
}
</style>
@@ -26,6 +26,9 @@
<div class="settings-btn" @click="handleDeviceManage">
设备管理({{ device.deviceCount }})
</div>
<div class="settings-btn" @click="handleChatHistory">
聊天记录
</div>
</div>
<div class="version-info">
<div>最近对话{{ device.lastConnectedAt }}</div>
@@ -51,6 +54,9 @@ export default {
},
handleDeviceManage() {
this.$router.push({ path: '/device-management', query: { agentId: this.device.agentId } });
},
handleChatHistory() {
this.$emit('chat-history', { agentId: this.device.agentId, agentName: this.device.agentName })
}
}
}
@@ -0,0 +1,105 @@
<template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose">
<el-form :model="form" :rules="rules" ref="form" label-width="100px">
<el-form-item label="字典标签" prop="dictLabel">
<el-input v-model="form.dictLabel" placeholder="请输入字典标签"></el-input>
</el-form-item>
<el-form-item label="字典值" prop="dictValue">
<el-input v-model="form.dictValue" placeholder="请输入字典值"></el-input>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="999" style="width: 100%;"></el-input-number>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="handleClose"> </el-button>
<el-button type="primary" @click="handleSave"> </el-button>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'DictDataDialog',
props: {
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: '新增字典数据'
},
dictData: {
type: Object,
default: () => ({})
},
dictTypeId: {
type: [Number, String],
default: null
}
},
data() {
return {
form: {
id: null,
dictTypeId: null,
dictLabel: '',
dictValue: '',
sort: 0
},
rules: {
dictLabel: [{ required: true, message: '请输入字典标签', trigger: 'blur' }],
dictValue: [{ required: true, message: '请输入字典值', trigger: 'blur' }]
}
}
},
watch: {
dictData: {
handler(val) {
if (val) {
this.form = { ...val }
}
},
immediate: true
},
dictTypeId: {
handler(val) {
if (val) {
this.form.dictTypeId = val
}
},
immediate: true
}
},
methods: {
handleClose() {
this.$emit('update:visible', false)
this.resetForm()
},
resetForm() {
this.form = {
id: null,
dictTypeId: this.dictTypeId,
dictLabel: '',
dictValue: '',
sort: 0
}
this.$refs.form?.resetFields()
},
handleSave() {
this.$refs.form.validate(valid => {
if (valid) {
this.$emit('save', this.form)
}
})
}
}
}
</script>
<style scoped>
.dialog-footer {
text-align: right;
}
</style>
@@ -0,0 +1,86 @@
<template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose">
<el-form :model="form" :rules="rules" ref="form" label-width="120px">
<el-form-item label="字典类型名称" prop="dictName">
<el-input v-model="form.dictName" placeholder="请输入字典类型名称"></el-input>
</el-form-item>
<el-form-item label="字典类型编码" prop="dictType">
<el-input v-model="form.dictType" placeholder="请输入字典类型编码"></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="handleClose"> </el-button>
<el-button type="primary" @click="handleSave"> </el-button>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'DictTypeDialog',
props: {
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: '新增字典类型'
},
dictTypeData: {
type: Object,
default: () => ({})
}
},
data() {
return {
form: {
id: null,
dictName: '',
dictType: ''
},
rules: {
dictName: [{ required: true, message: '请输入字典类型名称', trigger: 'blur' }],
dictType: [{ required: true, message: '请输入字典类型编码', trigger: 'blur' }]
}
}
},
watch: {
dictTypeData: {
handler(val) {
if (val) {
this.form = { ...val }
}
},
immediate: true
}
},
methods: {
handleClose() {
this.$emit('update:visible', false)
this.resetForm()
},
resetForm() {
this.form = {
id: null,
dictName: '',
dictType: ''
}
this.$refs.form?.resetFields()
},
handleSave() {
this.$refs.form.validate(valid => {
if (valid) {
this.$emit('save', this.form)
}
})
}
}
}
</script>
<style scoped>
.dialog-footer {
text-align: right;
}
</style>
@@ -38,7 +38,6 @@
<script>
import Api from '@/apis/api';
import { FIRMWARE_TYPES } from '@/utils';
export default {
name: 'FirmwareDialog',
@@ -54,11 +53,14 @@ export default {
form: {
type: Object,
default: () => ({})
},
firmwareTypes: {
type: Array,
default: () => []
}
},
data() {
return {
firmwareTypes: FIRMWARE_TYPES,
uploadProgress: 0,
uploadStatus: '',
isUploading: false,
@@ -85,7 +87,11 @@ export default {
return !!this.form.id
}
},
created() {
// 移除 getDictDataByType 调用
},
methods: {
// 移除 getFirmwareTypes 方法
handleClose() {
this.$refs.form.clearValidate();
this.$emit('cancel');
+52 -8
View File
@@ -9,9 +9,11 @@
<!-- 中间导航菜单 -->
<div class="header-center">
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/home' }" @click="goHome">
<div class="equipment-management"
:class="{ 'active-tab': $route.path === '/home' || $route.path === '/role-config' || $route.path === '/device-management' }"
@click="goHome">
<img loading="lazy" alt="" src="@/assets/header/robot.png"
:style="{ filter: $route.path === '/home' ? 'brightness(0) invert(1)' : 'None' }" />
:style="{ filter: $route.path === '/home' || $route.path === '/role-config' || $route.path === '/device-management' ? 'brightness(0) invert(1)' : 'None' }" />
智能体管理
</div>
<div v-if="isSuperAdmin" class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }"
@@ -26,18 +28,29 @@
:style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }" />
用户管理
</div>
<div v-if="isSuperAdmin" class="equipment-management"
:class="{ 'active-tab': $route.path === '/params-management' }" @click="goParamManagement">
<img loading="lazy" alt="" src="@/assets/header/param_management.png"
:style="{ filter: $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" />
参数管理
</div>
<div v-if="isSuperAdmin" class="equipment-management"
:class="{ 'active-tab': $route.path === '/ota-management' }" @click="goOtaManagement">
<img loading="lazy" alt="" src="@/assets/header/firmware_update.png"
:style="{ filter: $route.path === '/ota-management' ? 'brightness(0) invert(1)' : 'None' }" />
OTA管理
</div>
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown"
:class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' }">
<span class="el-dropdown-link">
<img loading="lazy" alt="" src="@/assets/header/param_management.png"
:style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" />
参数字典
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="goParamManagement">
参数管理
</el-dropdown-item>
<el-dropdown-item @click.native="goDictManagement">
字典管理
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
<!-- 右侧元素 -->
@@ -114,6 +127,9 @@ export default {
goOtaManagement() {
this.$router.push('/ota-management')
},
goDictManagement() {
this.$router.push('/dict-management')
},
// 获取用户信息
fetchUserInfo() {
userApi.getUserInfo(({ data }) => {
@@ -343,4 +359,32 @@ export default {
min-width: 100px;
}
}
.equipment-management.more-dropdown {
position: relative;
}
.equipment-management.more-dropdown .el-dropdown-menu {
position: absolute;
right: 0;
min-width: 120px;
margin-top: 5px;
}
.el-dropdown-menu__item {
min-width: 60px;
padding: 8px 20px;
font-size: 14px;
color: #606266;
white-space: nowrap;
}
@media (max-width: 768px) {
.equipment-management.more-dropdown .el-dropdown-menu {
position: fixed;
right: 10px;
top: 60px;
z-index: 2000;
}
}
</style>
+7 -1
View File
@@ -84,8 +84,14 @@ const routes = [
title: 'OTA管理'
}
},
{
path: '/dict-management',
name: 'DictManagement',
component: function () {
return import('../views/DictManagement.vue')
}
}
]
const router = new VueRouter({
base: process.env.VUE_APP_PUBLIC_PATH || '/',
routes
-60
View File
@@ -136,63 +136,3 @@ export function getUUID() {
})
}
/**
* 固件类型选项
*/
export const FIRMWARE_TYPES = [
{ "key": "bread-compact-wifi", "name": "面包板新版接线(WiFi" },
{ "key": "bread-compact-wifi-lcd", "name": "面包板新版接线(WiFi+ LCD" },
{ "key": "bread-compact-ml307", "name": "面包板新版接线(ML307 AT" },
{ "key": "bread-compact-esp32", "name": "面包板(WiFi ESP32 DevKit" },
{ "key": "bread-compact-esp32-lcd", "name": "面包板(WiFi+ LCD ESP32 DevKit" },
{ "key": "df-k10", "name": "DFRobot 行空板 k10" },
{ "key": "esp32-cgc", "name": "ESP32 CGC" },
{ "key": "esp-box-3", "name": "ESP BOX 3" },
{ "key": "esp-box", "name": "ESP BOX" },
{ "key": "esp-box-lite", "name": "ESP BOX Lite" },
{ "key": "kevin-box-1", "name": "Kevin Box 1" },
{ "key": "kevin-box-2", "name": "Kevin Box 2" },
{ "key": "kevin-c3", "name": "Kevin C3" },
{ "key": "kevin-sp-v3-dev", "name": "Kevin SP V3开发板" },
{ "key": "kevin-sp-v4-dev", "name": "Kevin SP V4开发板" },
{ "key": "kevin-yuying-313lcd", "name": "鱼鹰科技3.13LCD开发板" },
{ "key": "lichuang-dev", "name": "立创·实战派ESP32-S3开发板" },
{ "key": "lichuang-c3-dev", "name": "立创·实战派ESP32-C3开发板" },
{ "key": "magiclick-2p4", "name": "神奇按钮 Magiclick_2.4" },
{ "key": "magiclick-2p5", "name": "神奇按钮 Magiclick_2.5" },
{ "key": "magiclick-c3", "name": "神奇按钮 Magiclick_C3" },
{ "key": "magiclick-c3-v2", "name": "神奇按钮 Magiclick_C3_v2" },
{ "key": "m5stack-core-s3", "name": "M5Stack CoreS3" },
{ "key": "atoms3-echo-base", "name": "AtomS3 + Echo Base" },
{ "key": "atoms3r-echo-base", "name": "AtomS3R + Echo Base" },
{ "key": "atoms3r-cam-m12-echo-base", "name": "AtomS3R CAM/M12 + Echo Base" },
{ "key": "atommatrix-echo-base", "name": "AtomMatrix + Echo Base" },
{ "key": "xmini-c3", "name": "虾哥 Mini C3" },
{ "key": "esp32s3-korvo2-v3", "name": "ESP32S3_KORVO2_V3开发板" },
{ "key": "esp-sparkbot", "name": "ESP-SparkBot开发板" },
{ "key": "esp-spot-s3", "name": "ESP-Spot-S3" },
{ "key": "esp32-s3-touch-amoled-1.8", "name": "Waveshare ESP32-S3-Touch-AMOLED-1.8" },
{ "key": "esp32-s3-touch-lcd-1.85c", "name": "Waveshare ESP32-S3-Touch-LCD-1.85C" },
{ "key": "esp32-s3-touch-lcd-1.85", "name": "Waveshare ESP32-S3-Touch-LCD-1.85" },
{ "key": "esp32-s3-touch-lcd-1.46", "name": "Waveshare ESP32-S3-Touch-LCD-1.46" },
{ "key": "esp32-s3-touch-lcd-3.5", "name": "Waveshare ESP32-S3-Touch-LCD-3.5" },
{ "key": "tudouzi", "name": "土豆子" },
{ "key": "lilygo-t-circle-s3", "name": "LILYGO T-Circle-S3" },
{ "key": "lilygo-t-cameraplus-s3", "name": "LILYGO T-CameraPlus-S3" },
{ "key": "movecall-moji-esp32s3", "name": "Movecall Moji 小智AI衍生版" },
{ "key": "movecall-cuican-esp32s3", "name": "Movecall CuiCan 璀璨·AI吊坠" },
{ "key": "atk-dnesp32s3", "name": "正点原子DNESP32S3开发板" },
{ "key": "atk-dnesp32s3-box", "name": "正点原子DNESP32S3-BOX" },
{ "key": "du-chatx", "name": "嘟嘟开发板CHATX(wifi)" },
{ "key": "taiji-pi-s3", "name": "太极小派esp32s3" },
{ "key": "xingzhi-cube-0.85tft-wifi", "name": "无名科技星智0.85(WIFI)" },
{ "key": "xingzhi-cube-0.85tft-ml307", "name": "无名科技星智0.85(ML307)" },
{ "key": "xingzhi-cube-0.96oled-wifi", "name": "无名科技星智0.96(WIFI)" },
{ "key": "xingzhi-cube-0.96oled-ml307", "name": "无名科技星智0.96(ML307)" },
{ "key": "xingzhi-cube-1.54tft-wifi", "name": "无名科技星智1.54(WIFI)" },
{ "key": "xingzhi-cube-1.54tft-ml307", "name": "无名科技星智1.54(ML307)" },
{ "key": "sensecap-watcher", "name": "SenseCAP Watcher" },
{ "key": "doit-s3-aibox", "name": "四博智联AI陪伴盒子" },
{ "key": "mixgo-nova", "name": "元控·青春" }
]
+17 -12
View File
@@ -100,7 +100,6 @@
import Api from '@/apis/api';
import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import { FIRMWARE_TYPES } from "@/utils";
export default {
components: { HeaderBar, AddDeviceDialog },
@@ -118,6 +117,7 @@ export default {
deviceList: [],
loading: false,
userApi: null,
firmwareTypes: [],
};
},
computed: {
@@ -163,7 +163,19 @@ export default {
this.fetchBindDevices(agentId);
}
},
created() {
this.getFirmwareTypes()
},
methods: {
async getFirmwareTypes() {
try {
const res = await Api.dict.getDictDataByType('FIRMWARE_TYPE')
this.firmwareTypes = res.data
} catch (error) {
console.error('获取固件类型失败:', error)
this.$message.error(error.message || '获取固件类型失败')
}
},
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
@@ -282,20 +294,13 @@ export default {
this.loading = false;
if (data.code === 0) {
this.deviceList = data.data.map(device => {
const bindDate = new Date(device.createDate);
const formattedBindTime = `${bindDate.getFullYear()}-${(bindDate.getMonth() + 1).toString().padStart(2, '0')}-${bindDate.getDate().toString().padStart(2, '0')} ${bindDate.getHours().toString().padStart(2, '0')}:${bindDate.getMinutes().toString().padStart(2, '0')}:${bindDate.getSeconds().toString().padStart(2, '0')}`;
let formattedLastConversation = '';
if (device.lastConnectedAt) {
const lastConvoDate = new Date(device.lastConnectedAt);
formattedLastConversation = `${lastConvoDate.getFullYear()}-${(lastConvoDate.getMonth() + 1).toString().padStart(2, '0')}-${lastConvoDate.getDate().toString().padStart(2, '0')} ${lastConvoDate.getHours().toString().padStart(2, '0')}:${lastConvoDate.getMinutes().toString().padStart(2, '0')}:${lastConvoDate.getSeconds().toString().padStart(2, '0')}`;
}
return {
device_id: device.id,
model: device.board,
firmwareVersion: device.appVersion,
macAddress: device.macAddress,
bindTime: formattedBindTime,
lastConversation: formattedLastConversation,
bindTime: device.createDate,
lastConversation: device.lastConnectedAt,
remark: device.alias,
isEdit: false,
otaSwitch: device.autoUpdate === 1,
@@ -317,8 +322,8 @@ export default {
return "";
},
getFirmwareTypeName(type) {
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
return firmwareType ? firmwareType.name : type;
const firmwareType = this.firmwareTypes.find(item => item.key === type)
return firmwareType ? firmwareType.name : type
},
handleOtaSwitchChange(row) {
Api.device.enableOtaUpgrade(row.device_id, row.otaSwitch ? 1 : 0, ({ data }) => {
@@ -0,0 +1,835 @@
<template>
<div class="welcome">
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">字典管理</h2>
<div class="action-group">
<div class="search-group">
<el-input placeholder="请输入字典值标签查询" v-model="search" class="search-input" clearable
@keyup.enter.native="handleSearch" style="width: 240px" />
<el-button class="btn-search" @click="handleSearch">
搜索
</el-button>
</div>
</div>
</div>
<!-- 主体内容 -->
<div class="main-wrapper">
<div class="content-panel">
<!-- 左侧字典类型列表 -->
<div class="dict-type-panel">
<div class="dict-type-header">
<el-button type="success" size="mini" @click="showAddDictTypeDialog">新增字典类型</el-button>
<el-button type="danger" size="mini" @click="batchDeleteDictType"
:disabled="selectedDictTypes.length === 0">
批量删除字典类型
</el-button>
</div>
<el-table ref="dictTypeTable" :data="dictTypeList" style="width: 100%" v-loading="dictTypeLoading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)" @row-click="handleDictTypeRowClick"
@selection-change="handleDictTypeSelectionChange" :row-class-name="tableRowClassName"
class="dict-type-table">
<el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="字典类型名称" prop="dictName" align="center"></el-table-column>
<el-table-column label="操作" width="100" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click.stop="editDictType(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 右侧字典数据列表 -->
<div class="content-area">
<el-card class="dict-data-card" shadow="never">
<el-table ref="dictDataTable" :data="dictDataList" style="width: 100%"
v-loading="dictDataLoading" element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
@selection-change="handleDictDataSelectionChange" class="data-table"
header-row-class-name="table-header">
<el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="字典标签" prop="dictLabel" align="center"></el-table-column>
<el-table-column label="字典值" prop="dictValue" align="center"></el-table-column>
<el-table-column label="排序" prop="sort" align="center"></el-table-column>
<el-table-column label="操作" align="center" width="180px">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="editDictData(scope.row)"
class="edit-btn">
修改
</el-button>
<el-button type="text" size="mini" @click="deleteDictData(scope.row)"
class="delete-btn">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<div class="batch-actions">
<el-button size="mini" type="primary" @click="selectAllDictData">
{{ isAllDictDataSelected ? '取消全选' : '全选' }}
</el-button>
<el-button type="success" size="mini" @click="showAddDictDataDialog" class="add-btn">
新增字典数据
</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDeleteDictData">
批量删除字典数据
</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
:value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
首页
</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
上一页
</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
下一页
</button>
<span class="total-text">{{ total }}条记录</span>
</div>
</div>
</el-card>
</div>
</div>
</div>
<!-- 使用字典类型编辑弹框组件 -->
<DictTypeDialog :visible.sync="dictTypeDialogVisible" :title="dictTypeDialogTitle" :dictTypeData="dictTypeForm"
@save="saveDictType" />
<!-- 使用字典数据编辑弹框组件 -->
<DictDataDialog :visible.sync="dictDataDialogVisible" :title="dictDataDialogTitle" :dictData="dictDataForm"
:dictTypeId="selectedDictType?.id" @save="saveDictData" />
<el-footer style="flex-shrink:unset;">
<version-footer />
</el-footer>
</div>
</template>
<script>
import dictApi from '@/apis/module/dict'
import DictDataDialog from '@/components/DictDataDialog.vue'
import DictTypeDialog from '@/components/DictTypeDialog.vue'
import HeaderBar from '@/components/HeaderBar.vue'
import VersionFooter from '@/components/VersionFooter.vue'
export default {
name: 'DictManagement',
components: {
HeaderBar,
DictTypeDialog,
DictDataDialog,
VersionFooter
},
data() {
return {
// 字典类型相关
dictTypeList: [],
dictTypeLoading: false,
selectedDictType: null,
selectedDictTypes: [], // 恢复多选数组
dictTypeDialogVisible: false,
dictTypeDialogTitle: '新增字典类型',
dictTypeForm: {
id: null,
dictName: '',
dictType: ''
},
// 字典数据相关
dictDataList: [],
dictDataLoading: false,
selectedDictData: [],
isAllDictDataSelected: false,
dictDataDialogVisible: false,
dictDataDialogTitle: '新增字典数据',
dictDataForm: {
id: null,
dictTypeId: null,
dictLabel: '',
dictValue: '',
sort: 0
},
search: '',
// 添加分页相关数据
pageSizeOptions: [10, 20, 50, 100],
currentPage: 1,
pageSize: 10,
total: 0
}
},
created() {
this.loadDictTypeList()
},
methods: {
// 字典类型相关方法
loadDictTypeList() {
this.dictTypeLoading = true
dictApi.getDictTypeList({
page: 1,
limit: 100,
dictName: this.search
}, ({ data }) => {
if (data.code === 0) {
this.dictTypeList = data.data.list
if (this.dictTypeList.length > 0) {
this.selectedDictType = this.dictTypeList[0]
this.loadDictDataList(this.dictTypeList[0].id)
this.$nextTick(() => {
this.$refs.dictTypeTable.setCurrentRow(this.dictTypeList[0])
})
}
}
this.dictTypeLoading = false
})
},
handleDictTypeRowClick(row) {
this.selectedDictType = row
this.loadDictDataList(row.id)
this.$refs.dictTypeTable.setCurrentRow(row)
},
handleDictTypeSelectionChange(val) {
this.selectedDictTypes = val
},
tableRowClassName({ row }) {
return row === this.selectedDictType ? 'current-row' : ''
},
showAddDictTypeDialog() {
this.dictTypeDialogTitle = '新增字典类型'
this.dictTypeForm = {
id: null,
dictName: '',
dictType: ''
}
this.dictTypeDialogVisible = true
},
editDictType(row) {
this.dictTypeDialogTitle = '编辑字典类型'
this.dictTypeForm = { ...row }
this.dictTypeDialogVisible = true
},
saveDictType(formData) {
const api = formData.id ? dictApi.updateDictType : dictApi.addDictType
api(formData, ({ data }) => {
if (data.code === 0) {
this.$message.success('保存成功')
this.dictTypeDialogVisible = false
this.loadDictTypeList()
}
})
},
batchDeleteDictType() {
if (this.selectedDictTypes.length === 0) {
this.$message.warning('请选择要删除的字典类型')
return
}
this.$confirm('确定要删除选中的字典类型吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const ids = this.selectedDictTypes.map(item => item.id)
dictApi.deleteDictType(ids, ({ data }) => {
if (data.code === 0) {
this.$message.success('删除成功')
this.loadDictTypeList()
}
})
})
},
// 字典数据相关方法
loadDictDataList(dictTypeId) {
if (!dictTypeId) return
this.dictDataLoading = true
dictApi.getDictDataList({
dictTypeId,
page: this.currentPage,
limit: this.pageSize,
dictLabel: this.search,
dictValue: ''
}, ({ data }) => {
if (data.code === 0) {
this.dictDataList = data.data.list
this.total = data.data.total
} else {
this.$message.error(data.msg || '获取字典数据失败')
}
this.dictDataLoading = false
})
},
handleDictDataSelectionChange(val) {
this.selectedDictData = val
this.isAllDictDataSelected = val.length === this.dictDataList.length
},
selectAllDictData() {
if (this.isAllDictDataSelected) {
this.$refs.dictDataTable.clearSelection()
} else {
this.$refs.dictDataTable.toggleAllSelection()
}
},
showAddDictDataDialog() {
if (!this.selectedDictType) {
this.$message.warning('请先选择字典类型')
return
}
this.dictDataDialogTitle = '新增字典数据'
this.dictDataForm = {
id: null,
dictTypeId: this.selectedDictType.id,
dictLabel: '',
dictValue: '',
sort: 0
}
this.dictDataDialogVisible = true
},
editDictData(row) {
this.dictDataDialogTitle = '编辑字典数据'
this.dictDataForm = { ...row }
this.dictDataDialogVisible = true
},
saveDictData(formData) {
const api = formData.id ? dictApi.updateDictData : dictApi.addDictData
api(formData, ({ data }) => {
if (data.code === 0) {
this.$message.success('保存成功')
this.dictDataDialogVisible = false
this.loadDictDataList(formData.dictTypeId)
}
})
},
deleteDictData(row) {
this.$confirm('确定要删除该字典数据吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
dictApi.deleteDictData([row.id], ({ data }) => {
if (data.code === 0) {
this.$message.success('删除成功')
this.loadDictDataList(row.dictTypeId)
}
})
})
},
batchDeleteDictData() {
if (this.selectedDictData.length === 0) {
this.$message.warning('请选择要删除的字典数据')
return
}
this.$confirm('确定要删除选中的字典数据吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const ids = this.selectedDictData.map(item => item.id)
dictApi.deleteDictData(ids, ({ data }) => {
if (data.code === 0) {
this.$message.success('删除成功')
this.loadDictDataList(this.selectedDictType.id)
}
})
})
},
handleSearch() {
if (!this.selectedDictType) {
this.$message.warning('请先选择字典类型')
return
}
this.currentPage = 1
this.loadDictDataList(this.selectedDictType.id)
},
// 添加分页相关方法
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.loadDictDataList(this.selectedDictType?.id);
},
goFirst() {
this.currentPage = 1;
this.loadDictDataList(this.selectedDictType?.id);
},
goPrev() {
if (this.currentPage > 1) {
this.currentPage--;
this.loadDictDataList(this.selectedDictType?.id);
}
},
goNext() {
if (this.currentPage < this.pageCount) {
this.currentPage++;
this.loadDictDataList(this.selectedDictType?.id);
}
},
goToPage(page) {
this.currentPage = page;
this.loadDictDataList(this.selectedDictType?.id);
}
},
computed: {
pageCount() {
return Math.ceil(this.total / this.pageSize);
},
visiblePages() {
const pages = [];
const maxVisible = 3;
let start = Math.max(1, this.currentPage - 1);
let end = Math.min(this.pageCount, start + maxVisible - 1);
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
}
}
}
</script>
<style lang="scss" scoped>
.welcome {
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
position: relative;
flex-direction: column;
background-size: cover;
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.action-group {
display: flex;
align-items: center;
gap: 16px;
}
.search-group {
display: flex;
gap: 10px;
}
.search-input {
width: 240px;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
.btn-search:hover {
opacity: 0.9;
transform: translateY(-1px);
}
:deep(.search-input .el-input__inner) {
border-radius: 4px;
border: 1px solid #DCDFE6;
background-color: white;
transition: border-color 0.2s;
}
:deep(.search-input .el-input__inner:focus) {
border-color: #6b8cff;
outline: none;
}
.content-panel {
flex: 1;
display: flex;
overflow: hidden;
height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
}
.dict-type-panel {
width: 300px;
background: white;
border-right: 1px solid #ebeef5;
display: flex;
flex-direction: column;
}
.dict-type-header {
padding: 16px;
border-bottom: 1px solid #ebeef5;
display: flex;
gap: 8px;
}
.dict-type-table {
flex: 1;
overflow-y: auto;
}
.content-area {
flex: 1;
padding: 24px;
height: 100%;
min-width: 600px;
overflow: hidden;
background-color: white;
display: flex;
flex-direction: column;
}
.dict-data-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
}
.data-table {
border-radius: 6px;
overflow-y: auto;
background-color: transparent !important;
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
:deep(.el-table__body-wrapper) {
max-height: calc(var(--table-max-height) - 40px);
}
:deep(.el-table__body) {
tr:last-child td {
border-bottom: none;
}
}
}
:deep(.el-table) {
&::before {
display: none;
}
&::after {
display: none;
}
}
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
width: 100%;
flex-shrink: 0;
min-height: 60px;
background: white;
margin-top: 10px;
}
.batch-actions {
display: flex;
gap: 8px;
padding-left: 26px;
.el-button {
min-width: 72px;
height: 32px;
padding: 7px 12px 7px 10px;
font-size: 12px;
border-radius: 4px;
line-height: 1;
font-weight: 500;
border: none;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
}
.el-button--primary {
background: #5f70f3;
color: white;
}
.el-button--success {
background: #5bc98c;
color: white;
}
.el-button--danger {
background: #fd5b63;
color: white;
}
}
.custom-pagination {
display: flex;
align-items: center;
gap: 8px;
.el-select {
margin-right: 8px;
}
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-child(3),
.pagination-btn:nth-last-child(2) {
min-width: 60px;
height: 32px;
padding: 0 12px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #d7dce6;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-child(3)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(245, 247, 250, 0.3);
}
}
.pagination-btn.active {
background: #5f70f3 !important;
color: #ffffff !important;
border-color: #5f70f3 !important;
&:hover {
background: #6d7cf5 !important;
}
}
.total-text {
color: #909399;
font-size: 14px;
margin-left: 10px;
}
}
.page-size-select {
width: 100px;
margin-right: 10px;
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__suffix) {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
}
.edit-btn,
.delete-btn {
margin: 0 8px;
color: #7079aa !important;
font-size: 12px;
padding: 7px 12px;
height: 32px;
line-height: 1;
border-radius: 4px;
transition: all 0.3s ease;
&:hover {
color: #5a64b5 !important;
transform: translateY(-1px);
}
}
:deep(.dict-type-header .el-button) {
min-width: 72px;
height: 32px;
padding: 7px 12px 7px 10px;
font-size: 12px;
border-radius: 4px;
line-height: 1;
font-weight: 500;
border: none;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
&.el-button--success {
background: #5bc98c;
color: white;
}
&.el-button--danger {
background: #fd5b63;
color: white;
}
}
:deep(.el-table .cell) {
padding-left: 10px;
padding-right: 10px;
}
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
:deep(.dict-type-table .el-table__row) {
cursor: pointer;
}
:deep(.dict-type-table .el-table__row.current-row) {
background-color: #5778ff !important;
color: white;
}
:deep(.dict-type-table .el-table__row.current-row .el-button--text) {
color: white !important;
}
:deep(.dict-type-table .el-table__row:hover) {
background-color: #f5f7fa;
}
:deep(.dict-type-table .el-table__row.current-row:hover) {
background-color: #5778ff !important;
}
:deep(.dict-type-table .el-table__row td) {
background-color: transparent !important;
}
:deep(.el-table thead) {
color: #000000;
}
:deep(.el-card__body) {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
</style>
+15 -5
View File
@@ -96,8 +96,8 @@
</div>
<!-- 新增/编辑固件对话框 -->
<firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm" @submit="handleSubmit"
@cancel="dialogVisible = false" />
<firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm"
:firmware-types="firmwareTypes" @submit="handleSubmit" @cancel="dialogVisible = false" />
<el-footer>
<version-footer />
</el-footer>
@@ -109,7 +109,6 @@ import Api from "@/apis/api";
import FirmwareDialog from "@/components/FirmwareDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue";
import { FIRMWARE_TYPES } from "@/utils";
import { formatDate, formatFileSize } from "@/utils/format";
export default {
@@ -136,10 +135,12 @@ export default {
remark: "",
firmwarePath: ""
},
firmwareTypes: [],
};
},
created() {
this.fetchFirmwareList();
this.getFirmwareTypes();
},
computed: {
@@ -387,9 +388,18 @@ export default {
},
formatDate,
formatFileSize,
async getFirmwareTypes() {
try {
const res = await Api.dict.getDictDataByType('FIRMWARE_TYPE')
this.firmwareTypes = res.data
} catch (error) {
console.error('获取固件类型失败:', error)
this.$message.error(error.message || '获取固件类型失败')
}
},
getFirmwareTypeName(type) {
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
return firmwareType ? firmwareType.name : type;
const firmwareType = this.firmwareTypes.find(item => item.key === type)
return firmwareType ? firmwareType.name : type
},
},
};
@@ -25,8 +25,19 @@
</template>
</el-table-column>
<el-table-column label="参数编码" prop="paramCode" align="center"></el-table-column>
<el-table-column label="参数值" prop="paramValue" align="center"
show-overflow-tooltip></el-table-column>
<el-table-column label="参数值" prop="paramValue" align="center" show-overflow-tooltip>
<template slot-scope="scope">
<div v-if="isSensitiveParam(scope.row.paramCode)">
<span v-if="!scope.row.showValue">{{ maskSensitiveValue(scope.row.paramValue)
}}</span>
<span v-else>{{ scope.row.paramValue }}</span>
<el-button size="mini" type="text" @click="toggleSensitiveValue(scope.row)">
{{ scope.row.showValue ? '隐藏' : '查看' }}
</el-button>
</div>
<span v-else>{{ scope.row.paramValue }}</span>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" align="center"></el-table-column>
<el-table-column label="操作" align="center">
<template slot-scope="scope">
@@ -100,6 +111,7 @@ export default {
dialogVisible: false,
dialogTitle: "新增参数",
isAllSelected: false,
sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key"],
paramForm: {
id: null,
paramCode: "",
@@ -152,7 +164,8 @@ export default {
if (data.code === 0) {
this.paramsList = data.data.list.map(item => ({
...item,
selected: false
selected: false,
showValue: false
}));
this.total = data.data.total;
} else {
@@ -314,7 +327,18 @@ export default {
goToPage(page) {
this.currentPage = page;
this.fetchParams();
}
},
isSensitiveParam(paramCode) {
return this.sensitive_keys.some(key => paramCode.toLowerCase().includes(key.toLowerCase()));
},
maskSensitiveValue(value) {
if (!value) return '';
if (value.length <= 8) return '****';
return value.substring(0, 4) + '****' + value.substring(value.length - 4);
},
toggleSensitiveValue(row) {
this.$set(row, 'showValue', !row.showValue);
},
},
};
</script>
@@ -26,6 +26,7 @@
<el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
<el-table-column label="设备数量" prop="deviceCount" align="center"></el-table-column>
<el-table-column label="注册时间" prop="createDate" align="center"></el-table-column>
<el-table-column label="状态" prop="status" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === 1" type="success">正常</el-tag>
@@ -336,7 +337,7 @@ export default {
}).catch(() => {
// 用户取消操作
});
}
},
},
};
</script>
+21 -21
View File
@@ -32,11 +32,7 @@
</div>
<div class="device-list-container">
<template v-if="isLoading">
<div
v-for="i in skeletonCount"
:key="'skeleton-'+i"
class="skeleton-item"
>
<div v-for="i in skeletonCount" :key="'skeleton-' + i" class="skeleton-item">
<div class="skeleton-image"></div>
<div class="skeleton-content">
<div class="skeleton-line"></div>
@@ -46,14 +42,8 @@
</template>
<template v-else>
<DeviceItem
v-for="(item, index) in devices"
:key="index"
:device="item"
@configure="goToRoleConfig"
@deviceManage="handleDeviceManage"
@delete="handleDeleteAgent"
/>
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" @configure="goToRoleConfig"
@deviceManage="handleDeviceManage" @delete="handleDeleteAgent" @chat-history="handleShowChatHistory" />
</template>
</div>
</div>
@@ -62,6 +52,7 @@
<el-footer>
<version-footer />
</el-footer>
<chat-history-dialog :visible.sync="showChatHistory" :agent-id="currentAgentId" :agent-name="currentAgentName" />
</div>
</template>
@@ -69,13 +60,14 @@
<script>
import Api from '@/apis/api';
import AddWisdomBodyDialog from '@/components/AddWisdomBodyDialog.vue';
import ChatHistoryDialog from '@/components/ChatHistoryDialog.vue';
import DeviceItem from '@/components/DeviceItem.vue';
import HeaderBar from '@/components/HeaderBar.vue';
import VersionFooter from '@/components/VersionFooter.vue';
export default {
name: 'HomePage',
components: { DeviceItem, AddWisdomBodyDialog, HeaderBar, VersionFooter },
components: { DeviceItem, AddWisdomBodyDialog, HeaderBar, VersionFooter, ChatHistoryDialog },
data() {
return {
addDeviceDialogVisible: false,
@@ -85,6 +77,9 @@ export default {
searchRegex: null,
isLoading: true,
skeletonCount: localStorage.getItem('skeletonCount') || 8,
showChatHistory: false,
currentAgentId: '',
currentAgentName: ''
}
},
@@ -177,6 +172,11 @@ export default {
}
});
}).catch(() => { });
},
handleShowChatHistory({ agentId, agentName }) {
this.currentAgentId = agentId;
this.currentAgentName = agentName;
this.showChatHistory = true;
}
}
}
@@ -302,7 +302,9 @@ export default {
/* 骨架屏动画 */
@keyframes shimmer {
100% { transform: translateX(100%); }
100% {
transform: translateX(100%);
}
}
.skeleton-item {
@@ -353,12 +355,10 @@ export default {
left: 0;
width: 50%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255,255,255,0),
rgba(255,255,255,0.3),
rgba(255,255,255,0)
);
background: linear-gradient(90deg,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0.3),
rgba(255, 255, 255, 0));
animation: shimmer 1.5s infinite;
}
</style>
+39 -85
View File
@@ -1,6 +1,6 @@
<template>
<div class="welcome">
<HeaderBar/>
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">角色配置</h2>
@@ -26,93 +26,49 @@
<div class="form-grid">
<div class="form-column">
<el-form-item label="助手昵称:">
<el-input v-model="form.agentName" class="form-input"/>
<el-input v-model="form.agentName" class="form-input" />
</el-form-item>
<el-form-item label="角色模版:">
<div class="template-container">
<div
v-for="(template, index) in templates"
:key="`template-${index}`"
class="template-item"
:class="{ 'template-loading': loadingTemplate }"
@click="selectTemplate(template)"
>
<div v-for="(template, index) in templates" :key="`template-${index}`" class="template-item"
:class="{ 'template-loading': loadingTemplate }" @click="selectTemplate(template)">
{{ template.agentName }}
</div>
</div>
</el-form-item>
<el-form-item label="角色介绍:">
<el-input
type="textarea"
rows="5"
resize="none"
placeholder="请输入内容"
v-model="form.systemPrompt"
maxlength="2000"
show-word-limit
class="form-textarea"
/>
<el-input type="textarea" rows="12" resize="none" placeholder="请输入内容" v-model="form.systemPrompt"
maxlength="2000" show-word-limit class="form-textarea" />
</el-form-item>
<el-form-item label="语言编码:">
<el-input
v-model="form.langCode"
placeholder="请输入语言编码,如:zh_CN"
maxlength="10"
show-word-limit
class="form-input"
/>
<el-form-item label="语言编码:" style="display: none;">
<el-input v-model="form.langCode" placeholder="请输入语言编码,如:zh_CN" maxlength="10" show-word-limit
class="form-input" />
</el-form-item>
<el-form-item label="交互语种:">
<el-input
v-model="form.language"
placeholder="请输入交互语种,如:中文"
maxlength="10"
show-word-limit
class="form-input"
/>
<el-form-item label="交互语种:" style="display: none;">
<el-input v-model="form.language" placeholder="请输入交互语种,如:中文" maxlength="10" show-word-limit
class="form-input" />
</el-form-item>
<div class="action-bar">
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
<el-button class="reset-btn" @click="resetConfig">重置</el-button>
<div class="hint-text">
<img loading="lazy" src="@/assets/home/red-info.png" alt="">
<span>保存配置后需要重启设备新的配置才会生效</span>
</div>
</div>
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
<el-button class="reset-btn" @click="resetConfig">重置</el-button>
<div class="hint-text">
<img loading="lazy" src="@/assets/home/red-info.png" alt="">
<span>保存配置后需要重启设备新的配置才会生效</span>
</div>
</div>
</div>
<div class="form-column">
<el-form-item
v-for="(model, index) in models"
:key="`model-${index}`"
:label="model.label"
class="model-item"
>
<el-select
v-model="form.model[model.key]"
filterable
placeholder="请选择"
class="form-select"
>
<el-option
v-for="(item, optionIndex) in modelOptions[model.type]"
:key="`option-${index}-${optionIndex}`"
:label="item.label"
:value="item.value"
/>
<el-form-item v-for="(model, index) in models" :key="`model-${index}`" :label="model.label"
class="model-item">
<el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="form-select">
<el-option v-for="(item, optionIndex) in modelOptions[model.type]"
:key="`option-${index}-${optionIndex}`" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="角色音色:">
<el-select
v-model="form.ttsVoiceId"
placeholder="请选择"
class="form-select"
>
<el-option
v-for="(item, index) in voiceOptions"
:key="`voice-${index}`"
:label="item.label"
:value="item.value"
/>
<el-select v-model="form.ttsVoiceId" placeholder="请选择" class="form-select">
<el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label"
:value="item.value" />
</el-select>
</el-form-item>
</div>
@@ -133,7 +89,7 @@ import HeaderBar from "@/components/HeaderBar.vue";
export default {
name: 'RoleConfigPage',
components: {HeaderBar},
components: { HeaderBar },
data() {
return {
form: {
@@ -154,12 +110,12 @@ export default {
}
},
models: [
{label: '语音活动检测(VAD)', key: 'vadModelId', type: 'VAD'},
{label: '语音识别(ASR)', key: 'asrModelId', type: 'ASR'},
{label: '大语言模型(LLM)', key: 'llmModelId', type: 'LLM'},
{label: '意图识别(Intent)', key: 'intentModelId', type: 'Intent'},
{label: '记忆(Memory)', key: 'memModelId', type: 'Memory'},
{label: '语音合成(TTS)', key: 'ttsModelId', type: 'TTS'},
{ label: '语音活动检测(VAD)', key: 'vadModelId', type: 'VAD' },
{ label: '语音识别(ASR)', key: 'asrModelId', type: 'ASR' },
{ label: '大语言模型(LLM)', key: 'llmModelId', type: 'LLM' },
{ label: '意图识别(Intent)', key: 'intentModelId', type: 'Intent' },
{ label: '记忆(Memory)', key: 'memModelId', type: 'Memory' },
{ label: '语音合成(TTS)', key: 'ttsModelId', type: 'TTS' },
],
modelOptions: {},
templates: [],
@@ -187,7 +143,7 @@ export default {
language: this.form.language,
sort: this.form.sort
};
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({data}) => {
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => {
if (data.code === 0) {
this.$message.success({
message: '配置保存成功',
@@ -232,7 +188,7 @@ export default {
});
},
fetchTemplates() {
Api.agent.getAgentTemplate(({data}) => {
Api.agent.getAgentTemplate(({ data }) => {
if (data.code === 0) {
this.templates = data.data;
} else {
@@ -277,7 +233,7 @@ export default {
};
},
fetchAgentConfig(agentId) {
Api.agent.getDeviceConfig(agentId, ({data}) => {
Api.agent.getDeviceConfig(agentId, ({ data }) => {
if (data.code === 0) {
this.form = {
...this.form,
@@ -298,7 +254,7 @@ export default {
},
fetchModelOptions() {
this.models.forEach(model => {
Api.model.getModelNames(model.type, '', ({data}) => {
Api.model.getModelNames(model.type, '', ({ data }) => {
if (data.code === 0) {
this.$set(this.modelOptions, model.type, data.data.map(item => ({
value: item.id,
@@ -315,7 +271,7 @@ export default {
this.voiceOptions = [];
return;
}
Api.model.getModelVoices(modelId, '', ({data}) => {
Api.model.getModelVoices(modelId, '', ({ data }) => {
if (data.code === 0 && data.data) {
this.voiceOptions = data.data.map(voice => ({
value: voice.id,
@@ -567,7 +523,6 @@ export default {
background: none;
position: absolute;
font-size: 12px;
bottom: -10%;
right: 3%;
}
@@ -597,5 +552,4 @@ export default {
color: #409EFF;
border-color: #409EFF;
}
</style>
+17 -14
View File
@@ -1,7 +1,7 @@
import asyncio
import sys
import signal
from config.settings import load_config, check_config_file
from config.settings import load_config
from core.websocket_server import WebSocketServer
from core.ota_server import SimpleOtaServer
from core.utils.util import check_ffmpeg_installed
@@ -12,26 +12,29 @@ TAG = __name__
logger = setup_logging()
async def wait_for_exit():
"""Windows 和 Linux 兼容的退出监听"""
async def wait_for_exit() -> None:
"""
阻塞直到收到 CtrlC / SIGTERM。
- Unix: 使用 add_signal_handler
- Windows: 依赖 KeyboardInterrupt
"""
loop = asyncio.get_running_loop()
stop_event = asyncio.Event()
if sys.platform == "win32":
# Windows: 用 sys.stdin.read() 监听 Ctrl + C
await loop.run_in_executor(None, sys.stdin.read)
else:
# Linux/macOS: 用 signal 监听 Ctrl + C
def stop():
stop_event.set()
loop.add_signal_handler(signal.SIGINT, stop)
loop.add_signal_handler(signal.SIGTERM, stop) # 支持 kill 进程
if sys.platform != "win32": # Unix / macOS
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop_event.set)
await stop_event.wait()
else:
# Windowsawait一个永远pending的fut
# 让 KeyboardInterrupt 冒泡到 asyncio.run,以此消除遗留普通线程导致进程退出阻塞的问题
try:
await asyncio.Future()
except KeyboardInterrupt: # CtrlC
pass
async def main():
check_config_file()
check_ffmpeg_installed()
config = load_config()
+55 -12
View File
@@ -1,7 +1,7 @@
# 如果您是一名开发者,建议阅读以下内容。如果不是开发者,可以忽略这部分内容。
# 在开发中,在项目根目录创建data目录,将【config.yaml】复制一份,改成【.config.yaml】,放进data目录中
# 系统会优先读取【data/.config.yaml】文件的配置。
# 这样做,可以避免在提交代码的时候,错误地提交密钥信息,保护您的密钥安全。
# 在开发中,请在项目根目录创建data目录,然后在data目录创建名称为【.config.yaml】的空文件
# 然后你想修改覆盖修改什么配置,就修改【.config.yaml】文件,而不是修改【config.yaml】文件
# 系统会优先读取【data/.config.yaml】文件的配置,如果【.config.yaml】文件里的配置不存在,系统会自动去读取【config.yaml】文件的配置
# 这样做,可以最简化配置,保护您的密钥安全。
# #####################################################################################
# #############################以下是服务器基本运行配置####################################
@@ -158,7 +158,7 @@ selected_module:
# 不想开通意图识别,就设置成:nointent
# 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作
# 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令
# 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-pro-32k-functioncall-241028
# 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-1-5-pro-32k-250115
Intent: function_call
# 意图识别,是用于理解用户意图的模块,例如:播放音乐
@@ -209,6 +209,20 @@ ASR:
type: fun_local
model_dir: models/SenseVoiceSmall
output_dir: tmp/
FunASRServer:
# 独立部署FunASR,使用FunASR的API服务,只需要五句话
# 第一句:mkdir -p ./funasr-runtime-resources/models
# 第二句:sudo docker run -d -p 10096:10095 --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12
# 上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime
# 不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 &
# 上一句话执行后会进入到容器,继续第五句:tail -f log.txt
# 第五句话执行完后,会看到模型下载日志,下载完后就可以连接使用了
# 以上是使用CPU推理,如果有GPU,详细参考:https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md
type: fun_server
host: 127.0.0.1
port: 10096
is_ssl: true
output_dir: tmp/
SherpaASR:
type: sherpa_onnx_local
model_dir: models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17
@@ -220,6 +234,9 @@ ASR:
appid: 你的火山引擎语音合成服务appid
access_token: 你的火山引擎语音合成服务access_token
cluster: volcengine_input_common
# 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738
boosting_table_name: (选填)你的热词文件名称
correct_table_name: (选填)你的替换词文件名称
output_dir: tmp/
TencentASR:
# token申请地址:https://console.cloud.tencent.com/cam/capi
@@ -229,6 +246,29 @@ ASR:
secret_id: 你的腾讯语音合成服务secret_id
secret_key: 你的腾讯语音合成服务secret_key
output_dir: tmp/
AliyunASR:
# 阿里云智能语音交互服务,需要先在阿里云平台开通服务,然后获取验证信息
# 平台地址:https://nls-portal.console.aliyun.com/
# appkey地址:https://nls-portal.console.aliyun.com/applist
# token地址:https://nls-portal.console.aliyun.com/overview
# 定义ASR API类型
type: aliyun
appkey: 你的阿里云智能语音交互服务项目Appkey
token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_idaccess_key_secret
access_key_id: 你的阿里云账号access_key_id
access_key_secret: 你的阿里云账号access_key_secret
output_dir: tmp/
BaiduASR:
# 获取AppID、API Key、Secret Keyhttps://console.bce.baidu.com/ai-engine/old/#/ai/speech/app/list
# 查看资源额度:https://console.bce.baidu.com/ai-engine/old/#/ai/speech/overview/resource/list
type: baidu
app_id: 你的百度语音技术AppID
api_key: 你的百度语音技术APIKey
secret_key: 你的百度语音技术SecretKey
# 语言参数,1537为普通话,具体参考:https://ai.baidu.com/ai-doc/SPEECH/0lbxfnc9b
dev_pid: 1537
output_dir: tmp/
VAD:
SileroVAD:
type: silero
@@ -266,12 +306,12 @@ LLM:
DoubaoLLM:
# 定义LLM API类型
type: openai
# 先开通服务,打开以下网址,开通的服务搜索Doubao-pro-32k,开通它
# 先开通服务,打开以下网址,开通的服务搜索Doubao-1.5-pro,开通它
# 开通改地址:https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false
# 免费额度500000token
# 开通后,进入这里获取密钥:https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D
base_url: https://ark.cn-beijing.volces.com/api/v3
model_name: doubao-pro-32k-functioncall-241028
model_name: doubao-1-5-pro-32k-250115
api_key: 你的doubao web key
DeepSeekLLM:
# 定义LLM API类型
@@ -385,6 +425,9 @@ TTS:
appid: 你的火山引擎语音合成服务appid
access_token: 你的火山引擎语音合成服务access_token
cluster: volcano_tts
speed_ratio: 1.0
volume_ratio: 1.0
pitch_ratio: 1.0
CosyVoiceSiliconflow:
type: siliconflow
# 硅基流动TTS
@@ -447,12 +490,12 @@ TTS:
GPT_SOVITS_V2:
# 定义TTS API类型
#启动tts方法:
#python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/caixukun.yaml
#python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/demo.yaml
type: gpt_sovits_v2
url: "http://127.0.0.1:9880/tts"
output_dir: tmp/
text_lang: "auto"
ref_audio_path: "caixukun.wav"
ref_audio_path: "demo.wav"
prompt_text: ""
prompt_lang: "zh"
top_k: 5
@@ -585,8 +628,8 @@ TTS:
ACGNTTS:
#在线网址:https://acgn.ttson.cn/
#token购买:www.ttson.cn
#开发相关疑问请提交至3497689533@qq.com
#角色id获取地址:ctrl+f快速检索角色——网站管理者不允许发布,可询问网站管理者1069379506
#开发相关疑问请提交至网站上的qq
#角色id获取地址:ctrl+f快速检索角色——网站管理者不允许发布,可询问网站管理者
#各参数意义见开发文档:https://www.yuque.com/alexuh/skmti9/wm6taqislegb02gd?singleDoc#
type: ttson
token: your_token
@@ -628,4 +671,4 @@ TTS:
headers: # 自定义请求头
# Authorization: Bearer xxxx
format: wav # 接口返回的音频格式
output_dir: tmp/
output_dir: tmp/
+42 -21
View File
@@ -1,6 +1,7 @@
import os
import argparse
import yaml
from collections.abc import Mapping
from config.manage_api_client import init_service, get_server_config, get_agent_models
@@ -25,35 +26,24 @@ def load_config():
if _config_cache is not None:
return _config_cache
parser = argparse.ArgumentParser(description="Server configuration")
config_file = get_config_file()
default_config_path = get_project_dir() + "config.yaml"
custom_config_path = get_project_dir() + "data/.config.yaml"
parser.add_argument("--config_path", type=str, default=config_file)
args = parser.parse_args()
config = read_config(args.config_path)
if config.get("manager-api", {}).get("url"):
config = get_config_from_api(config)
# 加载默认配置
default_config = read_config(default_config_path)
custom_config = read_config(custom_config_path)
if custom_config.get("manager-api", {}).get("url"):
config = get_config_from_api(custom_config)
else:
# 合并配置
config = merge_configs(default_config, custom_config)
# 初始化目录
ensure_directories(config)
_config_cache = config
return config
def get_config_file():
"""获取配置文件路径,优先使用私有配置文件(若存在)。
Returns:
str: 配置文件路径(相对路径或默认路径)
"""
default_config_file = "config.yaml"
config_file = default_config_file
if os.path.exists(get_project_dir() + "data/." + default_config_file):
config_file = "data/." + default_config_file
return config_file
def get_config_from_api(config):
"""从Java API获取配置"""
# 初始化API客户端
@@ -115,3 +105,34 @@ def ensure_directories(config):
os.makedirs(dir_path, exist_ok=True)
except PermissionError:
print(f"警告:无法创建目录 {dir_path},请检查写入权限")
def merge_configs(default_config, custom_config):
"""
递归合并配置,custom_config优先级更高
Args:
default_config: 默认配置
custom_config: 用户自定义配置
Returns:
合并后的配置
"""
if not isinstance(default_config, Mapping) or not isinstance(
custom_config, Mapping
):
return custom_config
merged = dict(default_config)
for key, value in custom_config.items():
if (
key in merged
and isinstance(merged[key], Mapping)
and isinstance(value, Mapping)
):
merged[key] = merge_configs(merged[key], value)
else:
merged[key] = value
return merged
+13 -5
View File
@@ -2,15 +2,22 @@ import os
import sys
from loguru import logger
from config.config_loader import load_config
from config.settings import check_config_file
SERVER_VERSION = "0.3.13"
SERVER_VERSION = "0.3.14"
def get_module_abbreviation(module_name, module_dict):
"""获取模块名称的缩写,如果为空则返回00"""
return (
module_dict.get(module_name, "")[:2] if module_dict.get(module_name) else "00"
)
"""获取模块名称的缩写,如果为空则返回00
如果名称中包含下划线,则返回下划线后面的前两个字符
"""
module_value = module_dict.get(module_name, "")
if not module_value:
return "00"
if "_" in module_value:
parts = module_value.split("_")
return parts[-1][:2] if parts[-1] else "00"
return module_value[:2]
def build_module_string(selected_module):
@@ -32,6 +39,7 @@ def formatter(record):
def setup_logging():
check_config_file()
"""从配置文件中读取日志配置,并设置日志输出格式和级别"""
config = load_config()
log_config = config["log"]
@@ -1,5 +1,6 @@
import os
import time
import base64
from typing import Optional, Dict
import httpx
@@ -53,6 +54,7 @@ class ManageApiClient:
headers={
"User-Agent": f"PythonClient/2.0 (PID:{os.getpid()})",
"Accept": "application/json",
"Authorization": "Bearer " + cls._secret,
},
timeout=cls.config.get("timeout", 30), # 默认超时时间30秒
)
@@ -125,9 +127,7 @@ class ManageApiClient:
def get_server_config() -> Optional[Dict]:
"""获取服务器基础配置"""
return ManageApiClient._instance._execute_request(
"POST", "/config/server-base", json={"secret": ManageApiClient._secret}
)
return ManageApiClient._instance._execute_request("POST", "/config/server-base")
def get_agent_models(
@@ -138,7 +138,6 @@ def get_agent_models(
"POST",
"/config/agent-models",
json={
"secret": ManageApiClient._secret,
"macAddress": mac_address,
"clientId": client_id,
"selectedModule": selected_module,
@@ -146,6 +145,31 @@ def get_agent_models(
)
def report(
mac_address: str, session_id: str, chat_type: int, content: str, audio
) -> Optional[Dict]:
"""带熔断的业务方法示例"""
if not content or not ManageApiClient._instance:
return None
try:
return ManageApiClient._instance._execute_request(
"POST",
f"/agent/chat-history/report",
json={
"macAddress": mac_address,
"sessionId": session_id,
"chatType": chat_type,
"content": content,
"audioBase64": (
base64.b64encode(audio).decode("utf-8") if audio else None
),
},
)
except Exception as e:
print(f"TTS上报失败: {e}")
return None
def init_service(config):
ManageApiClient(config)
+19 -50
View File
@@ -1,64 +1,33 @@
import os
from collections.abc import Mapping
from config.config_loader import read_config, get_project_dir, load_config
default_config_file = "config.yaml"
def find_missing_keys(new_config, old_config, parent_key=""):
"""
递归查找缺失的配置项
返回格式:[缺失配置路径]
"""
missing_keys = []
if not isinstance(new_config, Mapping):
return missing_keys
for key, value in new_config.items():
# 构建当前配置路径
full_path = f"{parent_key}.{key}" if parent_key else key
# 检查键是否存在
if key not in old_config:
missing_keys.append(full_path)
continue
# 递归检查嵌套字典
if isinstance(value, Mapping):
sub_missing = find_missing_keys(
value, old_config[key], parent_key=full_path
)
missing_keys.extend(sub_missing)
return missing_keys
config_file_valid = False
def check_config_file():
old_config_file = get_project_dir() + "data/." + default_config_file
if not os.path.exists(old_config_file):
global config_file_valid
if config_file_valid:
return
old_config = load_config()
new_config = read_config(get_project_dir() + default_config_file)
# 查找缺失的配置项
missing_keys = find_missing_keys(new_config, old_config)
read_config_from_api = old_config.get("read_config_from_api", False)
if read_config_from_api:
old_config_origin = read_config(old_config_file)
"""
简化的配置检查,仅提示用户配置文件的使用情况
"""
custom_config_file = get_project_dir() + "data/." + default_config_file
if not os.path.exists(custom_config_file):
raise FileNotFoundError(
"找不到data/.config.yaml文件,请按教程确认该配置文件是否存在"
)
# 检查是否从API读取配置
config = load_config()
if config.get("read_config_from_api", False):
print("从API读取配置")
old_config_origin = read_config(custom_config_file)
if old_config_origin.get("selected_module") is not None:
missing_keys_str = "\n".join(f"- {key}" for key in missing_keys)
error_msg = "您的配置文件好像既包含智控台的配置又包含本地配置:\n"
error_msg += "\n建议您:\n"
error_msg += "1、将根目录的config_from_api.yaml文件复制到data下,重命名为.config.yaml\n"
error_msg += "2、按教程配置好接口地址和密钥\n"
raise ValueError(error_msg)
return
if missing_keys:
missing_keys_str = "\n".join(f"- {key}" for key in missing_keys)
error_msg = "您的配置文件太旧了,缺少了:\n"
error_msg += missing_keys_str
error_msg += "\n建议您:\n"
error_msg += "1、备份data/.config.yaml文件\n"
error_msg += "2、将根目录的config.yaml文件复制到data下,重命名为.config.yaml\n"
error_msg += "3、将密钥逐个复制到新的配置文件中\n"
raise ValueError(error_msg)
config_file_valid = True
+138 -88
View File
@@ -17,8 +17,9 @@ from core.handle.textHandle import handleTextMessage
from core.utils.util import (
get_string_no_punctuation_or_emoji,
extract_json_from_string,
get_ip_info,
initialize_modules,
check_vad_update,
check_asr_update,
)
from concurrent.futures import ThreadPoolExecutor, TimeoutError
from core.handle.sendAudioHandle import sendAudioMessage
@@ -30,6 +31,7 @@ from core.mcp.manager import MCPManager
from config.config_loader import get_private_config_from_api
from config.manage_api_client import DeviceNotFoundException, DeviceBindException
from core.utils.output_counter import add_device_output
from core.handle.ttsReportHandle import enqueue_tts_report, report_tts
TAG = __name__
@@ -42,20 +44,32 @@ class TTSException(RuntimeError):
class ConnectionHandler:
def __init__(
self, config: Dict[str, Any], _vad, _asr, _llm, _tts, _memory, _intent
self,
config: Dict[str, Any],
_vad,
_asr,
_llm,
_tts,
_memory,
_intent,
server=None,
):
self.common_config = config
self.config = copy.deepcopy(config)
self.session_id = str(uuid.uuid4())
self.logger = setup_logging()
self.auth = AuthMiddleware(config)
self.server = server # 保存server实例的引用
self.need_bind = False
self.bind_code = None
self.read_config_from_api = self.config.get("read_config_from_api", False)
self.websocket = None
self.headers = None
self.device_id = None
self.client_ip = None
self.client_ip_info = {}
self.session_id = None
self.prompt = None
self.welcome_msg = None
self.max_output_size = 0
@@ -71,9 +85,15 @@ class ConnectionHandler:
self.audio_play_queue = queue.Queue()
self.executor = ThreadPoolExecutor(max_workers=10)
# 上报线程
self.tts_report_queue = queue.Queue()
self.tts_report_thread = None
# 依赖的组件
self.vad = _vad
self.asr = _asr
self.vad = None
self.asr = None
self._asr = _asr
self._vad = _vad
self.llm = _llm
self.tts = _tts
self.memory = _memory
@@ -136,11 +156,9 @@ class ConnectionHandler:
self.headers["device-id"] = query_params["device-id"][0]
self.headers["client-id"] = query_params["client-id"][0]
else:
self.logger.bind(tag=TAG).error(
"无法从请求头和URL查询参数中获取device-id"
)
await ws.send("端口正常,如需测试连接,请使用test_page.html")
await self.close(ws)
return
# 获取客户端ip地址
self.client_ip = ws.remote_address[0]
self.logger.bind(tag=TAG).info(
@@ -152,7 +170,7 @@ class ConnectionHandler:
# 认证通过,继续处理
self.websocket = ws
self.session_id = str(uuid.uuid4())
self.device_id = self.headers.get("device-id", None)
# 启动超时检查任务
self.timeout_task = asyncio.create_task(self._check_timeout())
@@ -162,9 +180,9 @@ class ConnectionHandler:
await self.websocket.send(json.dumps(self.welcome_msg))
# 获取差异化配置
private_config = self._initialize_private_config()
self._initialize_private_config()
# 异步初始化
self.executor.submit(self._initialize_components, private_config)
self.executor.submit(self._initialize_components)
# tts 消化线程
self.tts_priority_thread = threading.Thread(
target=self._tts_priority_thread, daemon=True
@@ -196,40 +214,61 @@ class ConnectionHandler:
async def _save_and_close(self, ws):
"""保存记忆并关闭连接"""
try:
await self.memory.save_memory(self.dialogue.dialogue)
if self.memory:
await self.memory.save_memory(self.dialogue.dialogue)
except Exception as e:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
finally:
await self.close(ws)
async def reset_timeout(self):
"""重置超时计时器"""
if self.timeout_task:
self.timeout_task.cancel()
self.timeout_task = asyncio.create_task(self._check_timeout())
async def _route_message(self, message):
"""消息路由"""
# 重置超时计时器
if self.timeout_task:
self.timeout_task.cancel()
self.timeout_task = asyncio.create_task(self._check_timeout())
await self.reset_timeout()
if isinstance(message, str):
await handleTextMessage(self, message)
elif isinstance(message, bytes):
await handleAudioMessage(self, message)
def _initialize_components(self, private_config):
def _initialize_components(self):
"""初始化组件"""
if private_config is not None:
self._initialize_models(private_config)
else:
self.prompt = self.config["prompt"]
self.change_system_prompt(self.prompt)
self.prompt = self.config["prompt"]
self.change_system_prompt(self.prompt)
self.logger.bind(tag=TAG).info(f"初始化组件: prompt成功 {self.prompt[:50]}...")
"""初始化本地组件"""
if self.vad is None:
self.vad = self._vad
if self.asr is None:
self.asr = self._asr
"""加载记忆"""
self._initialize_memory()
"""加载意图识别"""
self._initialize_intent()
"""初始化上报线程"""
self._init_report_threads()
def _init_report_threads(self):
"""初始化ASR和TTS上报线程"""
if not self.read_config_from_api or self.need_bind:
return
if self.tts_report_thread is None or not self.tts_report_thread.is_alive():
self.tts_report_thread = threading.Thread(
target=self._tts_report_worker, daemon=True
)
self.tts_report_thread.start()
self.logger.bind(tag=TAG).info("TTS上报线程已启动")
def _initialize_private_config(self):
read_config_from_api = self.config.get("read_config_from_api", False)
"""如果是从配置文件获取,则进行二次实例化"""
if not read_config_from_api:
if not self.read_config_from_api:
return
"""从接口获取差异化的配置进行二次实例化,非全量重新实例化"""
try:
@@ -255,54 +294,21 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}")
private_config = {}
init_tts = False
if private_config.get("TTS", None) is not None:
init_tts = True
self.config["TTS"] = private_config["TTS"]
self.config["selected_module"]["TTS"] = private_config["selected_module"][
"TTS"
]
try:
modules = initialize_modules(
self.logger,
private_config,
False,
False,
False,
init_tts,
False,
False,
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
modules = {}
if modules.get("tts", None) is not None:
self.tts = modules["tts"]
if modules.get("prompt", None) is not None:
self.change_system_prompt(modules["prompt"])
private_config["prompt"] = None
return private_config
def _initialize_models(self, private_config):
init_vad, init_asr, init_llm, init_memory, init_intent = (
False,
init_llm, init_tts, init_memory, init_intent = (
False,
False,
False,
False,
)
if private_config.get("VAD", None) is not None:
init_vad = True
self.config["VAD"] = private_config["VAD"]
self.config["selected_module"]["VAD"] = private_config["selected_module"][
"VAD"
]
if private_config.get("ASR", None) is not None:
init_asr = True
self.config["ASR"] = private_config["ASR"]
self.config["selected_module"]["ASR"] = private_config["selected_module"][
"ASR"
init_vad = check_vad_update(self.common_config, private_config)
init_asr = check_asr_update(self.common_config, private_config)
if private_config.get("TTS", None) is not None:
init_tts = True
self.config["TTS"] = private_config["TTS"]
self.config["selected_module"]["TTS"] = private_config["selected_module"][
"TTS"
]
if private_config.get("LLM", None) is not None:
init_llm = True
@@ -322,8 +328,11 @@ class ConnectionHandler:
self.config["selected_module"]["Intent"] = private_config[
"selected_module"
]["Intent"]
if private_config.get("prompt", None) is not None:
self.config["prompt"] = private_config["prompt"]
if private_config.get("device_max_output_size", None) is not None:
self.max_output_size = int(private_config["device_max_output_size"])
try:
modules = initialize_modules(
self.logger,
@@ -331,13 +340,15 @@ class ConnectionHandler:
init_vad,
init_asr,
init_llm,
False,
init_tts,
init_memory,
init_intent,
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
modules = {}
if modules.get("tts", None) is not None:
self.tts = modules["tts"]
if modules.get("vad", None) is not None:
self.vad = modules["vad"]
if modules.get("asr", None) is not None:
@@ -351,8 +362,7 @@ class ConnectionHandler:
def _initialize_memory(self):
"""初始化记忆模块"""
device_id = self.headers.get("device-id", None)
self.memory.init_memory(device_id, self.llm)
self.memory.init_memory(self.device_id, self.llm)
def _initialize_intent(self):
if (
@@ -416,10 +426,12 @@ class ConnectionHandler:
processed_chars = 0 # 跟踪已处理的字符位置
try:
# 使用带记忆的对话
future = asyncio.run_coroutine_threadsafe(
self.memory.query_memory(query), self.loop
)
memory_str = future.result()
memory_str = None
if self.memory is not None:
future = asyncio.run_coroutine_threadsafe(
self.memory.query_memory(query), self.loop
)
memory_str = future.result()
self.logger.bind(tag=TAG).debug(f"记忆内容: {memory_str}")
llm_responses = self.llm.response(
@@ -466,7 +478,7 @@ class ConnectionHandler:
future = self.executor.submit(
self.speak_and_play, segment_text, text_index
)
self.tts_queue.put(future)
self.tts_queue.put((future, text_index))
processed_chars += len(segment_text_raw) # 更新已处理字符位置
# 处理最后剩余的文本
@@ -480,7 +492,7 @@ class ConnectionHandler:
future = self.executor.submit(
self.speak_and_play, segment_text, text_index
)
self.tts_queue.put(future)
self.tts_queue.put((future, text_index))
self.llm_finish_task = True
self.dialogue.put(Message(role="assistant", content="".join(response_message)))
@@ -507,10 +519,12 @@ class ConnectionHandler:
start_time = time.time()
# 使用带记忆的对话
future = asyncio.run_coroutine_threadsafe(
self.memory.query_memory(query), self.loop
)
memory_str = future.result()
memory_str = None
if self.memory is not None:
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)}")
@@ -596,7 +610,7 @@ class ConnectionHandler:
future = self.executor.submit(
self.speak_and_play, segment_text, text_index
)
self.tts_queue.put(future)
self.tts_queue.put((future, text_index))
# 更新已处理字符位置
processed_chars += len(segment_text_raw)
@@ -655,7 +669,7 @@ class ConnectionHandler:
future = self.executor.submit(
self.speak_and_play, segment_text, text_index
)
self.tts_queue.put(future)
self.tts_queue.put((future, text_index))
# 存储对话内容
if len(response_message) > 0:
@@ -717,7 +731,7 @@ class ConnectionHandler:
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.tts_queue.put((future, text_index))
self.dialogue.put(Message(role="assistant", content=text))
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
text = result.result
@@ -750,7 +764,7 @@ class ConnectionHandler:
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.tts_queue.put((future, text_index))
self.dialogue.put(Message(role="assistant", content=text))
else:
pass
@@ -760,7 +774,10 @@ class ConnectionHandler:
text = None
try:
try:
future = self.tts_queue.get(timeout=1)
item = self.tts_queue.get(timeout=1)
if item is None:
continue
future, text_index = item # 解包获取 Future 和 text_index
except queue.Empty:
if self.stop_event.is_set():
break
@@ -768,11 +785,11 @@ class ConnectionHandler:
if future is None:
continue
text = None
opus_datas, text_index, tts_file = [], 0, None
opus_datas, tts_file = [], None
try:
self.logger.bind(tag=TAG).debug("正在处理TTS任务...")
tts_timeout = int(self.config.get("tts_timeout", 10))
tts_file, text, text_index = future.result(timeout=tts_timeout)
tts_file, text, _ = future.result(timeout=tts_timeout)
if text is None or len(text) <= 0:
self.logger.bind(tag=TAG).error(
f"TTS出错:{text_index}: tts text is empty"
@@ -786,7 +803,9 @@ class ConnectionHandler:
f"TTS生成:文件路径: {tts_file}"
)
if os.path.exists(tts_file):
opus_datas, duration = self.tts.audio_to_opus_data(tts_file)
opus_datas, _ = self.tts.audio_to_opus_data(tts_file)
# 在这里上报TTS数据(使用文件路径)
enqueue_tts_report(self, 2, text, opus_datas)
else:
self.logger.bind(tag=TAG).error(
f"TTS出错:文件不存在{tts_file}"
@@ -842,6 +861,33 @@ class ConnectionHandler:
f"audio_play_priority priority_thread: {text} {e}"
)
def _tts_report_worker(self):
"""TTS上报工作线程"""
while not self.stop_event.is_set():
try:
# 从队列获取数据,设置超时以便定期检查停止事件
item = self.tts_report_queue.get(timeout=1)
if item is None: # 检测毒丸对象
break
type, text, audio_data = item
try:
# 执行上报(传入二进制数据)
report_tts(self, type, text, audio_data)
except Exception as e:
self.logger.bind(tag=TAG).error(f"TTS上报线程异常: {e}")
finally:
# 标记任务完成
self.tts_report_queue.task_done()
except queue.Empty:
continue
except Exception as e:
self.logger.bind(tag=TAG).error(f"TTS上报工作线程异常: {e}")
self.logger.bind(tag=TAG).info("TTS上报线程已退出")
def speak_and_play(self, text, text_index=0):
if text is None or len(text) <= 0:
self.logger.bind(tag=TAG).info(f"无需tts转换,query为空,{text}")
@@ -887,6 +933,9 @@ class ConnectionHandler:
self.executor.shutdown(wait=False, cancel_futures=True)
self.executor = None
# 添加毒丸对象到上报队列确保线程退出
self.tts_report_queue.put(None)
# 清空任务队列
self.clear_queues()
@@ -960,6 +1009,7 @@ def filter_sensitive_info(config: dict) -> dict:
"personal_access_token",
"access_token",
"token",
"secret",
"access_key_secret",
"secret_key",
]
@@ -3,15 +3,16 @@ import queue
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
async def handleAbortMessage(conn):
logger.bind(tag=TAG).info("Abort message received")
conn.logger.bind(tag=TAG).info("Abort message received")
# 设置成打断状态,会自动打断llm、tts任务
conn.client_abort = True
conn.clear_queues()
# 打断客户端说话状态
await conn.websocket.send(json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id}))
await conn.websocket.send(
json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id})
)
conn.clearSpeakStatus()
logger.bind(tag=TAG).info("Abort message received-end")
conn.logger.bind(tag=TAG).info("Abort message received-end")
@@ -4,7 +4,6 @@ from plugins_func.register import FunctionRegistry, ActionResponse, Action, Tool
from plugins_func.functions.hass_init import append_devices_to_prompt
TAG = __name__
logger = setup_logging()
class FunctionHandler:
@@ -40,7 +39,9 @@ class FunctionHandler:
for func in self.functions_desc:
func_names.append(func["function"]["name"])
# 打印当前支持的函数列表
logger.bind(tag=TAG).info(f"当前支持的函数列表: {func_names}")
self.conn.logger.bind(tag=TAG, session_id=self.conn.session_id).info(
f"当前支持的函数列表: {func_names}"
)
return func_names
def get_functions(self):
@@ -79,7 +80,9 @@ class FunctionHandler:
func = funcItem.func
arguments = function_call_data["arguments"]
arguments = json.loads(arguments) if arguments else {}
logger.bind(tag=TAG).debug(f"调用函数: {function_name}, 参数: {arguments}")
self.conn.logger.bind(tag=TAG).debug(
f"调用函数: {function_name}, 参数: {arguments}"
)
if (
funcItem.type == ToolType.SYSTEM_CTL
or funcItem.type == ToolType.IOT_CTL
@@ -94,6 +97,6 @@ class FunctionHandler:
action=Action.NOTFOUND, result="没有找到对应的函数", response=""
)
except Exception as e:
logger.bind(tag=TAG).error(f"处理function call错误: {e}")
self.conn.logger.bind(tag=TAG).error(f"处理function call错误: {e}")
return None
@@ -9,7 +9,6 @@ import random
import time
TAG = __name__
logger = setup_logging()
WAKEUP_CONFIG = {
"dir": "config/assets/",
@@ -44,7 +43,7 @@ async def checkWakeupWords(conn, text):
if file is None:
asyncio.create_task(wakeupWordsResponse(conn))
return False
opus_packets, duration = conn.tts.audio_to_opus_data(file)
opus_packets, _ = conn.tts.audio_to_opus_data(file)
text_hello = WAKEUP_CONFIG["text"]
if not text_hello:
text_hello = text
@@ -75,7 +74,7 @@ async def wakeupWordsResponse(conn):
await asyncio.sleep(1)
wait_max_time -= 1
if wait_max_time <= 0:
logger.bind(tag=TAG).error("连接对象没有llm")
conn.logger.bind(tag=TAG).error("连接对象没有llm")
return
"""唤醒词响应"""
@@ -5,10 +5,8 @@ from core.handle.sendAudioHandle import send_stt_message
from core.handle.helloHandle import checkWakeupWords
from core.utils.util import remove_punctuation_and_length
from core.utils.dialogue import Message
from loguru import logger
TAG = __name__
logger = setup_logging()
async def handle_user_intent(conn, text):
@@ -36,7 +34,7 @@ async def check_direct_exit(conn, text):
cmd_exit = conn.cmd_exit
for cmd in cmd_exit:
if text == cmd:
logger.bind(tag=TAG).info(f"识别到明确的退出命令: {text}")
conn.logger.bind(tag=TAG).info(f"识别到明确的退出命令: {text}")
await send_stt_message(conn, text)
await conn.close()
return True
@@ -46,7 +44,7 @@ async def check_direct_exit(conn, text):
async def analyze_intent_with_llm(conn, text):
"""使用LLM分析用户意图"""
if not hasattr(conn, "intent") or not conn.intent:
logger.bind(tag=TAG).warning("意图识别服务未初始化")
conn.logger.bind(tag=TAG).warning("意图识别服务未初始化")
return None
# 对话历史记录
@@ -55,7 +53,7 @@ async def analyze_intent_with_llm(conn, text):
intent_result = await conn.intent.detect_intent(conn, dialogue.dialogue, text)
return intent_result
except Exception as e:
logger.bind(tag=TAG).error(f"意图识别失败: {str(e)}")
conn.logger.bind(tag=TAG).error(f"意图识别失败: {str(e)}")
return None
@@ -69,7 +67,7 @@ async def process_intent_result(conn, intent_result, original_text):
# 检查是否有function_call
if "function_call" in intent_data:
# 直接从意图识别获取了function_call
logger.bind(tag=TAG).debug(
conn.logger.bind(tag=TAG).debug(
f"检测到function_call格式的意图结果: {intent_data['function_call']['name']}"
)
function_name = intent_data["function_call"]["name"]
@@ -118,7 +116,7 @@ async def process_intent_result(conn, intent_result, original_text):
conn.speak_and_play, text, text_index
)
conn.llm_finish_task = True
conn.tts_queue.put(future)
conn.tts_queue.put((future, text_index))
conn.dialogue.put(Message(role="assistant", content=text))
# 将函数执行放在线程池中
@@ -126,7 +124,7 @@ async def process_intent_result(conn, intent_result, original_text):
return True
return False
except json.JSONDecodeError as e:
logger.bind(tag=TAG).error(f"处理意图结果时出错: {e}")
conn.logger.bind(tag=TAG).error(f"处理意图结果时出错: {e}")
return False
+22 -19
View File
@@ -10,7 +10,6 @@ from plugins_func.register import (
)
TAG = __name__
logger = setup_logging()
def wrap_async_function(async_func):
@@ -21,7 +20,7 @@ def wrap_async_function(async_func):
# 获取连接对象(第一个参数)
conn = args[0]
if not hasattr(conn, "loop"):
logger.bind(tag=TAG).error("Connection对象没有loop属性")
conn.logger.bind(tag=TAG).error("Connection对象没有loop属性")
return ActionResponse(
Action.ERROR,
"Connection对象没有loop属性",
@@ -35,7 +34,7 @@ def wrap_async_function(async_func):
# 等待结果返回
return future.result()
except Exception as e:
logger.bind(tag=TAG).error(f"运行异步函数时出错: {e}")
conn.logger.bind(tag=TAG).error(f"运行异步函数时出错: {e}")
return ActionResponse(Action.ERROR, str(e), f"执行操作时出错: {e}")
return wrapper
@@ -57,7 +56,7 @@ def create_iot_function(device_name, method_name, method_info):
response_failure = "操作失败"
# 打印响应参数
logger.bind(tag=TAG).debug(
conn.logger.bind(tag=TAG).debug(
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
)
@@ -86,7 +85,9 @@ def create_iot_function(device_name, method_name, method_info):
return ActionResponse(Action.RESPONSE, result, response)
except Exception as e:
logger.bind(tag=TAG).error(f"执行{device_name}{method_name}操作失败: {e}")
conn.logger.bind(tag=TAG).error(
f"执行{device_name}{method_name}操作失败: {e}"
)
# 操作失败时使用大模型提供的失败响应
response = response_failure
@@ -104,7 +105,7 @@ def create_iot_query_function(device_name, prop_name, prop_info):
async def iot_query_function(conn, response_success=None, response_failure=None):
try:
# 打印响应参数
logger.bind(tag=TAG).info(
conn.logger.bind(tag=TAG).info(
f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
)
@@ -122,7 +123,9 @@ def create_iot_query_function(device_name, prop_name, prop_info):
return ActionResponse(Action.ERROR, f"属性{prop_name}不存在", response)
except Exception as e:
logger.bind(tag=TAG).error(f"查询{device_name}{prop_name}时出错: {e}")
conn.logger.bind(tag=TAG).error(
f"查询{device_name}{prop_name}时出错: {e}"
)
# 查询出错时使用大模型提供的失败响应
response = response_failure
@@ -280,7 +283,7 @@ async def handleIotDescriptors(conn, descriptors):
await asyncio.sleep(1)
wait_max_time -= 1
if wait_max_time <= 0:
logger.bind(tag=TAG).debug("连接对象没有func_handler")
conn.logger.bind(tag=TAG).debug("连接对象没有func_handler")
return
"""处理物联网描述"""
functions_changed = False
@@ -323,7 +326,7 @@ async def handleIotDescriptors(conn, descriptors):
if hasattr(conn, "func_handler"):
for func_name in device_functions:
conn.func_handler.function_registry.register_function(func_name)
logger.bind(tag=TAG).info(
conn.logger.bind(tag=TAG).info(
f"注册IOT函数到function handler: {func_name}"
)
functions_changed = True
@@ -332,8 +335,8 @@ async def handleIotDescriptors(conn, descriptors):
if functions_changed and hasattr(conn, "func_handler"):
conn.func_handler.upload_functions_desc()
func_names = conn.func_handler.current_support_functions()
logger.bind(tag=TAG).info(f"设备类型: {type_id}")
logger.bind(tag=TAG).info(
conn.logger.bind(tag=TAG).info(f"设备类型: {type_id}")
conn.logger.bind(tag=TAG).info(
f"更新function描述列表完成,当前支持的函数: {func_names}"
)
@@ -347,13 +350,13 @@ async def handleIotStatus(conn, states):
for k, v in state["state"].items():
if property_item["name"] == k:
if type(v) != type(property_item["value"]):
logger.bind(tag=TAG).error(
conn.logger.bind(tag=TAG).error(
f"属性{property_item['name']}的值类型不匹配"
)
break
else:
property_item["value"] = v
logger.bind(tag=TAG).info(
conn.logger.bind(tag=TAG).info(
f"物联网状态更新: {key} , {property_item['name']} = {v}"
)
break
@@ -367,7 +370,7 @@ async def get_iot_status(conn, name, property_name):
for property_item in value.properties:
if property_item["name"] == property_name:
return property_item["value"]
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
conn.logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
return None
@@ -378,16 +381,16 @@ async def set_iot_status(conn, name, property_name, value):
for property_item in iot_descriptor.properties:
if property_item["name"] == property_name:
if type(value) != type(property_item["value"]):
logger.bind(tag=TAG).error(
conn.logger.bind(tag=TAG).error(
f"属性{property_item['name']}的值类型不匹配"
)
return
property_item["value"] = value
logger.bind(tag=TAG).info(
conn.logger.bind(tag=TAG).info(
f"物联网状态更新: {name} , {property_name} = {value}"
)
return
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
conn.logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
async def send_iot_conn(conn, name, method_name, parameters):
@@ -409,6 +412,6 @@ async def send_iot_conn(conn, name, method_name, parameters):
command["parameters"] = parameters
send_message = json.dumps({"type": "iot", "commands": [command]})
await conn.websocket.send(send_message)
logger.bind(tag=TAG).info(f"发送物联网指令: {send_message}")
conn.logger.bind(tag=TAG).info(f"发送物联网指令: {send_message}")
return
logger.bind(tag=TAG).error(f"未找到方法{method_name}")
conn.logger.bind(tag=TAG).error(f"未找到方法{method_name}")
@@ -1,17 +1,20 @@
from config.logger import setup_logging
import time
import copy
from core.utils.util import remove_punctuation_and_length
from core.handle.sendAudioHandle import send_stt_message
from core.handle.intentHandler import handle_user_intent
from core.utils.output_counter import check_device_output_limit
from core.handle.ttsReportHandle import enqueue_tts_report
from core.providers.tts.base import audio_to_opus_data
TAG = __name__
logger = setup_logging()
async def handleAudioMessage(conn, audio):
if conn.vad is None:
return
if not conn.asr_server_receive:
logger.bind(tag=TAG).debug(f"前期数据处理中,暂停接收")
conn.logger.bind(tag=TAG).debug(f"前期数据处理中,暂停接收")
return
if conn.client_listen_mode == "auto":
have_voice = conn.vad.is_vad(conn, audio)
@@ -37,9 +40,12 @@ async def handleAudioMessage(conn, audio):
conn.asr_server_receive = True
else:
text, _ = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id)
logger.bind(tag=TAG).info(f"识别文本: {text}")
conn.logger.bind(tag=TAG).info(f"识别文本: {text}")
text_len, _ = remove_punctuation_and_length(text)
if text_len > 0:
# 使用自定义模块进行上报
enqueue_tts_report(conn, 1, text, copy.deepcopy(conn.asr_audio))
await startToChat(conn, text)
else:
conn.asr_server_receive = True
@@ -105,7 +111,7 @@ async def max_out_size(conn):
conn.tts_last_text_index = 0
conn.llm_finish_task = True
file_path = "config/assets/max_output_size.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(file_path)
opus_packets, _ = audio_to_opus_data(file_path)
conn.audio_play_queue.put((opus_packets, text, 0))
conn.close_after_chat = True
@@ -114,7 +120,7 @@ async def check_bind_device(conn):
if conn.bind_code:
# 确保bind_code是6位数字
if len(conn.bind_code) != 6:
logger.bind(tag=TAG).error(f"无效的绑定码格式: {conn.bind_code}")
conn.logger.bind(tag=TAG).error(f"无效的绑定码格式: {conn.bind_code}")
text = "绑定码格式错误,请检查配置。"
await send_stt_message(conn, text)
return
@@ -127,7 +133,7 @@ async def check_bind_device(conn):
# 播放提示音
music_path = "config/assets/bind_code.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(music_path)
opus_packets, _ = audio_to_opus_data(music_path)
conn.audio_play_queue.put((opus_packets, text, 0))
# 逐个播放数字
@@ -135,10 +141,10 @@ async def check_bind_device(conn):
try:
digit = conn.bind_code[i]
num_path = f"config/assets/bind_code/{digit}.wav"
num_packets, _ = conn.tts.audio_to_opus_data(num_path)
num_packets, _ = audio_to_opus_data(num_path)
conn.audio_play_queue.put((num_packets, None, i + 1))
except Exception as e:
logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
continue
else:
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
@@ -147,5 +153,5 @@ async def check_bind_device(conn):
conn.tts_last_text_index = 0
conn.llm_finish_task = True
music_path = "config/assets/bind_not_found.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(music_path)
opus_packets, _ = audio_to_opus_data(music_path)
conn.audio_play_queue.put((opus_packets, text, 0))
@@ -1,11 +1,9 @@
from config.logger import setup_logging
import json
import asyncio
import time
from core.utils.util import get_string_no_punctuation_or_emoji, analyze_emotion
TAG = __name__
logger = setup_logging()
emoji_map = {
"neutral": "😶",
@@ -49,11 +47,11 @@ async def sendAudioMessage(conn, audios, text, text_index=0):
)
if text_index == conn.tts_first_text_index:
logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
conn.logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
await send_tts_message(conn, "sentence_start", text)
# 播放音频
await sendAudio(conn, audios)
is_first_audio = text_index == conn.tts_first_text_index
await sendAudio(conn, audios, pre_buffer=is_first_audio)
await send_tts_message(conn, "sentence_end", text)
@@ -65,22 +63,32 @@ async def sendAudioMessage(conn, audios, text, text_index=0):
# 播放音频
async def sendAudio(conn, audios):
async def sendAudio(conn, audios, pre_buffer=True):
# 流控参数优化
frame_duration = 60 # 帧时长(毫秒),匹配 Opus 编码
start_time = time.perf_counter()
play_position = 0
last_reset_time = time.perf_counter() # 记录最后的重置时间
# 预缓冲:发送前 3 帧
pre_buffer = min(3, len(audios))
for i in range(pre_buffer):
await conn.websocket.send(audios[i])
# 仅当第一句话时执行预缓冲
if pre_buffer:
pre_buffer_frames = min(3, len(audios))
for i in range(pre_buffer_frames):
await conn.websocket.send(audios[i])
remaining_audios = audios[pre_buffer_frames:]
else:
remaining_audios = audios
# 正常播放剩余帧
for opus_packet in audios[pre_buffer:]:
# 播放剩余音频
for opus_packet in remaining_audios:
if conn.client_abort:
return
# 每分钟重置一次计时器
if time.perf_counter() - last_reset_time > 60:
await conn.reset_timeout()
last_reset_time = time.perf_counter()
# 计算预期发送时间
expected_time = start_time + (play_position / 1000)
current_time = time.perf_counter()
+79 -4
View File
@@ -1,4 +1,3 @@
from config.logger import setup_logging
import json
from core.handle.abortHandle import handleAbortMessage
from core.handle.helloHandle import handleHelloMessage
@@ -6,15 +5,15 @@ from core.utils.util import remove_punctuation_and_length
from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
from core.handle.sendAudioHandle import send_stt_message, send_tts_message
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
from core.handle.ttsReportHandle import enqueue_tts_report
import asyncio
TAG = __name__
logger = setup_logging()
async def handleTextMessage(conn, message):
"""处理文本消息"""
logger.bind(tag=TAG).info(f"收到文本消息:{message}")
conn.logger.bind(tag=TAG).info(f"收到文本消息:{message}")
try:
msg_json = json.loads(message)
if isinstance(msg_json, int):
@@ -27,7 +26,9 @@ async def handleTextMessage(conn, message):
elif msg_json["type"] == "listen":
if "mode" in msg_json:
conn.client_listen_mode = msg_json["mode"]
logger.bind(tag=TAG).debug(f"客户端拾音模式:{conn.client_listen_mode}")
conn.logger.bind(tag=TAG).debug(
f"客户端拾音模式:{conn.client_listen_mode}"
)
if msg_json["state"] == "start":
conn.client_have_voice = True
conn.client_voice_stop = False
@@ -53,7 +54,13 @@ async def handleTextMessage(conn, message):
# 如果是唤醒词,且关闭了唤醒词回复,就不用回答
await send_stt_message(conn, text)
await send_tts_message(conn, "stop", None)
elif is_wakeup_words:
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_tts_report(conn, 1, "嘿,你好呀", [])
await startToChat(conn, "嘿,你好呀")
else:
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_tts_report(conn, 1, text, [])
# 否则需要LLM对文字内容进行答复
await startToChat(conn, text)
elif msg_json["type"] == "iot":
@@ -61,5 +68,73 @@ async def handleTextMessage(conn, message):
asyncio.create_task(handleIotDescriptors(conn, msg_json["descriptors"]))
if "states" in msg_json:
asyncio.create_task(handleIotStatus(conn, msg_json["states"]))
elif msg_json["type"] == "server":
# 如果配置是从API读取的,则需要验证secret
if not conn.read_config_from_api:
return
# 获取post请求的secret
post_secret = msg_json.get("content", {}).get("secret", "")
secret = conn.config["manager-api"].get("secret", "")
# 如果secret不匹配,则返回
if post_secret != secret:
await conn.websocket.send(
json.dumps(
{
"type": "server",
"status": "error",
"message": "服务器密钥验证失败",
}
)
)
return
# 动态更新配置
if msg_json["action"] == "update_config":
try:
# 更新WebSocketServer的配置
if not conn.server:
await conn.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "error",
"message": "无法获取服务器实例",
}
)
)
return
if not await conn.server.update_config():
await conn.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "error",
"message": "更新服务器配置失败",
}
)
)
return
# 发送成功响应
await conn.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "success",
"message": "配置更新成功",
}
)
)
except Exception as e:
conn.logger.bind(tag=TAG).error(f"更新配置失败: {str(e)}")
await conn.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "error",
"message": f"更新配置失败: {str(e)}",
}
)
)
except json.JSONDecodeError:
await conn.websocket.send(message)
@@ -0,0 +1,110 @@
"""
TTS上报功能已集成到ConnectionHandler类中。
上报功能包括:
1. 每个连接对象拥有自己的上报队列和处理线程
2. 上报线程的生命周期与连接对象绑定
3. 使用ConnectionHandler.enqueue_tts_report方法进行上报
具体实现请参考core/connection.py中的相关代码。
"""
import opuslib_next
from config.manage_api_client import report
TAG = __name__
def report_tts(conn, type, text, opus_data):
"""执行TTS上报操作
Args:
conn: 连接对象
type: 上报类型,1为用户,2为智能体
text: 合成文本
opus_data: opus音频数据
"""
try:
if opus_data:
audio_data = opus_to_wav(conn, opus_data)
else:
audio_data = None
# 执行上报
report(
mac_address=conn.device_id,
session_id=conn.session_id,
chat_type=type,
content=text,
audio=audio_data,
)
except Exception as e:
conn.logger.bind(tag=TAG).error(f"TTS上报失败: {e}")
def opus_to_wav(conn, opus_data):
"""将Opus数据转换为WAV格式的字节流
Args:
output_dir: 输出目录(保留参数以保持接口兼容)
opus_data: opus音频数据
Returns:
bytes: WAV格式的音频数据
"""
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:
conn.logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
if not pcm_data:
raise ValueError("没有有效的PCM数据")
# 创建WAV文件头
pcm_data_bytes = b"".join(pcm_data)
num_samples = len(pcm_data_bytes) // 2 # 16-bit samples
# WAV文件头
wav_header = bytearray()
wav_header.extend(b"RIFF") # ChunkID
wav_header.extend((36 + len(pcm_data_bytes)).to_bytes(4, "little")) # ChunkSize
wav_header.extend(b"WAVE") # Format
wav_header.extend(b"fmt ") # Subchunk1ID
wav_header.extend((16).to_bytes(4, "little")) # Subchunk1Size
wav_header.extend((1).to_bytes(2, "little")) # AudioFormat (PCM)
wav_header.extend((1).to_bytes(2, "little")) # NumChannels
wav_header.extend((16000).to_bytes(4, "little")) # SampleRate
wav_header.extend((32000).to_bytes(4, "little")) # ByteRate
wav_header.extend((2).to_bytes(2, "little")) # BlockAlign
wav_header.extend((16).to_bytes(2, "little")) # BitsPerSample
wav_header.extend(b"data") # Subchunk2ID
wav_header.extend(len(pcm_data_bytes).to_bytes(4, "little")) # Subchunk2Size
# 返回完整的WAV数据
return bytes(wav_header) + pcm_data_bytes
def enqueue_tts_report(conn, type, text, opus_data):
if not conn.read_config_from_api or conn.need_bind:
return
"""将TTS数据加入上报队列
Args:
conn: 连接对象
text: 合成文本
opus_data: opus音频数据
"""
try:
# 使用连接对象的队列,传入文本和二进制数据而非文件路径
conn.tts_report_queue.put((type, text, opus_data))
conn.logger.bind(tag=TAG).debug(
f"TTS数据已加入上报队列: {conn.device_id}, 音频大小: {len(opus_data)} "
)
except Exception as e:
conn.logger.bind(tag=TAG).error(f"加入TTS上报队列失败: {text}, {e}")
+108 -69
View File
@@ -1,86 +1,125 @@
from __future__ import annotations
from datetime import timedelta
from typing import Optional
import asyncio, os, shutil, concurrent.futures
from contextlib import AsyncExitStack
import os, shutil
from typing import Optional, List, Dict, Any
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.sse import sse_client
from config.logger import setup_logging
TAG = __name__
class MCPClient:
def __init__(self, config):
# Initialize session and client objects
self.session: Optional[ClientSession] = None
self.exit_stack = AsyncExitStack()
def __init__(self, config: Dict[str, Any]):
self.logger = setup_logging()
self.config = config
self.tolls = []
self._worker_task: Optional[asyncio.Task] = None
self._ready_evt = asyncio.Event()
self._shutdown_evt = asyncio.Event()
self.session: Optional[ClientSession] = None
self.tools: List = []
async def initialize(self):
args = self.config.get("args", [])
if self._worker_task:
return
self._worker_task = asyncio.create_task(self._worker(), name="MCPClientWorker")
await self._ready_evt.wait()
command = (
shutil.which("npx")
if self.config["command"] == "npx"
else self.config["command"]
self.logger.bind(tag=TAG).info(
f"Connected, tools = {[t.name for t in self.tools]}"
)
env={**os.environ}
if self.config.get("env"):
env.update(self.config["env"])
server_params = StdioServerParameters(
command=command,
args=args,
env=env
)
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
self.stdio, self.write = stdio_transport
time_out_delta = timedelta(seconds=15)
self.session = await self.exit_stack.enter_async_context(ClientSession(read_stream=self.stdio, write_stream=self.write, read_timeout_seconds=time_out_delta))
await self.session.initialize()
# List available tools
response = await self.session.list_tools()
tools = response.tools
self.tools = tools
self.logger.bind(tag=TAG).info(f"Connected to server with tools:{[tool.name for tool in tools]}")
def has_tool(self, tool_name):
return any(tool.name == tool_name for tool in self.tools)
def get_available_tools(self):
available_tools = [{"type": "function", "function":{
"name": tool.name,
"description": tool.description,
"parameters": tool.inputSchema
} } for tool in self.tools]
return available_tools
async def call_tool(self, tool_name: str, tool_args: dict):
self.logger.bind(tag=TAG).info(f"MCPClient Calling tool {tool_name} with args: {tool_args}")
try:
response = await self.session.call_tool(tool_name, tool_args)
except Exception as e:
self.logger.bind(tag=TAG).error(f"Error calling tool {tool_name}: {e}")
from types import SimpleNamespace
error_content = SimpleNamespace(
type='text',
text=f"Error calling tool {tool_name}: {e}"
)
error_response = SimpleNamespace(
content=[error_content],
isError=True
)
return error_response
self.logger.bind(tag=TAG).info(f"MCPClient Response from tool {tool_name}: {response}")
return response
async def cleanup(self):
"""Clean up resources"""
await self.exit_stack.aclose()
if not self._worker_task:
return
self._shutdown_evt.set()
try:
await asyncio.wait_for(self._worker_task, timeout=20)
except (asyncio.TimeoutError, Exception) as e:
self.logger.bind(tag=TAG).error(f"worker shutdown err: {e}")
finally:
self._worker_task = None
def has_tool(self, name: str) -> bool:
return any(t.name == name for t in self.tools)
def get_available_tools(self):
return [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.inputSchema,
},
}
for t in self.tools
]
async def call_tool(self, name: str, args: dict):
if not self.session:
raise RuntimeError("MCPClient not initialized")
loop = self._worker_task.get_loop()
coro = self.session.call_tool(name, args)
if loop is asyncio.get_running_loop():
return await coro
fut: concurrent.futures.Future = asyncio.run_coroutine_threadsafe(coro, loop)
return await asyncio.wrap_future(fut)
async def _worker(self):
async with AsyncExitStack() as stack:
try:
# 建立 StdioClient
if "command" in self.config:
cmd = (
shutil.which("npx")
if self.config["command"] == "npx"
else self.config["command"]
)
env = {**os.environ, **self.config.get("env", {})}
params = StdioServerParameters(
command=cmd,
args=self.config.get("args", []),
env=env,
)
stdio_r, stdio_w = await stack.enter_async_context(stdio_client(params))
read_stream, write_stream = stdio_r, stdio_w
# 建立SSEClient
elif "url" in self.config:
sse_r, sse_w = await stack.enter_async_context(sse_client(self.config["url"]))
read_stream, write_stream = sse_r, sse_w
else:
raise ValueError("MCPClient config must include 'command' or 'url'")
self.session = await stack.enter_async_context(
ClientSession(
read_stream=read_stream,
write_stream=write_stream,
read_timeout_seconds=timedelta(seconds=15),
)
)
await self.session.initialize()
# 获取工具
self.tools = (await self.session.list_tools()).tools
self._ready_evt.set()
# 挂起等待关闭
await self._shutdown_evt.wait()
except Exception as e:
self.logger.bind(tag=TAG).error(f"worker error: {e}")
self._ready_evt.set()
raise
+16 -16
View File
@@ -1,9 +1,9 @@
"""MCP服务管理器"""
import asyncio
import os, json
from typing import Dict, Any, List
from .MCPClient import MCPClient
from config.logger import setup_logging
from plugins_func.register import register_function, ToolType
from config.config_loader import get_project_dir
@@ -18,11 +18,10 @@ class MCPManager:
初始化MCP管理器
"""
self.conn = conn
self.logger = setup_logging()
self.config_path = get_project_dir() + "data/.mcp_server_settings.json"
if os.path.exists(self.config_path) == False:
self.config_path = ""
self.logger.bind(tag=TAG).warning(
self.conn.logger.bind(tag=TAG).warning(
f"请检查mcp服务配置文件:data/.mcp_server_settings.json"
)
self.client: Dict[str, MCPClient] = {}
@@ -41,7 +40,7 @@ class MCPManager:
config = json.load(f)
return config.get("mcpServers", {})
except Exception as e:
self.logger.bind(tag=TAG).error(
self.conn.logger.bind(tag=TAG).error(
f"Error loading MCP config from {self.config_path}: {e}"
)
return {}
@@ -50,9 +49,9 @@ class MCPManager:
"""初始化所有MCP服务"""
config = self.load_config()
for name, srv_config in config.items():
if not srv_config.get("command"):
self.logger.bind(tag=TAG).warning(
f"Skipping server {name}: command not specified"
if not srv_config.get("command") and not srv_config.get("url"):
self.conn.logger.bind(tag=TAG).warning(
f"Skipping server {name}: neither command nor url specified"
)
continue
@@ -60,7 +59,7 @@ class MCPManager:
client = MCPClient(srv_config)
await client.initialize()
self.client[name] = client
self.logger.bind(tag=TAG).info(f"Initialized MCP client: {name}")
self.conn.logger.bind(tag=TAG).info(f"Initialized MCP client: {name}")
client_tools = client.get_available_tools()
self.tools.extend(client_tools)
for tool in client_tools:
@@ -73,7 +72,7 @@ class MCPManager:
)
except Exception as e:
self.logger.bind(tag=TAG).error(
self.conn.logger.bind(tag=TAG).error(
f"Failed to initialize MCP server {name}: {e}"
)
self.conn.func_handler.upload_functions_desc()
@@ -110,7 +109,7 @@ class MCPManager:
Raises:
ValueError: 工具未找到时抛出
"""
self.logger.bind(tag=TAG).info(
self.conn.logger.bind(tag=TAG).info(
f"Executing tool {tool_name} with arguments: {arguments}"
)
for client in self.client.values():
@@ -120,12 +119,13 @@ class MCPManager:
raise ValueError(f"Tool {tool_name} not found in any MCP server")
async def cleanup_all(self) -> None:
for name, client in self.client.items():
"""依次关闭所有 MCPClient,不让异常阻断整体流程。"""
for name, client in list(self.client.items()):
try:
await client.cleanup()
self.logger.bind(tag=TAG).info(f"Cleaned up MCP client: {name}")
except Exception as e:
self.logger.bind(tag=TAG).error(
f"Error cleaning up MCP client {name}: {e}"
await asyncio.wait_for(client.cleanup(), timeout=20)
self.conn.logger.bind(tag=TAG).info(f"MCP client closed: {name}")
except (asyncio.TimeoutError, Exception) as e:
self.conn.logger.bind(tag=TAG).error(
f"Error closing MCP client {name}: {e}"
)
self.client.clear()

Some files were not shown because too many files have changed in this diff Show More