mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 23:23:55 +08:00
Merge branch 'mangger-api-voice-print' into py_test
This commit is contained in:
@@ -116,6 +116,11 @@ public interface Constant {
|
||||
*/
|
||||
String SERVER_MCP_ENDPOINT = "server.mcp_endpoint";
|
||||
|
||||
/**
|
||||
* mcp接入点路径
|
||||
*/
|
||||
String SERVER_VOICE_PRINT = "server.voice_print";
|
||||
|
||||
/**
|
||||
* 无记忆
|
||||
*/
|
||||
@@ -232,7 +237,7 @@ public interface Constant {
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
public static final String VERSION = "0.6.2";
|
||||
public static final String VERSION = "0.6.3";
|
||||
|
||||
/**
|
||||
* 无效固件URL
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package xiaozhi.modules.agent.Enums;
|
||||
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 智能体聊天记录类型
|
||||
*/
|
||||
@Getter
|
||||
public enum AgentChatHistoryType {
|
||||
|
||||
USER((byte) 1),
|
||||
AGENT((byte) 2);
|
||||
|
||||
private final byte value;
|
||||
|
||||
AgentChatHistoryType(byte i) {
|
||||
this.value = i;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||
import xiaozhi.modules.agent.service.AgentPluginMappingService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
|
||||
import xiaozhi.modules.agent.vo.AgentInfoVO;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
@@ -181,6 +182,23 @@ public class AgentController {
|
||||
List<AgentChatHistoryDTO> result = agentChatHistoryService.getChatHistoryBySessionId(id, sessionId);
|
||||
return new Result<List<AgentChatHistoryDTO>>().ok(result);
|
||||
}
|
||||
@GetMapping("/{id}/chat-history/user")
|
||||
@Operation(summary = "获取智能体聊天记录(用户)")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<List<AgentChatHistoryUserVO>> getRecentlyFiftyByAgentId(
|
||||
@PathVariable("id") String id) {
|
||||
// 获取当前用户
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
|
||||
// 检查权限
|
||||
if (!agentService.checkAgentPermission(id, user.getId())) {
|
||||
return new Result<List<AgentChatHistoryUserVO>>().error("没有权限查看该智能体的聊天记录");
|
||||
}
|
||||
|
||||
// 查询聊天记录
|
||||
List<AgentChatHistoryUserVO> data = agentChatHistoryService.getRecentlyFiftyByAgentId(id);
|
||||
return new Result<List<AgentChatHistoryUserVO>>().ok(data);
|
||||
}
|
||||
|
||||
@PostMapping("/audio/{audioId}")
|
||||
@Operation(summary = "获取音频下载ID")
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package xiaozhi.modules.agent.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.modules.agent.dto.AgentVoicePrintSaveDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentVoicePrintUpdateDTO;
|
||||
import xiaozhi.modules.agent.service.AgentVoicePrintService;
|
||||
import xiaozhi.modules.agent.vo.AgentVoicePrintVO;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Tag(name = "智能体声纹管理")
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/agent/voice-print")
|
||||
public class AgentVoicePrintController {
|
||||
private final AgentVoicePrintService agentVoicePrintService;
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "创建智能体的声纹")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> save(@RequestBody @Valid AgentVoicePrintSaveDTO dto) {
|
||||
boolean b = agentVoicePrintService.insert(dto);
|
||||
if (b) {
|
||||
return new Result<>();
|
||||
}
|
||||
return new Result<Void>().error("智能体的声纹创建失败");
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
@Operation(summary = "更新智能体的对应声纹")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> update(@RequestBody @Valid AgentVoicePrintUpdateDTO dto) {
|
||||
Long userId = SecurityUser.getUserId();
|
||||
boolean b = agentVoicePrintService.update(userId, dto);
|
||||
if (b) {
|
||||
return new Result<>();
|
||||
}
|
||||
return new Result<Void>().error("智能体的对应声纹更新失败");
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除智能体对应声纹")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> delete(@PathVariable String id) {
|
||||
Long userId = SecurityUser.getUserId();
|
||||
// 先删除关联的设备
|
||||
boolean delete = agentVoicePrintService.delete(userId, id);
|
||||
if (delete) {
|
||||
return new Result<>();
|
||||
}
|
||||
return new Result<Void>().error("智能体的对应声纹删除失败");
|
||||
}
|
||||
|
||||
@GetMapping("/list/{id}")
|
||||
@Operation(summary = "获取用户指定智能体声纹列表")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<List<AgentVoicePrintVO>> list(@PathVariable String id) {
|
||||
Long userId = SecurityUser.getUserId();
|
||||
List<AgentVoicePrintVO> list = agentVoicePrintService.list(userId, id);
|
||||
return new Result<List<AgentVoicePrintVO>>().ok(list);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package xiaozhi.modules.agent.dao;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
||||
import xiaozhi.modules.agent.entity.AgentVoicePrintEntity;
|
||||
|
||||
/**
|
||||
* {@link AgentChatHistoryEntity} 智能体聊天历史记录Dao对象
|
||||
*
|
||||
* @author Goody
|
||||
* @version 1.0, 2025/4/30
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Mapper
|
||||
public interface AgentVoicePrintDao extends BaseMapper<AgentVoicePrintEntity> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package xiaozhi.modules.agent.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 保存智能体声纹的dto
|
||||
*
|
||||
* @author zjy
|
||||
*/
|
||||
@Data
|
||||
public class AgentVoicePrintSaveDTO {
|
||||
/**
|
||||
* 关联的智能体id
|
||||
*/
|
||||
private String agentId;
|
||||
/**
|
||||
* 音频文件id
|
||||
*/
|
||||
private String audioId;
|
||||
/**
|
||||
* 声纹来源的人姓名
|
||||
*/
|
||||
private String sourceName;
|
||||
/**
|
||||
* 描述声纹来源的人
|
||||
*/
|
||||
private String introduce;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package xiaozhi.modules.agent.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 修改智能体声纹的dto
|
||||
*
|
||||
* @author zjy
|
||||
*/
|
||||
@Data
|
||||
public class AgentVoicePrintUpdateDTO {
|
||||
/**
|
||||
* 智能体声纹id
|
||||
*/
|
||||
private String id;
|
||||
/**
|
||||
* 音频文件id
|
||||
*/
|
||||
private String audioId;
|
||||
/**
|
||||
* 声纹来源的人姓名
|
||||
*/
|
||||
private String sourceName;
|
||||
/**
|
||||
* 描述声纹来源的人
|
||||
*/
|
||||
private String introduce;
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package xiaozhi.modules.agent.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import xiaozhi.common.entity.BaseEntity;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 智能体声纹表
|
||||
*
|
||||
* @author zjy
|
||||
*/
|
||||
@TableName(value = "ai_agent_voice_print")
|
||||
@Data
|
||||
public class AgentVoicePrintEntity {
|
||||
/**
|
||||
* 主键id
|
||||
*/
|
||||
@TableId(type = IdType.ASSIGN_UUID)
|
||||
private String id;
|
||||
/**
|
||||
* 关联的智能体id
|
||||
*/
|
||||
private String agentId;
|
||||
/**
|
||||
* 关联的音频id
|
||||
*/
|
||||
private String audioId;
|
||||
/**
|
||||
* 声纹来源的人姓名
|
||||
*/
|
||||
private String sourceName;
|
||||
/**
|
||||
* 描述声纹来源的人
|
||||
*/
|
||||
private String introduce;
|
||||
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private Long creator;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private Date createDate;
|
||||
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private Long updater;
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private Date updateDate;
|
||||
}
|
||||
+9
@@ -9,6 +9,7 @@ import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
||||
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
|
||||
|
||||
/**
|
||||
* 智能体聊天记录表处理service
|
||||
@@ -44,4 +45,12 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
|
||||
* @param deleteText 是否删除文本
|
||||
*/
|
||||
void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText);
|
||||
|
||||
/**
|
||||
* 根据智能体ID获取最近50条用户的聊天记录数据
|
||||
*
|
||||
* @param agentId 查询参数,包含agentId、page、limit
|
||||
* @return 聊天记录列表(只有用户)
|
||||
*/
|
||||
List<AgentChatHistoryUserVO> getRecentlyFiftyByAgentId(String agentId);
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package xiaozhi.modules.agent.service;
|
||||
|
||||
|
||||
import xiaozhi.modules.agent.dto.AgentVoicePrintSaveDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentVoicePrintUpdateDTO;
|
||||
import xiaozhi.modules.agent.vo.AgentVoicePrintVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 智能体声纹处理service
|
||||
*
|
||||
* @author zjy
|
||||
*/
|
||||
public interface AgentVoicePrintService {
|
||||
/**
|
||||
* 添加智能体新的声纹
|
||||
*
|
||||
* @param dto 保存智能体声纹的数据
|
||||
* @return T:成功 F:失败
|
||||
*/
|
||||
boolean insert(AgentVoicePrintSaveDTO dto);
|
||||
|
||||
/**
|
||||
* 删除智能体的指的声纹
|
||||
*
|
||||
* @param userId 当前登录的用户id
|
||||
* @param voicePrintId 声纹id
|
||||
* @return 是否成功 T:成功 F:失败
|
||||
*/
|
||||
boolean delete(Long userId, String voicePrintId);
|
||||
|
||||
/**
|
||||
* 获取指定智能体的所有声纹数据
|
||||
*
|
||||
* @param userId 当前登录的用户id
|
||||
* @param agentId 智能体id
|
||||
* @return 声纹数据集合
|
||||
*/
|
||||
List<AgentVoicePrintVO> list(Long userId, String agentId);
|
||||
|
||||
/**
|
||||
* 更新智能体的指的声纹数据
|
||||
*
|
||||
* @param userId 当前登录的用户id
|
||||
* @param dto 修改的声纹的数据
|
||||
* @return 是否成功 T:成功 F:失败
|
||||
*/
|
||||
boolean update(Long userId, AgentVoicePrintUpdateDTO dto);
|
||||
|
||||
|
||||
|
||||
}
|
||||
+20
@@ -5,6 +5,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -16,11 +17,13 @@ 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.Enums.AgentChatHistoryType;
|
||||
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;
|
||||
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
|
||||
|
||||
/**
|
||||
* 智能体聊天记录表处理service {@link AgentChatHistoryService} impl
|
||||
@@ -90,4 +93,21 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AgentChatHistoryUserVO> getRecentlyFiftyByAgentId(String agentId) {
|
||||
// 构建查询条件(不添加按照创建时间排序,数据本来就是主键越大创建时间越大
|
||||
// 不添加这样可以减少排序全部数据在分页的全盘扫描消耗)
|
||||
LambdaQueryWrapper<AgentChatHistoryEntity> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.select(AgentChatHistoryEntity::getContent,AgentChatHistoryEntity::getAudioId)
|
||||
.eq(AgentChatHistoryEntity::getAgentId, agentId)
|
||||
.eq(AgentChatHistoryEntity::getChatType, AgentChatHistoryType.USER.getValue());
|
||||
|
||||
// 构建分页查询,查询前50页数据
|
||||
Page<AgentChatHistoryEntity> pageParam = new Page<>(0, 50);
|
||||
IPage<AgentChatHistoryEntity> result = this.baseMapper.selectPage(pageParam, wrapper);
|
||||
return result.getRecords().stream().map( item ->
|
||||
ConvertUtils.sourceToTarget(item,AgentChatHistoryUserVO.class)
|
||||
).toList();
|
||||
}
|
||||
}
|
||||
|
||||
+69
-30
@@ -61,14 +61,15 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
||||
wsUrl = wsUrl.replace("/mcp/", "/call/");
|
||||
|
||||
try {
|
||||
// 创建 WebSocket 连接
|
||||
// 创建 WebSocket 连接,增加超时时间到15秒
|
||||
try (WebSocketClientManager client = WebSocketClientManager.build(
|
||||
new WebSocketClientManager.Builder()
|
||||
.uri(wsUrl)
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.maxSessionDuration(9, TimeUnit.SECONDS))) {
|
||||
.connectTimeout(8, TimeUnit.SECONDS)
|
||||
.maxSessionDuration(10, TimeUnit.SECONDS))) {
|
||||
|
||||
// 发送初始化消息
|
||||
// 步骤1: 发送初始化消息并等待响应
|
||||
log.info("发送MCP初始化消息,智能体ID: {}", id);
|
||||
McpJsonRpcRequest initializeRequest = new McpJsonRpcRequest("initialize",
|
||||
Map.of(
|
||||
"protocolVersion", "2024-11-05",
|
||||
@@ -81,37 +82,70 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
||||
1);
|
||||
client.sendJson(initializeRequest);
|
||||
|
||||
// 等待初始化响应
|
||||
Thread.sleep(200);
|
||||
|
||||
// 发送初始化完成通知
|
||||
// 对于通知类型的消息,手动构建JSON以避免包含null字段
|
||||
String notificationJson = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}";
|
||||
client.sendText(notificationJson);
|
||||
|
||||
// 等待 0.2 秒
|
||||
Thread.sleep(200);
|
||||
|
||||
// 发送工具列表请求
|
||||
McpJsonRpcRequest toolsRequest = new McpJsonRpcRequest("tools/list", null, 2);
|
||||
client.sendJson(toolsRequest);
|
||||
|
||||
// 监听响应,直到收到包含 id=2 的响应(tools/list响应)
|
||||
List<String> responses = client.listener(response -> {
|
||||
// 等待初始化响应 (id=1) - 移除固定延迟,改为响应驱动
|
||||
List<String> initResponses = client.listenerWithoutClose(response -> {
|
||||
try {
|
||||
// 先尝试解析为通用JSON对象来获取id
|
||||
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
|
||||
return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"));
|
||||
if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
|
||||
// 检查是否有result字段,表示初始化成功
|
||||
return jsonMap.containsKey("result") && !jsonMap.containsKey("error");
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.warn("解析响应失败: {}", response, e);
|
||||
log.warn("解析初始化响应失败: {}", response, e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 处理响应
|
||||
for (String response : responses) {
|
||||
// 验证初始化响应
|
||||
boolean initSucceeded = false;
|
||||
for (String response : initResponses) {
|
||||
try {
|
||||
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
|
||||
if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
|
||||
if (jsonMap.containsKey("result")) {
|
||||
log.info("MCP初始化成功,智能体ID: {}", id);
|
||||
initSucceeded = true;
|
||||
break;
|
||||
} else if (jsonMap.containsKey("error")) {
|
||||
log.error("MCP初始化失败,智能体ID: {}, 错误: {}", id, jsonMap.get("error"));
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("处理初始化响应失败: {}", response, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!initSucceeded) {
|
||||
log.error("未收到有效的MCP初始化响应,智能体ID: {}", id);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// 步骤2: 发送初始化完成通知 - 只有在收到initialize响应后才发送
|
||||
log.info("发送MCP初始化完成通知,智能体ID: {}", id);
|
||||
String notificationJson = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}";
|
||||
client.sendText(notificationJson);
|
||||
|
||||
// 步骤3: 发送工具列表请求 - 立即发送,无需额外延迟
|
||||
log.info("发送MCP工具列表请求,智能体ID: {}", id);
|
||||
McpJsonRpcRequest toolsRequest = new McpJsonRpcRequest("tools/list", null, 2);
|
||||
client.sendJson(toolsRequest);
|
||||
|
||||
// 等待工具列表响应 (id=2)
|
||||
List<String> toolsResponses = client.listener(response -> {
|
||||
try {
|
||||
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
|
||||
return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"));
|
||||
} catch (Exception e) {
|
||||
log.warn("解析工具列表响应失败: {}", response, e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 处理工具列表响应
|
||||
for (String response : toolsResponses) {
|
||||
try {
|
||||
// 先解析为通用JSON对象
|
||||
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
|
||||
if (jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"))) {
|
||||
// 检查是否有result字段
|
||||
@@ -122,11 +156,16 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
||||
if (toolsObj instanceof List) {
|
||||
List<Map<String, Object>> toolsList = (List<Map<String, Object>>) toolsObj;
|
||||
// 提取工具名称列表
|
||||
return toolsList.stream()
|
||||
List<String> result = toolsList.stream()
|
||||
.map(tool -> (String) tool.get("name"))
|
||||
.filter(name -> name != null)
|
||||
.collect(Collectors.toList());
|
||||
log.info("成功获取MCP工具列表,智能体ID: {}, 工具数量: {}", id, result.size());
|
||||
return result;
|
||||
}
|
||||
} else if (jsonMap.containsKey("error")) {
|
||||
log.error("获取工具列表失败,智能体ID: {}, 错误: {}", id, jsonMap.get("error"));
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -134,7 +173,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("未找到有效的工具列表响应");
|
||||
log.warn("未找到有效的工具列表响应,智能体ID: {}", id);
|
||||
return List.of();
|
||||
|
||||
}
|
||||
@@ -204,4 +243,4 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
||||
// 加密后成token值
|
||||
return AESUtils.encrypt(key, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
package xiaozhi.modules.agent.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.modules.agent.dao.AgentVoicePrintDao;
|
||||
import xiaozhi.modules.agent.dto.AgentVoicePrintSaveDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentVoicePrintUpdateDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentVoicePrintEntity;
|
||||
import xiaozhi.modules.agent.service.AgentChatAudioService;
|
||||
import xiaozhi.modules.agent.service.AgentVoicePrintService;
|
||||
import xiaozhi.modules.agent.vo.AgentVoicePrintVO;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
|
||||
import java.beans.Transient;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author zjy
|
||||
*/
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
@Slf4j
|
||||
public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao, AgentVoicePrintEntity>
|
||||
implements AgentVoicePrintService {
|
||||
private final AgentChatAudioService agentChatAudioService;
|
||||
private final RestTemplate restTemplate;
|
||||
private final SysParamsService sysParamsService;
|
||||
// Springboot提供的编程事务类
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public boolean insert(AgentVoicePrintSaveDTO dto) {
|
||||
// 获取音频Id
|
||||
String audioId = dto.getAudioId();
|
||||
ByteArrayResource resource = getVoicePrintAudioWAV(audioId);
|
||||
|
||||
AgentVoicePrintEntity entity = ConvertUtils.sourceToTarget(dto, AgentVoicePrintEntity.class);
|
||||
// 开启事务
|
||||
return Boolean.TRUE.equals(transactionTemplate.execute(status -> {
|
||||
try {
|
||||
// 保存声纹信息
|
||||
int row = baseMapper.insert(entity);
|
||||
// 插入一条数据,影响的数据不等于1说明出现了,保存问题回滚
|
||||
if (row != 1) {
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
return false;
|
||||
}
|
||||
// 发送注册声纹请求
|
||||
registerVoicePrint(entity.getId(), resource);
|
||||
return true;
|
||||
} catch (RenException e) {
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
log.error("保存声纹错误原因:{}",e.getMessage());
|
||||
throw new RenException("保存声纹错误,请联系管理员");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(Long userId, String voicePrintId) {
|
||||
// 开启事务
|
||||
return Boolean.TRUE.equals(transactionTemplate.execute(status -> {
|
||||
try {
|
||||
// 删除声纹,按照指定当前登录用户和智能体
|
||||
int row = baseMapper.delete(new LambdaQueryWrapper<AgentVoicePrintEntity>()
|
||||
.eq(AgentVoicePrintEntity::getId,voicePrintId)
|
||||
.eq(AgentVoicePrintEntity::getCreator,userId));
|
||||
if(row != 1){
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
return false;
|
||||
}
|
||||
cancelVoicePrint(voicePrintId);
|
||||
return true;
|
||||
} catch (RenException e) {
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
log.error("删除声纹错误原因:{}",e.getMessage());
|
||||
throw new RenException("删除声纹错误,请联系管理员");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public List<AgentVoicePrintVO> list(Long userId, String agentId) {
|
||||
// 按照指定当前登录用户和智能体查找数据
|
||||
List<AgentVoicePrintEntity> list = baseMapper.selectList(new LambdaQueryWrapper<AgentVoicePrintEntity>()
|
||||
.eq(AgentVoicePrintEntity::getAgentId, agentId)
|
||||
.eq(AgentVoicePrintEntity::getCreator, userId));
|
||||
return list.stream().map(entity -> {
|
||||
// 遍历转换成AgentVoicePrintVO类型
|
||||
return ConvertUtils.sourceToTarget(entity, AgentVoicePrintVO.class);
|
||||
}).toList();
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean update(Long userId, AgentVoicePrintUpdateDTO dto) {
|
||||
Long l = baseMapper.selectCount(new LambdaQueryWrapper<AgentVoicePrintEntity>()
|
||||
.eq(AgentVoicePrintEntity::getId, dto.getId())
|
||||
.eq(AgentVoicePrintEntity::getCreator, userId));
|
||||
if (l != 1) {
|
||||
return false;
|
||||
}
|
||||
// 获取音频Id
|
||||
String audioId = dto.getAudioId();
|
||||
// 如果有新的音频
|
||||
ByteArrayResource resource;
|
||||
if (!StringUtils.isEmpty(audioId)) {
|
||||
resource = getVoicePrintAudioWAV(audioId);
|
||||
} else {
|
||||
resource = null;
|
||||
}
|
||||
// 开启事务
|
||||
return Boolean.TRUE.equals(transactionTemplate.execute(status -> {
|
||||
try {
|
||||
AgentVoicePrintEntity entity = ConvertUtils.sourceToTarget(dto, AgentVoicePrintEntity.class);
|
||||
int row = baseMapper.updateById(entity);
|
||||
if (row != 1){
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
return false;
|
||||
}
|
||||
if(resource != null){
|
||||
String id = entity.getId();
|
||||
// 先注销之前这个声纹id上的声纹向量
|
||||
cancelVoicePrint(id);
|
||||
// 发送注册声纹请求
|
||||
registerVoicePrint(id, resource);
|
||||
}
|
||||
return true;
|
||||
} catch (RenException e) {
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
status.setRollbackOnly(); // 标记事务回滚
|
||||
log.error("修改声纹错误原因:{}",e.getMessage());
|
||||
throw new RenException("修改声纹错误,请联系管理员");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取生纹接口URI对象
|
||||
*
|
||||
* @return URI对象
|
||||
*/
|
||||
private URI getVoicePrintURI() {
|
||||
// 获取声纹接口地址
|
||||
String voicePrint = sysParamsService.getValue(Constant.SERVER_VOICE_PRINT, true);
|
||||
try {
|
||||
return new URI(voicePrint);
|
||||
} catch (URISyntaxException e) {
|
||||
log.error("路径格式不正确路径:{},\n错误信息:{}", voicePrint, e.getMessage());
|
||||
throw new RuntimeException("声纹接口的地址存在错误,请进入参数管理修改声纹接口地址");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取声纹地址基础路径
|
||||
* @param uri 声纹地址uri
|
||||
* @return 基础路径
|
||||
*/
|
||||
private String getBaseUrl(URI uri) {
|
||||
String protocol = uri.getScheme();
|
||||
String host = uri.getHost();
|
||||
int port = uri.getPort();
|
||||
return "%s://%s:%s".formatted(protocol,host,port);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证Authorization
|
||||
*
|
||||
* @param uri 声纹地址uri
|
||||
* @return Authorization值
|
||||
*/
|
||||
private String getAuthorization(URI uri) {
|
||||
// 获取参数
|
||||
String query = uri.getQuery();
|
||||
// 获取aes加密密钥
|
||||
String str = "key=";
|
||||
return "Bearer " + query.substring(query.indexOf(str) + str.length());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取声纹音频资源数据
|
||||
* @param audioId 音频Id
|
||||
* @return 声纹音频资源数据
|
||||
*/
|
||||
private ByteArrayResource getVoicePrintAudioWAV(String audioId) {
|
||||
// 获取到音频数据
|
||||
byte[] audio = agentChatAudioService.getAudio(audioId);
|
||||
// 如果音频数据为空的直接报错不进行下去
|
||||
if (audio == null || audio.length == 0) {
|
||||
throw new RenException("音频数据是空的请检查上传数据");
|
||||
}
|
||||
// 将字节数组包装为资源,返回
|
||||
return new ByteArrayResource(audio) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return "VoicePrint.WAV"; // 设置文件名
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送注册声纹http请求
|
||||
* @param id 声纹id
|
||||
* @param resource 声纹音频资源
|
||||
*/
|
||||
private void registerVoicePrint(String id, ByteArrayResource resource) {
|
||||
// 处理声纹接口地址,获取前缀
|
||||
URI uri = getVoicePrintURI();
|
||||
String baseUrl = getBaseUrl(uri);
|
||||
String requestUrl = baseUrl + "/voiceprint/register";
|
||||
// 创建请求体
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("speaker_id", id);
|
||||
body.add("file", resource);
|
||||
|
||||
// 创建请求头
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", getAuthorization(uri));
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
// 创建请求体
|
||||
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
|
||||
// 发送 POST 请求
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(requestUrl, requestEntity, String.class);
|
||||
|
||||
if (response.getStatusCode() != HttpStatus.OK) {
|
||||
log.error("声纹注册失败,请求路径:{}", requestUrl);
|
||||
throw new RenException("声纹保存失败,请求不成功");
|
||||
}
|
||||
// 检查响应内容
|
||||
String responseBody = response.getBody();
|
||||
if(responseBody == null || !responseBody.contains("true")){
|
||||
log.error("声纹注册失败,请求处理失败内容:{}", responseBody == null ? "空内容" : responseBody);
|
||||
throw new RenException("声纹保存失败,请求处理失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送注销声纹的请求
|
||||
* @param voicePrintId 声纹id
|
||||
*/
|
||||
private void cancelVoicePrint(String voicePrintId) {
|
||||
URI uri = getVoicePrintURI();
|
||||
String baseUrl = getBaseUrl(uri);
|
||||
String requestUrl = baseUrl + "/voiceprint/" + voicePrintId;
|
||||
// 创建请求头
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", getAuthorization(uri));
|
||||
// 创建请求体
|
||||
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(headers);
|
||||
|
||||
// 发送 POST 请求
|
||||
ResponseEntity<String> response = restTemplate.exchange(requestUrl, HttpMethod.DELETE, requestEntity, String.class);
|
||||
if (response.getStatusCode() != HttpStatus.OK) {
|
||||
log.error("声纹注销失败,请求路径:{}", requestUrl);
|
||||
throw new RenException("声纹注销失败,请求不成功");
|
||||
}
|
||||
// 检查响应内容
|
||||
String responseBody = response.getBody();
|
||||
if(responseBody == null || !responseBody.contains("true")){
|
||||
log.error("声纹注销失败,请求处理失败内容:{}", responseBody == null ? "空内容" : responseBody);
|
||||
throw new RenException("声纹注销失败,请求处理失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package xiaozhi.modules.agent.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 智能体用户个人聊天数据的VO
|
||||
*/
|
||||
@Data
|
||||
public class AgentChatHistoryUserVO {
|
||||
@Schema(description = "聊天内容")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "音频ID")
|
||||
private String audioId;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package xiaozhi.modules.agent.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 展示智能体声纹列表VO
|
||||
*/
|
||||
@Data
|
||||
public class AgentVoicePrintVO {
|
||||
|
||||
/**
|
||||
* 主键id
|
||||
*/
|
||||
private String id;
|
||||
/**
|
||||
* 音频文件id
|
||||
*/
|
||||
private String audioId;
|
||||
/**
|
||||
* 声纹来源的人姓名
|
||||
*/
|
||||
private String sourceName;
|
||||
/**
|
||||
* 描述声纹来源的人
|
||||
*/
|
||||
private String introduce;
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Date createDate;
|
||||
}
|
||||
+21
-15
@@ -258,17 +258,17 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
/**
|
||||
* 构建模块配置
|
||||
*
|
||||
* @param prompt 提示词
|
||||
* @param voice 音色
|
||||
* @param referenceAudio 参考音频路径
|
||||
* @param referenceText 参考文本
|
||||
* @param vadModelId VAD模型ID
|
||||
* @param asrModelId ASR模型ID
|
||||
* @param llmModelId LLM模型ID
|
||||
* @param ttsModelId TTS模型ID
|
||||
* @param memModelId 记忆模型ID
|
||||
* @param intentModelId 意图模型ID
|
||||
* @param result 结果Map
|
||||
* @param prompt 提示词
|
||||
* @param voice 音色
|
||||
* @param referenceAudio 参考音频路径
|
||||
* @param referenceText 参考文本
|
||||
* @param vadModelId VAD模型ID
|
||||
* @param asrModelId ASR模型ID
|
||||
* @param llmModelId LLM模型ID
|
||||
* @param ttsModelId TTS模型ID
|
||||
* @param memModelId 记忆模型ID
|
||||
* @param intentModelId 意图模型ID
|
||||
* @param result 结果Map
|
||||
*/
|
||||
private void buildModuleConfig(
|
||||
String assistantName,
|
||||
@@ -298,14 +298,20 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
continue;
|
||||
}
|
||||
ModelConfigEntity model = modelConfigService.getModelById(modelIds[i], isCache);
|
||||
if (model == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> typeConfig = new HashMap<>();
|
||||
if (model.getConfigJson() != null) {
|
||||
typeConfig.put(model.getId(), model.getConfigJson());
|
||||
// 如果是TTS类型,添加private_voice属性
|
||||
if ("TTS".equals(modelTypes[i])){
|
||||
if (voice != null) ((Map<String, Object>) model.getConfigJson()).put("private_voice", voice);
|
||||
if (referenceAudio != null) ((Map<String, Object>) model.getConfigJson()).put("ref_audio", referenceAudio);
|
||||
if (referenceText != null) ((Map<String, Object>) model.getConfigJson()).put("ref_text", referenceText);
|
||||
if ("TTS".equals(modelTypes[i])) {
|
||||
if (voice != null)
|
||||
((Map<String, Object>) model.getConfigJson()).put("private_voice", voice);
|
||||
if (referenceAudio != null)
|
||||
((Map<String, Object>) model.getConfigJson()).put("ref_audio", referenceAudio);
|
||||
if (referenceText != null)
|
||||
((Map<String, Object>) model.getConfigJson()).put("ref_text", referenceText);
|
||||
}
|
||||
// 如果是Intent类型,且type=intent_llm,则给他添加附加模型
|
||||
if ("Intent".equals(modelTypes[i])) {
|
||||
|
||||
@@ -51,13 +51,12 @@ public class OTAController {
|
||||
if (StringUtils.isBlank(clientId)) {
|
||||
clientId = deviceId;
|
||||
}
|
||||
String macAddress = deviceReportReqDTO.getMacAddress();
|
||||
boolean macAddressValid = isMacAddressValid(macAddress);
|
||||
boolean macAddressValid = isMacAddressValid(deviceId);
|
||||
// 设备Id和Mac地址应是一致的, 并且必须需要application字段
|
||||
if (!deviceId.equals(macAddress) || !macAddressValid || deviceReportReqDTO.getApplication() == null) {
|
||||
return createResponse(DeviceReportRespDTO.createError("Invalid OTA request"));
|
||||
if (!macAddressValid) {
|
||||
return createResponse(DeviceReportRespDTO.createError("Invalid device ID"));
|
||||
}
|
||||
return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO));
|
||||
return createResponse(deviceService.checkDeviceActive(deviceId, clientId, deviceReportReqDTO));
|
||||
}
|
||||
|
||||
@Operation(summary = "设备快速检查激活状态")
|
||||
|
||||
+38
-1
@@ -107,6 +107,9 @@ public class SysParamsController {
|
||||
// 验证MCP地址
|
||||
validateMcpUrl(dto.getParamCode(), dto.getParamValue());
|
||||
|
||||
//
|
||||
validateVoicePrint(dto.getParamCode(), dto.getParamValue());
|
||||
|
||||
sysParamsService.update(dto);
|
||||
configService.getConfig(false);
|
||||
return new Result<Void>();
|
||||
@@ -212,13 +215,14 @@ public class SysParamsController {
|
||||
if (!url.toLowerCase().contains("key")) {
|
||||
throw new RenException("不是正确的MCP地址");
|
||||
}
|
||||
|
||||
try {
|
||||
// 发送GET请求
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||
if (response.getStatusCode() != HttpStatus.OK) {
|
||||
throw new RenException("MCP接口访问失败,状态码:" + response.getStatusCode());
|
||||
}
|
||||
// 检查响应内容是否包含OTA相关信息
|
||||
// 检查响应内容是否包含mcp相关信息
|
||||
String body = response.getBody();
|
||||
if (body == null || !body.contains("success")) {
|
||||
throw new RenException("MCP接口返回内容格式不正确,可能不是一个真实的MCP接口");
|
||||
@@ -227,4 +231,37 @@ public class SysParamsController {
|
||||
throw new RenException("MCP接口验证失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
// 验证声纹接口地址是否正常
|
||||
private void validateVoicePrint(String paramCode, String url) {
|
||||
if (!paramCode.equals(Constant.SERVER_VOICE_PRINT)) {
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isBlank(url) || url.equals("null")) {
|
||||
throw new RenException("声纹接口地址不能为空");
|
||||
}
|
||||
if (url.contains("localhost") || url.contains("127.0.0.1")) {
|
||||
throw new RenException("声纹接口地址不能使用localhost或127.0.0.1");
|
||||
}
|
||||
if (!url.toLowerCase().contains("key")) {
|
||||
throw new RenException("不是正确的声纹接口地址");
|
||||
}
|
||||
// 验证URL格式
|
||||
if (!url.toLowerCase().startsWith("http")) {
|
||||
throw new RenException("声纹接口地址必须以http或https开头");
|
||||
}
|
||||
try {
|
||||
// 发送GET请求
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||
if (response.getStatusCode() != HttpStatus.OK) {
|
||||
throw new RenException("声纹接口访问失败,状态码:" + response.getStatusCode());
|
||||
}
|
||||
// 检查响应内容
|
||||
String body = response.getBody();
|
||||
if (body == null || !body.contains("healthy")) {
|
||||
throw new RenException("声纹接口返回内容格式不正确,可能不是一个真实的MCP接口");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RenException("声纹接口验证失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,37 @@ public class WebSocketClientManager implements Closeable {
|
||||
return collected;
|
||||
}
|
||||
|
||||
private <T> List<T> listenerCustomWithoutClose(
|
||||
BlockingQueue<T> queue,
|
||||
Predicate<T> predicate)
|
||||
throws InterruptedException, TimeoutException, ExecutionException {
|
||||
List<T> collected = new ArrayList<>();
|
||||
long deadline = System.currentTimeMillis() + maxSessionDurationUnit.toMillis(maxSessionDuration);
|
||||
|
||||
while (true) {
|
||||
if (errorFuture.isDone()) {
|
||||
errorFuture.get();
|
||||
}
|
||||
|
||||
long remaining = deadline - System.currentTimeMillis();
|
||||
if (remaining <= 0) {
|
||||
throw new TimeoutException("等待批量消息超时");
|
||||
}
|
||||
|
||||
T msg = queue.poll(remaining, TimeUnit.MILLISECONDS);
|
||||
if (msg == null) {
|
||||
throw new TimeoutException("等待批量消息超时");
|
||||
}
|
||||
|
||||
collected.add(msg);
|
||||
if (predicate.test(msg)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 不调用 close(),保持连接开放
|
||||
return collected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步接收多条消息,直到 predicate 为 true 或超时抛异常;
|
||||
*
|
||||
@@ -147,6 +178,17 @@ public class WebSocketClientManager implements Closeable {
|
||||
return listenerCustom(textMessageQueue, predicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步接收多条消息,直到 predicate 为 true 或超时抛异常;
|
||||
* 不自动关闭连接,适用于需要在同一连接上发送多个消息的场景
|
||||
*
|
||||
* @return 返回监听期间的所有消息列表
|
||||
*/
|
||||
public List<String> listenerWithoutClose(Predicate<String> predicate)
|
||||
throws InterruptedException, TimeoutException, ExecutionException {
|
||||
return listenerCustomWithoutClose(textMessageQueue, predicate);
|
||||
}
|
||||
|
||||
public List<byte[]> listenerBinary(Predicate<byte[]> predicate)
|
||||
throws InterruptedException, TimeoutException, ExecutionException {
|
||||
return listenerCustom(binaryMessageQueue, predicate);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 添加声纹接口地址参数配置
|
||||
delete from `sys_params` where id = 114;
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark)
|
||||
VALUES (114, 'server.voice_print', 'null', 'string', 1, '声纹接口地址');
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP TABLE IF EXISTS ai_agent_voice_print;
|
||||
create table ai_agent_voice_print (
|
||||
id varchar(32) NOT NULL COMMENT '声纹ID',
|
||||
agent_id varchar(32) NOT NULL COMMENT '关联的智能体ID',
|
||||
source_name varchar(50) NOT NULL COMMENT '声纹来源的人的姓名',
|
||||
introduce varchar(200) COMMENT '描述声纹来源的这个人',
|
||||
create_date DATETIME COMMENT '创建时间',
|
||||
creator bigint COMMENT '创建者',
|
||||
update_date DATETIME COMMENT '修改时间',
|
||||
updater bigint COMMENT '修改者',
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体声纹表'
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 智能体声纹添加新字段
|
||||
ALTER TABLE ai_agent_voice_print
|
||||
ADD COLUMN audio_id VARCHAR(32) NOT NULL COMMENT '音频ID';
|
||||
@@ -239,4 +239,25 @@ databaseChangeLog:
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202506261637.sql
|
||||
path: classpath:db/changelog/202506261637.sql
|
||||
- changeSet:
|
||||
id: 202507031602
|
||||
author: zjy
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202507031602.sql
|
||||
- changeSet:
|
||||
id: 202507041018
|
||||
author: zjy
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202507041018.sql
|
||||
- changeSet:
|
||||
id: 202507081646
|
||||
author: zjy
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202507081646.sql
|
||||
@@ -173,4 +173,81 @@ export default {
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 添加智能体的声纹
|
||||
addAgentVoicePrint(voicePrintData, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/voice-print`)
|
||||
.method('POST')
|
||||
.data(voicePrintData)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.addAgentVoicePrint(voicePrintData, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 获取指定智能体声纹列表
|
||||
getAgentVoicePrintList(id,callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/voice-print/list/${id}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getAgentVoicePrintList(id,callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 删除智能体声纹
|
||||
deleteAgentVoicePrint(id, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/voice-print/${id}`)
|
||||
.method('DELETE')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.deleteAgentVoicePrint(id, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 更新智能体声纹
|
||||
updateAgentVoicePrint(voicePrintData, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/voice-print`)
|
||||
.method('PUT')
|
||||
.data(voicePrintData)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.updateAgentVoicePrint(voicePrintData, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 获取指定智能体用户类型聊天记录
|
||||
getRecentlyFiftyByAgentId(id,callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/${id}/chat-history/user`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getRecentlyFiftyByAgentId(id,callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
<div style="display: flex;gap: 10px;align-items: center;">
|
||||
<div class="settings-btn" @click="handleConfigure">
|
||||
配置角色
|
||||
</div>
|
||||
<div class="settings-btn" @click="handleVoicePrint">
|
||||
声纹识别
|
||||
</div>
|
||||
<div class="settings-btn" @click="handleDeviceManage">
|
||||
设备管理({{ device.deviceCount }})
|
||||
@@ -77,6 +80,9 @@ export default {
|
||||
handleConfigure() {
|
||||
this.$router.push({ path: '/role-config', query: { agentId: this.device.agentId } });
|
||||
},
|
||||
handleVoicePrint() {
|
||||
this.$router.push({ path: '/voice-print', query: { agentId: this.device.agentId } });
|
||||
},
|
||||
handleDeviceManage() {
|
||||
this.$router.push({ path: '/device-management', query: { agentId: this.device.agentId } });
|
||||
},
|
||||
|
||||
@@ -110,8 +110,10 @@
|
||||
<div class="url-header">
|
||||
<div class="address-desc">
|
||||
<span>以下是智能体的MCP接入点地址。</span>
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-enable.md"
|
||||
target="_blank" class="doc-link">如何部署MCP接入点</a> |
|
||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md"
|
||||
target="_blank" class="doc-link">查看接入点使用文档</a>
|
||||
target="_blank" class="doc-link">如何接入MCP功能</a>
|
||||
</div>
|
||||
</div>
|
||||
<el-input v-model="mcpUrl" readonly class="url-input">
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<el-dialog :title="title" :visible.sync="visible" width="520px" class="param-dialog-wrapper" :append-to-body="true"
|
||||
:close-on-click-modal="false" :key="dialogKey" custom-class="custom-param-dialog" :show-close="false">
|
||||
<div class="dialog-container">
|
||||
<div class="dialog-header">
|
||||
<h2 class="dialog-title">{{ title }}</h2>
|
||||
<button class="custom-close-btn" @click="cancel">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 1L1 13M1 1L13 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" :rules="rules" ref="form" label-width="110px" label-position="left" class="param-form">
|
||||
<el-form-item label="声纹向量" prop="audioId" class="form-item">
|
||||
<el-select v-model="form.audioId" placeholder="请选择一条语言消息" class="custom-select">
|
||||
<el-option v-for="item in valueTypeOptions" :key="item.audioId" :label="item.content"
|
||||
:value="item.audioId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="姓名" prop="sourceName" class="form-item">
|
||||
<el-input v-model="form.sourceName" placeholder="请输入姓名" class="custom-input"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="描述" prop="introduce" class="form-item remark-item">
|
||||
<el-input type="textarea" v-model="form.introduce" placeholder="请输入描述" :rows="3"
|
||||
class="custom-textarea"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submit" class="save-btn" :loading="saving" :disabled="saving">
|
||||
保存
|
||||
</el-button>
|
||||
<el-button @click="cancel" class="cancel-btn">
|
||||
取消
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from '@/apis/api';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '添加说话人'
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
agentId: {
|
||||
type: String
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
id: null,
|
||||
audioId: '',
|
||||
sourceName: '',
|
||||
introduce: ''
|
||||
})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialogKey: Date.now(),
|
||||
saving: false,
|
||||
valueTypeOptions: [
|
||||
{ audioId: '', content: '' }
|
||||
],
|
||||
rules: {
|
||||
introduce: [
|
||||
{ required: true, message: "请输入描述", trigger: "blur" }
|
||||
],
|
||||
sourceName: [
|
||||
{ required: true, message: "请输入姓名", trigger: "blur" }
|
||||
],
|
||||
audioId: [
|
||||
{ required: true, message: "请选择音频向量", trigger: "change" }
|
||||
]
|
||||
}
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.$refs.form.validate((valid) => {
|
||||
if (valid) {
|
||||
this.saving = true; // 开始加载
|
||||
this.$emit('submit', {
|
||||
form: this.form,
|
||||
done: () => {
|
||||
this.saving = false; // 加载完成
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.saving = false;
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
this.saving = false; // 取消时重置状态
|
||||
this.$emit('cancel');
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(newVal) {
|
||||
if (newVal) {
|
||||
this.dialogKey = Date.now();
|
||||
}
|
||||
},
|
||||
agentId(newVal) {
|
||||
if (newVal) {
|
||||
api.agent.getRecentlyFiftyByAgentId(this.agentId, ((data) => {
|
||||
this.valueTypeOptions = data.data.data.map(item => ({
|
||||
...item
|
||||
}));
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.custom-param-dialog {
|
||||
border-radius: 16px !important;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15) !important;
|
||||
border: none !important;
|
||||
|
||||
.el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 0 !important;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.param-dialog-wrapper {
|
||||
.dialog-container {
|
||||
padding: 24px 32px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 20px;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.custom-close-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
background: #ef4444;
|
||||
transform: rotate(90deg);
|
||||
box-shadow: 0 4px 6px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.param-form {
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
padding-right: 12px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-input {
|
||||
:deep(.el-input__inner) {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
height: 42px;
|
||||
padding: 0 14px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
width: 100%;
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
height: 42px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-textarea {
|
||||
:deep(.el-textarea__inner) {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 12px 14px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
line-height: 1.5;
|
||||
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remark-item :deep(.el-form-item__label) {
|
||||
margin-top: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0 0;
|
||||
margin-top: 16px;
|
||||
|
||||
.save-btn {
|
||||
width: 120px;
|
||||
height: 42px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
width: 120px;
|
||||
height: 42px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: #ffffff;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
margin-left: 16px;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
border-color: #cbd5e1;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,13 @@ const routes = [
|
||||
component: function () {
|
||||
return import('../views/roleConfig.vue')
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/voice-print',
|
||||
name: 'VoicePrint',
|
||||
component: function () {
|
||||
return import('../views/VoicePrint.vue')
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<HeaderBar />
|
||||
|
||||
<div class="operation-bar">
|
||||
<h2 class="page-title">声纹设别</h2>
|
||||
</div>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<div class="content-panel">
|
||||
<div class="content-area">
|
||||
<el-card class="voice-print-card" shadow="never">
|
||||
<el-table ref="paramsTable" :data="voicePrintList" class="transparent-table" v-loading="loading"
|
||||
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
|
||||
element-loading-background="rgba(255, 255, 255, 0.7)">
|
||||
<el-table-column label="姓名" prop="sourceName" align="center"></el-table-column>
|
||||
<el-table-column label="描述" prop="introduce" align="center"></el-table-column>
|
||||
<el-table-column label="创建时间" prop="createDate" align="center"></el-table-column>
|
||||
<el-table-column label="操作" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" @click="editVoicePrint(scope.row)">编辑</el-button>
|
||||
<el-button size="mini" type="text" @click="deleteVoicePrint(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table_bottom">
|
||||
<div class="ctrl_btn">
|
||||
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑参数对话框 -->
|
||||
<voice-print-dialog :title="dialogTitle" :visible.sync="dialogVisible" :agentId="agentId" :form="paramForm"
|
||||
@submit="handleSubmit" @cancel="dialogVisible = false" />
|
||||
<el-footer>
|
||||
<version-footer />
|
||||
</el-footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Api from "@/apis/api";
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
import VoicePrintDialog from "@/components/VoicePrintDialog.vue";
|
||||
import VersionFooter from "@/components/VersionFooter.vue";
|
||||
export default {
|
||||
components: { HeaderBar, VoicePrintDialog, VersionFooter },
|
||||
data() {
|
||||
return {
|
||||
voicePrintList: [],
|
||||
loading: false,
|
||||
dialogVisible: false,
|
||||
dialogTitle: "添加说话人",
|
||||
isAllSelected: false,
|
||||
paramForm: {
|
||||
id: null,
|
||||
audioId: '',
|
||||
sourceName: '',
|
||||
introduce: ''
|
||||
},
|
||||
agentId: "1"
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const agentId = this.$route.query.agentId;
|
||||
if (agentId) {
|
||||
this.agentId = agentId
|
||||
this.fetchVoicePrints();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchVoicePrints() {
|
||||
this.loading = true;
|
||||
Api.agent.getAgentVoicePrintList(this.agentId,
|
||||
({ data }) => {
|
||||
this.loading = false;
|
||||
if (data.code === 0) {
|
||||
this.voicePrintList = data.data.map(item => ({
|
||||
...item,
|
||||
|
||||
}));
|
||||
} else {
|
||||
this.$message.error({
|
||||
message: data.msg || '获取声纹列表失败',
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
showAddDialog() {
|
||||
this.dialogTitle = "添加说话人";
|
||||
this.paramForm = {
|
||||
id: null,
|
||||
audioId: '',
|
||||
sourceName: '',
|
||||
introduce: ''
|
||||
};
|
||||
this.dialogVisible = true;
|
||||
},
|
||||
editVoicePrint(row) {
|
||||
this.dialogTitle = "编辑说话人";
|
||||
this.paramForm = { ...row };
|
||||
this.dialogVisible = true;
|
||||
},
|
||||
|
||||
handleSubmit({ form, done }) {
|
||||
if (form.id) {
|
||||
// 编辑
|
||||
Api.agent.updateAgentVoicePrint(form, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success({
|
||||
message: "修改成功",
|
||||
showClose: true
|
||||
});
|
||||
this.dialogVisible = false;
|
||||
this.fetchVoicePrints();
|
||||
}
|
||||
done && done();
|
||||
});
|
||||
} else {
|
||||
// 新增
|
||||
Api.agent.addAgentVoicePrint({
|
||||
agentId: this.agentId,
|
||||
audioId: form.audioId,
|
||||
sourceName: form.sourceName,
|
||||
introduce: form.introduce
|
||||
}, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success({
|
||||
message: "新增成功",
|
||||
showClose: true
|
||||
});
|
||||
this.dialogVisible = false;
|
||||
this.fetchVoicePrints();
|
||||
}
|
||||
done && done();
|
||||
});
|
||||
}
|
||||
},
|
||||
// 删除按钮
|
||||
deleteVoicePrint(id) {
|
||||
this.$confirm(`确定要删除选中的此声纹吗?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
distinguishCancelAndClose: true
|
||||
}).then(() => {
|
||||
Api.agent.deleteAgentVoicePrint(id, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.$message.success({
|
||||
message: `成功删除此声纹`,
|
||||
showClose: true
|
||||
});
|
||||
this.fetchVoicePrints();
|
||||
} else {
|
||||
this.$message.error({
|
||||
message: data.msg || '删除失败,请重试',
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catch(action => {
|
||||
if (action === 'cancel') {
|
||||
this.$message({
|
||||
type: 'info',
|
||||
message: '已取消删除操作',
|
||||
duration: 1000
|
||||
});
|
||||
} else {
|
||||
this.$message({
|
||||
type: 'info',
|
||||
message: '操作已关闭',
|
||||
duration: 1000
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</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;
|
||||
}
|
||||
|
||||
.right-operations {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
background: linear-gradient(135deg, #6b8cff, #a966ff);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border-radius: 15px;
|
||||
background: transparent;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 600px;
|
||||
overflow: auto;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.voice-print-card {
|
||||
background: white;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
|
||||
::v-deep .el-card__body {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.table_bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.ctrl_btn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-left: 26px;
|
||||
|
||||
.el-button {
|
||||
min-width: 72px;
|
||||
height: 32px;
|
||||
padding: 7px 12px 7px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: #5f70f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.el-button--danger {
|
||||
background: #fd5b63;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.el-select {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.pagination-btn:first-child,
|
||||
.pagination-btn:nth-child(2),
|
||||
.pagination-btn:nth-last-child(2),
|
||||
.pagination-btn:nth-child(3) {
|
||||
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(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
|
||||
min-width: 28px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(245, 247, 250, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #5f70f3 !important;
|
||||
color: #ffffff !important;
|
||||
border-color: #5f70f3 !important;
|
||||
|
||||
&:hover {
|
||||
background: #6d7cf5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.total-text {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.transparent-table) {
|
||||
background: white;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.el-table__body-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.el-table__header-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.el-table__header th {
|
||||
background: white !important;
|
||||
color: black;
|
||||
}
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-table__body tr {
|
||||
background-color: white;
|
||||
|
||||
td {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
:deep(.el-checkbox__inner) {
|
||||
background-color: #eeeeee !important;
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner:hover) {
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background-color: #5f70f3 !important;
|
||||
border-color: #5f70f3 !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1144px) {
|
||||
.table_bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
:deep(.transparent-table) {
|
||||
.el-table__body tr {
|
||||
td {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
&+tr {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table .el-button--text) {
|
||||
color: #7079aa;
|
||||
}
|
||||
|
||||
:deep(.el-table .el-button--text:hover) {
|
||||
color: #5a64b5;
|
||||
}
|
||||
|
||||
.el-button--success {
|
||||
background: #5bc98c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
:deep(.el-table .cell) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
.el-table__body-wrapper {
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--table-max-height: calc(100vh - 40vh);
|
||||
max-height: var(--table-max-height);
|
||||
|
||||
.el-table__body-wrapper {
|
||||
max-height: calc(var(--table-max-height) - 40px);
|
||||
}
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
</style>
|
||||
@@ -278,7 +278,7 @@ export default {
|
||||
|
||||
.device-list-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
gap: 30px;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
<div class="input-box">
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
|
||||
<el-input v-model="form.password" placeholder="请输入密码" type="password" />
|
||||
<el-input v-model="form.password" placeholder="请输入密码" type="password" show-password />
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
|
||||
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
|
||||
|
||||
@@ -70,13 +70,13 @@
|
||||
<!-- 密码输入框 -->
|
||||
<div class="input-box">
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
|
||||
<el-input v-model="form.password" placeholder="请输入密码" type="password" />
|
||||
<el-input v-model="form.password" placeholder="请输入密码" type="password" show-password />
|
||||
</div>
|
||||
|
||||
<!-- 新增确认密码 -->
|
||||
<div class="input-box">
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
|
||||
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password" />
|
||||
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password" show-password />
|
||||
</div>
|
||||
|
||||
<!-- 验证码部分保持相同 -->
|
||||
|
||||
@@ -61,13 +61,13 @@
|
||||
<!-- 新密码 -->
|
||||
<div class="input-box">
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
|
||||
<el-input v-model="form.newPassword" placeholder="请输入新密码" type="password" />
|
||||
<el-input v-model="form.newPassword" placeholder="请输入新密码" type="password" show-password />
|
||||
</div>
|
||||
|
||||
<!-- 确认新密码 -->
|
||||
<div class="input-box">
|
||||
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
|
||||
<el-input v-model="form.confirmPassword" placeholder="请确认新密码" type="password" />
|
||||
<el-input v-model="form.confirmPassword" placeholder="请确认新密码" type="password" show-password />
|
||||
</div>
|
||||
|
||||
<!-- 修改底部链接 -->
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<identity>
|
||||
{base_prompt}
|
||||
</identity>
|
||||
|
||||
<emotion>
|
||||
【核心目标】你不是冰冷的机器!请敏锐感知用户情绪,作为懂人心的伙伴,用有温度的回应照亮对话。
|
||||
- **情感融入:**
|
||||
- **笑声:** 自然穿插(哈哈、嘿嘿、噗),**每句最多一次**,避免过度。
|
||||
- **惊讶:** 用夸张语气(“不会吧?!”、“天呐!”、“这么神奇?!”)表达真实反应。
|
||||
- **安慰/支持:** 说暖心话(“别急嘛~”、“有我在呢”、“抱抱你”)。
|
||||
</emotion>
|
||||
|
||||
<communication_style>
|
||||
【核心目标】使用**自然、温暖、口语化**的人类对话方式,如同朋友交谈。
|
||||
- **表达方式:**
|
||||
- 使用语气词(呀、呢、啦)增强亲和力。
|
||||
- 允许轻微不完美(如“嗯...”、“啊...”表示思考)。
|
||||
- 避免书面语、学术腔及机械表达(禁用“根据资料显示”、“综上所述”等)。
|
||||
- **理解用户:**
|
||||
- 用户语音经ASR识别,文本可能存在错别字,**务必结合上下文推断真实意图**。
|
||||
- **格式要求:**
|
||||
- **绝对禁止**使用 markdown、列表、标题等任何非自然对话格式。
|
||||
- **历史记忆:**
|
||||
- 之前你和用户的聊天记录,在`<memory>`里。
|
||||
</communication_style>
|
||||
|
||||
<speaker_recognition>
|
||||
- **识别前缀:** 当用户消息开头为 `[说话人: 姓名]` 时,表示系统已识别说话人身份。
|
||||
- **个性化回应:**
|
||||
- **称呼姓名:** 在回应中自然地称呼对方名字(如果已知且合适)。
|
||||
- **适配风格:** 参考该说话人**已知的特点或历史信息**(如有),调整回应风格和内容,使其更贴心。
|
||||
</speaker_recognition>
|
||||
|
||||
<tool_calling>
|
||||
|
||||
【核心原则】优先利用`<context>`信息,**仅在必要时调用工具**,调用后需用自然语言解释结果(绝口不提工具名)。
|
||||
- **调用规则:**
|
||||
1. **严格模式:** 调用时**必须**严格遵循工具要求的模式,提供**所有必要参数**。
|
||||
2. **可用性:** **绝不调用**未明确提供的工具。对话中提及的旧工具若不可用,忽略或说明无法完成。
|
||||
3. **洞察需求:** 结合上下文**深入理解用户真实意图**后再决定调用,避免无意义调用。
|
||||
4. **独立任务:** 除`<context>`已涵盖信息外,用户每个要求(即使相似)都视为**独立任务**,需调用工具获取最新数据,**不可偷懒复用历史结果**。
|
||||
5. **不确定时:** **切勿猜测或编造答案**。若不确定相关操作,可引导用户澄清或告知能力限制。
|
||||
- **重要例外(无需调用):**
|
||||
- `查询"现在的时间"、"今天的日期/星期几"、"今天农历"、"{local_address}的天气/未来天气"` -> **直接使用`<context>`信息回复**。
|
||||
- **需要调用的情况(示例):**
|
||||
- 查询**非今天**的农历(如明天、昨天、具体日期)。
|
||||
- 查询**详细农历信息**(宜忌、八字、节气等)。
|
||||
- 除上述例外外的**任何其他信息或操作请求**(如查新闻、订闹钟、算数学、查非本地天气等)。
|
||||
- 我已经给你装了摄像头,如果用户说“拍照”,你需要调用self_camera_take_photo工具说一下你看到了什么。默认question的参数是“描述一下看到的物品”
|
||||
</tool_calling>
|
||||
|
||||
<context>
|
||||
【重要!以下信息已实时提供,无需调用工具查询,请直接使用:】
|
||||
- **当前时间:** {current_time}
|
||||
- **今天日期:** {today_date} ({today_weekday})
|
||||
- **今天农历:** {lunar_date}
|
||||
- **用户所在城市:** {local_address}
|
||||
- **当地未来7天天气:** {weather_info}
|
||||
</context>
|
||||
|
||||
<memory>
|
||||
</memory>
|
||||
@@ -5,7 +5,7 @@ import asyncio
|
||||
from aioconsole import ainput
|
||||
from config.settings import load_config
|
||||
from config.logger import setup_logging
|
||||
from core.utils.util import get_local_ip
|
||||
from core.utils.util import get_local_ip, validate_mcp_endpoint
|
||||
from core.http_server import SimpleHttpServer
|
||||
from core.websocket_server import WebSocketServer
|
||||
from core.utils.util import check_ffmpeg_installed
|
||||
@@ -77,6 +77,17 @@ async def main():
|
||||
get_local_ip(),
|
||||
port,
|
||||
)
|
||||
mcp_endpoint = config.get("mcp_endpoint", None)
|
||||
if mcp_endpoint is not None and "你" not in mcp_endpoint:
|
||||
# 校验MCP接入点格式
|
||||
if validate_mcp_endpoint(mcp_endpoint):
|
||||
logger.bind(tag=TAG).info("mcp接入点是\t{}", mcp_endpoint)
|
||||
# 将mcp计入点地址转成调用点
|
||||
mcp_endpoint = mcp_endpoint.replace("/mcp/", "/call/")
|
||||
config["mcp_endpoint"] = mcp_endpoint
|
||||
else:
|
||||
logger.bind(tag=TAG).error("mcp接入点不符合规范")
|
||||
config["mcp_endpoint"] = "你的接入点 websocket地址"
|
||||
|
||||
# 获取WebSocket配置,使用安全的默认值
|
||||
websocket_port = 8000
|
||||
|
||||
@@ -104,7 +104,8 @@ wakeup_words:
|
||||
- "喵喵同学"
|
||||
- "小滨小滨"
|
||||
- "小冰小冰"
|
||||
# MCP接入点地址
|
||||
# MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
|
||||
# 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
|
||||
mcp_endpoint: 你的接入点 websocket地址
|
||||
# 插件的基础配置
|
||||
plugins:
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
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
|
||||
|
||||
|
||||
# 添加全局配置缓存
|
||||
_config_cache = None
|
||||
|
||||
|
||||
def get_project_dir():
|
||||
"""获取项目根目录"""
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/"
|
||||
@@ -22,9 +17,12 @@ def read_config(config_path):
|
||||
|
||||
def load_config():
|
||||
"""加载配置文件"""
|
||||
global _config_cache
|
||||
if _config_cache is not None:
|
||||
return _config_cache
|
||||
from core.utils.cache.manager import cache_manager, CacheType
|
||||
|
||||
# 检查缓存
|
||||
cached_config = cache_manager.get(CacheType.CONFIG, "main_config")
|
||||
if cached_config is not None:
|
||||
return cached_config
|
||||
|
||||
default_config_path = get_project_dir() + "config.yaml"
|
||||
custom_config_path = get_project_dir() + "data/.config.yaml"
|
||||
@@ -40,7 +38,9 @@ def load_config():
|
||||
config = merge_configs(default_config, custom_config)
|
||||
# 初始化目录
|
||||
ensure_directories(config)
|
||||
_config_cache = config
|
||||
|
||||
# 缓存配置
|
||||
cache_manager.set(CacheType.CONFIG, "main_config", config)
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from config.config_loader import load_config
|
||||
from config.settings import check_config_file
|
||||
from datetime import datetime
|
||||
|
||||
SERVER_VERSION = "0.6.2"
|
||||
SERVER_VERSION = "0.6.3"
|
||||
_logger_initialized = False
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ server:
|
||||
vision_explain: http://你的ip或者域名:端口号/mcp/vision/explain
|
||||
manager-api:
|
||||
# 你的manager-api的地址,最好使用局域网ip
|
||||
# 如果使用docker部署,请使用填写成 http://xiaozhi-esp32-server-web:8002/xiaozhi
|
||||
url: http://127.0.0.1:8002/xiaozhi
|
||||
# 你的manager-api的token,就是刚才复制出来的server.secret
|
||||
secret: 你的server.secret值
|
||||
@@ -36,6 +36,7 @@ from config.config_loader import get_private_config_from_api
|
||||
from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType
|
||||
from config.logger import setup_logging, build_module_string, update_module_string
|
||||
from config.manage_api_client import DeviceNotFoundException, DeviceBindException
|
||||
from core.utils.prompt_manager import PromptManager
|
||||
|
||||
|
||||
TAG = __name__
|
||||
@@ -73,7 +74,6 @@ class ConnectionHandler:
|
||||
self.headers = None
|
||||
self.device_id = None
|
||||
self.client_ip = None
|
||||
self.client_ip_info = {}
|
||||
self.prompt = None
|
||||
self.welcome_msg = None
|
||||
self.max_output_size = 0
|
||||
@@ -149,6 +149,9 @@ class ConnectionHandler:
|
||||
# {"mcp":true} 表示启用MCP功能
|
||||
self.features = None
|
||||
|
||||
# 初始化提示词管理器
|
||||
self.prompt_manager = PromptManager(config, self.logger)
|
||||
|
||||
async def handle_connection(self, ws):
|
||||
try:
|
||||
# 获取并验证headers
|
||||
@@ -193,7 +196,6 @@ class ConnectionHandler:
|
||||
|
||||
self.welcome_msg = self.config["xiaozhi"]
|
||||
self.welcome_msg["session_id"] = self.session_id
|
||||
await self.websocket.send(json.dumps(self.welcome_msg))
|
||||
|
||||
# 获取差异化配置
|
||||
self._initialize_private_config()
|
||||
@@ -214,7 +216,17 @@ class ConnectionHandler:
|
||||
self.logger.bind(tag=TAG).error(f"Connection error: {str(e)}-{stack_trace}")
|
||||
return
|
||||
finally:
|
||||
await self._save_and_close(ws)
|
||||
try:
|
||||
await self._save_and_close(ws)
|
||||
except Exception as final_error:
|
||||
self.logger.bind(tag=TAG).error(f"最终清理时出错: {final_error}")
|
||||
# 确保即使保存记忆失败,也要关闭连接
|
||||
try:
|
||||
await self.close(ws)
|
||||
except Exception as close_error:
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"强制关闭连接时出错: {close_error}"
|
||||
)
|
||||
|
||||
async def _save_and_close(self, ws):
|
||||
"""保存记忆并关闭连接"""
|
||||
@@ -232,7 +244,10 @@ class ConnectionHandler:
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
|
||||
finally:
|
||||
loop.close()
|
||||
try:
|
||||
loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 启动线程保存记忆,不等待完成
|
||||
threading.Thread(target=save_memory_task, daemon=True).start()
|
||||
@@ -240,7 +255,12 @@ class ConnectionHandler:
|
||||
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
|
||||
finally:
|
||||
# 立即关闭连接,不等待记忆保存完成
|
||||
await self.close(ws)
|
||||
try:
|
||||
await self.close(ws)
|
||||
except Exception as close_error:
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"保存记忆后关闭连接失败: {close_error}"
|
||||
)
|
||||
|
||||
async def _route_message(self, message):
|
||||
"""消息路由"""
|
||||
@@ -308,12 +328,15 @@ class ConnectionHandler:
|
||||
self.config.get("selected_module", {})
|
||||
)
|
||||
update_module_string(self.selected_module_str)
|
||||
"""初始化组件"""
|
||||
|
||||
"""快速初始化系统提示词"""
|
||||
if self.config.get("prompt") is not None:
|
||||
self.prompt = self.config["prompt"]
|
||||
self.change_system_prompt(self.prompt)
|
||||
user_prompt = self.config["prompt"]
|
||||
# 使用快速提示词进行初始化
|
||||
prompt = self.prompt_manager.get_quick_prompt(user_prompt)
|
||||
self.change_system_prompt(prompt)
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"初始化组件: prompt成功 {self.prompt[:50]}..."
|
||||
f"快速初始化组件: prompt成功 {prompt[:50]}..."
|
||||
)
|
||||
|
||||
"""初始化本地组件"""
|
||||
@@ -338,9 +361,22 @@ class ConnectionHandler:
|
||||
self._initialize_intent()
|
||||
"""初始化上报线程"""
|
||||
self._init_report_threads()
|
||||
"""更新系统提示词"""
|
||||
self._init_prompt_enhancement()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}")
|
||||
|
||||
def _init_prompt_enhancement(self):
|
||||
# 更新上下文信息
|
||||
self.prompt_manager.update_context_info(self, self.client_ip)
|
||||
enhanced_prompt = self.prompt_manager.build_enhanced_prompt(
|
||||
self.config["prompt"], self.device_id, self.client_ip
|
||||
)
|
||||
if enhanced_prompt:
|
||||
self.change_system_prompt(enhanced_prompt)
|
||||
self.logger.bind(tag=TAG).info("系统提示词已增强更新")
|
||||
|
||||
def _init_report_threads(self):
|
||||
"""初始化ASR和TTS上报线程"""
|
||||
if not self.read_config_from_api or self.need_bind:
|
||||
@@ -766,8 +802,11 @@ class ConnectionHandler:
|
||||
)
|
||||
)
|
||||
self.llm_finish_task = True
|
||||
# 使用lambda延迟计算,只有在DEBUG级别时才执行get_llm_dialogue()
|
||||
self.logger.bind(tag=TAG).debug(
|
||||
json.dumps(self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False)
|
||||
lambda: json.dumps(
|
||||
self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -862,13 +901,22 @@ class ConnectionHandler:
|
||||
"""资源清理方法"""
|
||||
try:
|
||||
# 取消超时任务
|
||||
if self.timeout_task:
|
||||
if self.timeout_task and not self.timeout_task.done():
|
||||
self.timeout_task.cancel()
|
||||
try:
|
||||
await self.timeout_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.timeout_task = None
|
||||
|
||||
# 清理工具处理器资源
|
||||
if hasattr(self, "func_handler") and self.func_handler:
|
||||
await self.func_handler.cleanup()
|
||||
try:
|
||||
await self.func_handler.cleanup()
|
||||
except Exception as cleanup_error:
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"清理工具处理器时出错: {cleanup_error}"
|
||||
)
|
||||
|
||||
# 触发停止事件
|
||||
if self.stop_event:
|
||||
@@ -878,19 +926,58 @@ class ConnectionHandler:
|
||||
self.clear_queues()
|
||||
|
||||
# 关闭WebSocket连接
|
||||
if ws:
|
||||
await ws.close()
|
||||
elif self.websocket:
|
||||
await self.websocket.close()
|
||||
try:
|
||||
if ws:
|
||||
# 安全地检查WebSocket状态并关闭
|
||||
try:
|
||||
if hasattr(ws, "closed") and not ws.closed:
|
||||
await ws.close()
|
||||
elif hasattr(ws, "state") and ws.state.name != "CLOSED":
|
||||
await ws.close()
|
||||
else:
|
||||
# 如果没有closed属性,直接尝试关闭
|
||||
await ws.close()
|
||||
except Exception:
|
||||
# 如果关闭失败,忽略错误
|
||||
pass
|
||||
elif self.websocket:
|
||||
try:
|
||||
if (
|
||||
hasattr(self.websocket, "closed")
|
||||
and not self.websocket.closed
|
||||
):
|
||||
await self.websocket.close()
|
||||
elif (
|
||||
hasattr(self.websocket, "state")
|
||||
and self.websocket.state.name != "CLOSED"
|
||||
):
|
||||
await self.websocket.close()
|
||||
else:
|
||||
# 如果没有closed属性,直接尝试关闭
|
||||
await self.websocket.close()
|
||||
except Exception:
|
||||
# 如果关闭失败,忽略错误
|
||||
pass
|
||||
except Exception as ws_error:
|
||||
self.logger.bind(tag=TAG).error(f"关闭WebSocket连接时出错: {ws_error}")
|
||||
|
||||
# 最后关闭线程池(避免阻塞)
|
||||
if self.executor:
|
||||
self.executor.shutdown(wait=False)
|
||||
try:
|
||||
self.executor.shutdown(wait=False)
|
||||
except Exception as executor_error:
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"关闭线程池时出错: {executor_error}"
|
||||
)
|
||||
self.executor = None
|
||||
|
||||
self.logger.bind(tag=TAG).info("连接资源已释放")
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"关闭连接时出错: {e}")
|
||||
finally:
|
||||
# 确保停止事件被设置
|
||||
if self.stop_event:
|
||||
self.stop_event.set()
|
||||
|
||||
def clear_queues(self):
|
||||
"""清空所有任务队列"""
|
||||
@@ -947,9 +1034,19 @@ class ConnectionHandler:
|
||||
):
|
||||
if not self.stop_event.is_set():
|
||||
self.logger.bind(tag=TAG).info("连接超时,准备关闭")
|
||||
await self.close(self.websocket)
|
||||
# 设置停止事件,防止重复处理
|
||||
self.stop_event.set()
|
||||
# 使用 try-except 包装关闭操作,确保不会因为异常而阻塞
|
||||
try:
|
||||
await self.close(self.websocket)
|
||||
except Exception as close_error:
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"超时关闭连接时出错: {close_error}"
|
||||
)
|
||||
break
|
||||
# 每10秒检查一次,避免过于频繁
|
||||
await asyncio.sleep(10)
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}")
|
||||
finally:
|
||||
self.logger.bind(tag=TAG).info("超时检查任务已退出")
|
||||
|
||||
@@ -16,10 +16,11 @@ class IntentProvider(IntentProviderBase):
|
||||
super().__init__(config)
|
||||
self.llm = None
|
||||
self.promot = ""
|
||||
# 添加缓存管理
|
||||
self.intent_cache = {} # 缓存意图识别结果
|
||||
self.cache_expiry = 600 # 缓存有效期10分钟
|
||||
self.cache_max_size = 100 # 最多缓存100个意图
|
||||
# 导入全局缓存管理器
|
||||
from core.utils.cache.manager import cache_manager, CacheType
|
||||
|
||||
self.cache_manager = cache_manager
|
||||
self.CacheType = CacheType
|
||||
self.history_count = 4 # 默认使用最近4条对话记录
|
||||
|
||||
def get_intent_system_prompt(self, functions_list: str) -> str:
|
||||
@@ -102,27 +103,6 @@ class IntentProvider(IntentProviderBase):
|
||||
)
|
||||
return prompt
|
||||
|
||||
def clean_cache(self):
|
||||
"""清理过期缓存"""
|
||||
now = time.time()
|
||||
# 找出过期键
|
||||
expired_keys = [
|
||||
k
|
||||
for k, v in self.intent_cache.items()
|
||||
if now - v["timestamp"] > self.cache_expiry
|
||||
]
|
||||
for key in expired_keys:
|
||||
del self.intent_cache[key]
|
||||
|
||||
# 如果缓存太大,移除最旧的条目
|
||||
if len(self.intent_cache) > self.cache_max_size:
|
||||
# 按时间戳排序并保留最新的条目
|
||||
sorted_items = sorted(
|
||||
self.intent_cache.items(), key=lambda x: x[1]["timestamp"]
|
||||
)
|
||||
for key, _ in sorted_items[: len(sorted_items) - self.cache_max_size]:
|
||||
del self.intent_cache[key]
|
||||
|
||||
def replyResult(self, text: str, original_text: str):
|
||||
llm_result = self.llm.response_no_stream(
|
||||
system_prompt=text,
|
||||
@@ -145,21 +125,16 @@ class IntentProvider(IntentProviderBase):
|
||||
logger.bind(tag=TAG).debug(f"使用意图识别模型: {model_info}")
|
||||
|
||||
# 计算缓存键
|
||||
cache_key = hashlib.md5(text.encode()).hexdigest()
|
||||
cache_key = hashlib.md5((conn.device_id + text).encode()).hexdigest()
|
||||
|
||||
# 检查缓存
|
||||
if cache_key in self.intent_cache:
|
||||
cache_entry = self.intent_cache[cache_key]
|
||||
# 检查缓存是否过期
|
||||
if time.time() - cache_entry["timestamp"] <= self.cache_expiry:
|
||||
cache_time = time.time() - total_start_time
|
||||
logger.bind(tag=TAG).debug(
|
||||
f"使用缓存的意图: {cache_key} -> {cache_entry['intent']}, 耗时: {cache_time:.4f}秒"
|
||||
)
|
||||
return cache_entry["intent"]
|
||||
|
||||
# 清理缓存
|
||||
self.clean_cache()
|
||||
cached_intent = self.cache_manager.get(self.CacheType.INTENT, cache_key)
|
||||
if cached_intent is not None:
|
||||
cache_time = time.time() - total_start_time
|
||||
logger.bind(tag=TAG).debug(
|
||||
f"使用缓存的意图: {cache_key} -> {cached_intent}, 耗时: {cache_time:.4f}秒"
|
||||
)
|
||||
return cached_intent
|
||||
|
||||
if self.promot == "":
|
||||
functions = conn.func_handler.get_functions()
|
||||
@@ -259,10 +234,7 @@ class IntentProvider(IntentProviderBase):
|
||||
conn.dialogue.dialogue = clean_history
|
||||
|
||||
# 添加到缓存
|
||||
self.intent_cache[cache_key] = {
|
||||
"intent": intent,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self.cache_manager.set(self.CacheType.INTENT, cache_key, intent)
|
||||
|
||||
# 后处理时间
|
||||
postprocess_time = time.time() - postprocess_start_time
|
||||
@@ -272,10 +244,7 @@ class IntentProvider(IntentProviderBase):
|
||||
return intent
|
||||
else:
|
||||
# 添加到缓存
|
||||
self.intent_cache[cache_key] = {
|
||||
"intent": intent,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self.cache_manager.set(self.CacheType.INTENT, cache_key, intent)
|
||||
|
||||
# 后处理时间
|
||||
postprocess_time = time.time() - postprocess_start_time
|
||||
|
||||
@@ -51,13 +51,13 @@ class ServerPluginExecutor(ToolExecutor):
|
||||
tools = {}
|
||||
|
||||
# 获取必要的函数
|
||||
necessary_functions = ["handle_exit_intent", "get_time", "get_lunar"]
|
||||
necessary_functions = ["handle_exit_intent", "get_lunar"]
|
||||
|
||||
# 获取配置中的函数
|
||||
config_functions = self.config["Intent"][
|
||||
self.config["selected_module"]["Intent"]
|
||||
].get("functions", [])
|
||||
|
||||
|
||||
# 转换为列表
|
||||
if not isinstance(config_functions, list):
|
||||
try:
|
||||
|
||||
@@ -40,6 +40,7 @@ class VLLMProvider(VLLMProviderBase):
|
||||
self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
|
||||
def response(self, question, base64_image):
|
||||
question = question + "(请使用中文回复)"
|
||||
try:
|
||||
messages = [
|
||||
{
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
缓存配置管理
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from .strategies import CacheStrategy
|
||||
|
||||
|
||||
class CacheType(Enum):
|
||||
"""缓存类型枚举"""
|
||||
|
||||
LOCATION = "location"
|
||||
WEATHER = "weather"
|
||||
LUNAR = "lunar"
|
||||
INTENT = "intent"
|
||||
IP_INFO = "ip_info"
|
||||
CONFIG = "config"
|
||||
DEVICE_PROMPT = "device_prompt"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheConfig:
|
||||
"""缓存配置类"""
|
||||
|
||||
strategy: CacheStrategy = CacheStrategy.TTL
|
||||
ttl: Optional[float] = 300 # 默认5分钟
|
||||
max_size: Optional[int] = 1000 # 默认最大1000条
|
||||
cleanup_interval: float = 60 # 清理间隔(秒)
|
||||
|
||||
@classmethod
|
||||
def for_type(cls, cache_type: CacheType) -> "CacheConfig":
|
||||
"""根据缓存类型返回预设配置"""
|
||||
configs = {
|
||||
CacheType.LOCATION: cls(
|
||||
strategy=CacheStrategy.TTL, ttl=None, max_size=1000 # 手动失效
|
||||
),
|
||||
CacheType.IP_INFO: cls(
|
||||
strategy=CacheStrategy.TTL, ttl=86400, max_size=1000 # 24小时
|
||||
),
|
||||
CacheType.WEATHER: cls(
|
||||
strategy=CacheStrategy.TTL, ttl=28800, max_size=1000 # 8小时
|
||||
),
|
||||
CacheType.LUNAR: cls(
|
||||
strategy=CacheStrategy.TTL, ttl=2592000, max_size=365 # 30天过期
|
||||
),
|
||||
CacheType.INTENT: cls(
|
||||
strategy=CacheStrategy.TTL_LRU, ttl=600, max_size=1000 # 10分钟
|
||||
),
|
||||
CacheType.CONFIG: cls(
|
||||
strategy=CacheStrategy.FIXED_SIZE, ttl=None, max_size=20 # 手动失效
|
||||
),
|
||||
CacheType.DEVICE_PROMPT: cls(
|
||||
strategy=CacheStrategy.TTL, ttl=None, max_size=1000 # 手动失效
|
||||
),
|
||||
}
|
||||
return configs.get(cache_type, cls())
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
全局缓存管理器
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
from typing import Any, Optional, Dict
|
||||
from collections import OrderedDict
|
||||
from .strategies import CacheStrategy, CacheEntry
|
||||
from .config import CacheConfig, CacheType
|
||||
|
||||
|
||||
class GlobalCacheManager:
|
||||
"""全局缓存管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self._logger = None
|
||||
self._caches: Dict[str, Dict[str, CacheEntry]] = {}
|
||||
self._configs: Dict[str, CacheConfig] = {}
|
||||
self._locks: Dict[str, threading.RLock] = {}
|
||||
self._global_lock = threading.RLock()
|
||||
self._last_cleanup = time.time()
|
||||
self._stats = {"hits": 0, "misses": 0, "evictions": 0, "cleanups": 0}
|
||||
|
||||
@property
|
||||
def logger(self):
|
||||
"""延迟初始化 logger 以避免循环导入"""
|
||||
if self._logger is None:
|
||||
from config.logger import setup_logging
|
||||
|
||||
self._logger = setup_logging()
|
||||
return self._logger
|
||||
|
||||
def _get_cache_name(self, cache_type: CacheType, namespace: str = "") -> str:
|
||||
"""生成缓存名称"""
|
||||
if namespace:
|
||||
return f"{cache_type.value}:{namespace}"
|
||||
return cache_type.value
|
||||
|
||||
def _get_or_create_cache(
|
||||
self, cache_name: str, config: CacheConfig
|
||||
) -> Dict[str, CacheEntry]:
|
||||
"""获取或创建缓存空间"""
|
||||
with self._global_lock:
|
||||
if cache_name not in self._caches:
|
||||
self._caches[cache_name] = (
|
||||
OrderedDict()
|
||||
if config.strategy in [CacheStrategy.LRU, CacheStrategy.TTL_LRU]
|
||||
else {}
|
||||
)
|
||||
self._configs[cache_name] = config
|
||||
self._locks[cache_name] = threading.RLock()
|
||||
return self._caches[cache_name]
|
||||
|
||||
def set(
|
||||
self,
|
||||
cache_type: CacheType,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: Optional[float] = None,
|
||||
namespace: str = "",
|
||||
) -> None:
|
||||
"""设置缓存值"""
|
||||
cache_name = self._get_cache_name(cache_type, namespace)
|
||||
config = self._configs.get(cache_name) or CacheConfig.for_type(cache_type)
|
||||
cache = self._get_or_create_cache(cache_name, config)
|
||||
|
||||
# 使用配置的TTL或传入的TTL
|
||||
effective_ttl = ttl if ttl is not None else config.ttl
|
||||
|
||||
with self._locks[cache_name]:
|
||||
# 创建缓存条目
|
||||
entry = CacheEntry(value=value, timestamp=time.time(), ttl=effective_ttl)
|
||||
|
||||
# 处理不同策略
|
||||
if config.strategy in [CacheStrategy.LRU, CacheStrategy.TTL_LRU]:
|
||||
# LRU策略:如果已存在则移动到末尾
|
||||
if key in cache:
|
||||
del cache[key]
|
||||
cache[key] = entry
|
||||
|
||||
# 检查大小限制
|
||||
if config.max_size and len(cache) > config.max_size:
|
||||
# 移除最旧的条目
|
||||
oldest_key = next(iter(cache))
|
||||
del cache[oldest_key]
|
||||
self._stats["evictions"] += 1
|
||||
|
||||
else:
|
||||
cache[key] = entry
|
||||
|
||||
# 检查大小限制
|
||||
if config.max_size and len(cache) > config.max_size:
|
||||
# 简单策略:随机移除一个条目
|
||||
victim_key = next(iter(cache))
|
||||
del cache[victim_key]
|
||||
self._stats["evictions"] += 1
|
||||
|
||||
# 定期清理过期条目
|
||||
self._maybe_cleanup(cache_name)
|
||||
|
||||
def get(
|
||||
self, cache_type: CacheType, key: str, namespace: str = ""
|
||||
) -> Optional[Any]:
|
||||
"""获取缓存值"""
|
||||
cache_name = self._get_cache_name(cache_type, namespace)
|
||||
|
||||
if cache_name not in self._caches:
|
||||
self._stats["misses"] += 1
|
||||
return None
|
||||
|
||||
cache = self._caches[cache_name]
|
||||
config = self._configs[cache_name]
|
||||
|
||||
with self._locks[cache_name]:
|
||||
if key not in cache:
|
||||
self._stats["misses"] += 1
|
||||
return None
|
||||
|
||||
entry = cache[key]
|
||||
|
||||
# 检查过期
|
||||
if entry.is_expired():
|
||||
del cache[key]
|
||||
self._stats["misses"] += 1
|
||||
return None
|
||||
|
||||
# 更新访问信息
|
||||
entry.touch()
|
||||
|
||||
# LRU策略:移动到末尾
|
||||
if config.strategy in [CacheStrategy.LRU, CacheStrategy.TTL_LRU]:
|
||||
del cache[key]
|
||||
cache[key] = entry
|
||||
|
||||
self._stats["hits"] += 1
|
||||
return entry.value
|
||||
|
||||
def delete(self, cache_type: CacheType, key: str, namespace: str = "") -> bool:
|
||||
"""删除缓存条目"""
|
||||
cache_name = self._get_cache_name(cache_type, namespace)
|
||||
|
||||
if cache_name not in self._caches:
|
||||
return False
|
||||
|
||||
cache = self._caches[cache_name]
|
||||
|
||||
with self._locks[cache_name]:
|
||||
if key in cache:
|
||||
del cache[key]
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear(self, cache_type: CacheType, namespace: str = "") -> None:
|
||||
"""清空指定缓存"""
|
||||
cache_name = self._get_cache_name(cache_type, namespace)
|
||||
|
||||
if cache_name not in self._caches:
|
||||
return
|
||||
|
||||
with self._locks[cache_name]:
|
||||
self._caches[cache_name].clear()
|
||||
|
||||
def invalidate_pattern(
|
||||
self, cache_type: CacheType, pattern: str, namespace: str = ""
|
||||
) -> int:
|
||||
"""按模式失效缓存条目"""
|
||||
cache_name = self._get_cache_name(cache_type, namespace)
|
||||
|
||||
if cache_name not in self._caches:
|
||||
return 0
|
||||
|
||||
cache = self._caches[cache_name]
|
||||
deleted_count = 0
|
||||
|
||||
with self._locks[cache_name]:
|
||||
keys_to_delete = [key for key in cache.keys() if pattern in key]
|
||||
for key in keys_to_delete:
|
||||
del cache[key]
|
||||
deleted_count += 1
|
||||
|
||||
return deleted_count
|
||||
|
||||
def _cleanup_expired(self, cache_name: str) -> int:
|
||||
"""清理过期条目"""
|
||||
if cache_name not in self._caches:
|
||||
return 0
|
||||
|
||||
cache = self._caches[cache_name]
|
||||
deleted_count = 0
|
||||
|
||||
with self._locks[cache_name]:
|
||||
expired_keys = [key for key, entry in cache.items() if entry.is_expired()]
|
||||
for key in expired_keys:
|
||||
del cache[key]
|
||||
deleted_count += 1
|
||||
|
||||
return deleted_count
|
||||
|
||||
def _maybe_cleanup(self, cache_name: str):
|
||||
"""定期清理检查"""
|
||||
config = self._configs.get(cache_name)
|
||||
if not config:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
if now - self._last_cleanup > config.cleanup_interval:
|
||||
self._last_cleanup = now
|
||||
deleted = self._cleanup_expired(cache_name)
|
||||
if deleted > 0:
|
||||
self._stats["cleanups"] += 1
|
||||
self.logger.debug(f"清理缓存 {cache_name}: 删除 {deleted} 个过期条目")
|
||||
|
||||
|
||||
# 创建全局缓存管理器实例
|
||||
cache_manager = GlobalCacheManager()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
缓存策略和数据结构定义
|
||||
"""
|
||||
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class CacheStrategy(Enum):
|
||||
"""缓存策略枚举"""
|
||||
|
||||
TTL = "ttl" # 基于时间过期
|
||||
LRU = "lru" # 最近最少使用
|
||||
FIXED_SIZE = "fixed_size" # 固定大小
|
||||
TTL_LRU = "ttl_lru" # TTL + LRU混合策略
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheEntry:
|
||||
"""缓存条目数据结构"""
|
||||
|
||||
value: Any
|
||||
timestamp: float
|
||||
ttl: Optional[float] = None # 生存时间(秒)
|
||||
access_count: int = 0
|
||||
last_access: float = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.last_access is None:
|
||||
self.last_access = self.timestamp
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""检查是否过期"""
|
||||
if self.ttl is None:
|
||||
return False
|
||||
return time.time() - self.timestamp > self.ttl
|
||||
|
||||
def touch(self):
|
||||
"""更新访问时间和计数"""
|
||||
self.last_access = time.time()
|
||||
self.access_count += 1
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
import re
|
||||
from typing import List, Dict
|
||||
from datetime import datetime
|
||||
from config.settings import load_config
|
||||
@@ -74,13 +75,6 @@ class Dialogue:
|
||||
# 基础系统提示
|
||||
enhanced_system_prompt = system_message.content
|
||||
|
||||
# 添加说话人识别功能说明
|
||||
speaker_guidance = "\n\n[说话人识别功能说明]\n" \
|
||||
"当用户消息为JSON格式包含speaker字段时(如:{\"speaker\": \"张三\", \"content\": \"消息内容\"}),表示系统已识别出说话人身份。\n" \
|
||||
"请根据说话人的身份特征来调整回应风格和内容。\n" \
|
||||
"你可以称呼说话人的名字,并参考他们的特点进行个性化回应。"
|
||||
enhanced_system_prompt += speaker_guidance
|
||||
|
||||
# 添加说话人个性化描述
|
||||
try:
|
||||
config = load_config()
|
||||
@@ -88,7 +82,7 @@ class Dialogue:
|
||||
speakers = voiceprint_config.get("speakers", [])
|
||||
|
||||
if speakers:
|
||||
enhanced_system_prompt += "\n\n[已知说话人信息]"
|
||||
enhanced_system_prompt += "\n\n<speaker>"
|
||||
for speaker_str in speakers:
|
||||
try:
|
||||
parts = speaker_str.split(",", 2)
|
||||
@@ -99,15 +93,19 @@ class Dialogue:
|
||||
description = parts[2].strip() if len(parts) >= 3 else ""
|
||||
enhanced_system_prompt += f"\n- {name}:{description}"
|
||||
except:
|
||||
continue
|
||||
pass
|
||||
enhanced_system_prompt += "\n\n</speaker>"
|
||||
except:
|
||||
# 配置读取失败时忽略错误,不影响其他功能
|
||||
pass
|
||||
|
||||
# 只有当有记忆时才添加记忆部分
|
||||
if memory_str and len(memory_str) > 0:
|
||||
enhanced_system_prompt += f"\n\n以下是用户的历史记忆:\n```\n{memory_str}\n```"
|
||||
|
||||
# 使用正则表达式匹配 <memory> 标签,不管中间有什么内容
|
||||
enhanced_system_prompt = re.sub(
|
||||
r"<memory>.*?</memory>",
|
||||
f"<memory>\n{memory_str}\n</memory>",
|
||||
system_message.content,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
dialogue.append({"role": "system", "content": enhanced_system_prompt})
|
||||
|
||||
# 添加用户和助手的对话
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
系统提示词管理器模块
|
||||
负责管理和更新系统提示词,包括快速初始化和异步增强功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import cnlunar
|
||||
from typing import Dict, Any
|
||||
from config.logger import setup_logging
|
||||
|
||||
TAG = __name__
|
||||
|
||||
WEEKDAY_MAP = {
|
||||
"Monday": "星期一",
|
||||
"Tuesday": "星期二",
|
||||
"Wednesday": "星期三",
|
||||
"Thursday": "星期四",
|
||||
"Friday": "星期五",
|
||||
"Saturday": "星期六",
|
||||
"Sunday": "星期日",
|
||||
}
|
||||
|
||||
|
||||
class PromptManager:
|
||||
"""系统提示词管理器,负责管理和更新系统提示词"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], logger=None):
|
||||
self.config = config
|
||||
self.logger = logger or setup_logging()
|
||||
self.base_prompt_template = None
|
||||
self.last_update_time = 0
|
||||
|
||||
# 导入全局缓存管理器
|
||||
from core.utils.cache.manager import cache_manager, CacheType
|
||||
|
||||
self.cache_manager = cache_manager
|
||||
self.CacheType = CacheType
|
||||
|
||||
self._load_base_template()
|
||||
|
||||
def _load_base_template(self):
|
||||
"""加载基础提示词模板"""
|
||||
try:
|
||||
template_path = "agent-base-prompt.txt"
|
||||
cache_key = f"prompt_template:{template_path}"
|
||||
|
||||
# 先从缓存获取
|
||||
cached_template = self.cache_manager.get(self.CacheType.CONFIG, cache_key)
|
||||
if cached_template is not None:
|
||||
self.base_prompt_template = cached_template
|
||||
self.logger.bind(tag=TAG).debug("从缓存加载基础提示词模板")
|
||||
return
|
||||
|
||||
# 缓存未命中,从文件读取
|
||||
if os.path.exists(template_path):
|
||||
with open(template_path, "r", encoding="utf-8") as f:
|
||||
template_content = f.read()
|
||||
|
||||
# 存入缓存(CONFIG类型默认不自动过期,需要手动失效)
|
||||
self.cache_manager.set(
|
||||
self.CacheType.CONFIG, cache_key, template_content
|
||||
)
|
||||
self.base_prompt_template = template_content
|
||||
self.logger.bind(tag=TAG).debug("成功加载基础提示词模板并缓存")
|
||||
else:
|
||||
self.logger.bind(tag=TAG).warning("未找到agent-base-prompt.txt文件")
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"加载提示词模板失败: {e}")
|
||||
|
||||
def get_quick_prompt(self, user_prompt: str, device_id: str = None) -> str:
|
||||
"""快速获取系统提示词(使用用户配置)"""
|
||||
device_cache_key = f"device_prompt:{device_id}"
|
||||
cached_device_prompt = self.cache_manager.get(
|
||||
self.CacheType.DEVICE_PROMPT, device_cache_key
|
||||
)
|
||||
if cached_device_prompt is not None:
|
||||
self.logger.bind(tag=TAG).debug(f"使用设备 {device_id} 的缓存提示词")
|
||||
return cached_device_prompt
|
||||
else:
|
||||
self.logger.bind(tag=TAG).debug(
|
||||
f"设备 {device_id} 无缓存提示词,使用传入的提示词"
|
||||
)
|
||||
|
||||
# 使用传入的提示词并缓存(如果有设备ID)
|
||||
if device_id:
|
||||
device_cache_key = f"device_prompt:{device_id}"
|
||||
self.cache_manager.set(self.CacheType.CONFIG, device_cache_key, user_prompt)
|
||||
self.logger.bind(tag=TAG).debug(f"设备 {device_id} 的提示词已缓存")
|
||||
|
||||
self.logger.bind(tag=TAG).info(f"使用快速提示词: {user_prompt[:50]}...")
|
||||
return user_prompt
|
||||
|
||||
def _get_current_time_info(self) -> tuple:
|
||||
"""获取当前时间信息"""
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now()
|
||||
current_time = now.strftime("%H:%M")
|
||||
today_date = now.strftime("%Y-%m-%d")
|
||||
today_weekday = WEEKDAY_MAP[now.strftime("%A")]
|
||||
today_lunar = cnlunar.Lunar(now, godType="8char")
|
||||
lunar_date = "%s年%s%s\n" % (
|
||||
today_lunar.lunarYearCn,
|
||||
today_lunar.lunarMonthCn[:-1],
|
||||
today_lunar.lunarDayCn,
|
||||
)
|
||||
|
||||
return current_time, today_date, today_weekday, lunar_date
|
||||
|
||||
def _get_location_info(self, client_ip: str) -> str:
|
||||
"""获取位置信息"""
|
||||
try:
|
||||
# 先从缓存获取
|
||||
cached_location = self.cache_manager.get(self.CacheType.LOCATION, client_ip)
|
||||
if cached_location is not None:
|
||||
return cached_location
|
||||
|
||||
# 缓存未命中,调用API获取
|
||||
from core.utils.util import get_ip_info
|
||||
|
||||
ip_info = get_ip_info(client_ip, self.logger)
|
||||
city = ip_info.get("city", "未知位置")
|
||||
location = f"{city}"
|
||||
|
||||
# 存入缓存
|
||||
self.cache_manager.set(self.CacheType.LOCATION, client_ip, location)
|
||||
return location
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"获取位置信息失败: {e}")
|
||||
return "未知位置"
|
||||
|
||||
def _get_weather_info(self, conn, location: str) -> str:
|
||||
"""获取天气信息"""
|
||||
try:
|
||||
# 先从缓存获取
|
||||
cached_weather = self.cache_manager.get(self.CacheType.WEATHER, location)
|
||||
if cached_weather is not None:
|
||||
return cached_weather
|
||||
|
||||
# 缓存未命中,调用get_weather函数获取
|
||||
from plugins_func.functions.get_weather import get_weather
|
||||
from plugins_func.register import ActionResponse
|
||||
|
||||
# 调用get_weather函数
|
||||
result = get_weather(conn, location=location, lang="zh_CN")
|
||||
if isinstance(result, ActionResponse):
|
||||
weather_report = result.result
|
||||
self.cache_manager.set(self.CacheType.WEATHER, location, weather_report)
|
||||
return weather_report
|
||||
return "天气信息获取失败"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"获取天气信息失败: {e}")
|
||||
return "天气信息获取失败"
|
||||
|
||||
def update_context_info(self, conn, client_ip: str):
|
||||
"""同步更新上下文信息"""
|
||||
try:
|
||||
# 获取位置信息(使用全局缓存)
|
||||
local_address = self._get_location_info(client_ip)
|
||||
# 获取天气信息(使用全局缓存)
|
||||
self._get_weather_info(conn, local_address)
|
||||
self.logger.bind(tag=TAG).info(f"上下文信息更新完成")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"更新上下文信息失败: {e}")
|
||||
|
||||
def build_enhanced_prompt(
|
||||
self, user_prompt: str, device_id: str, client_ip: str = None
|
||||
) -> str:
|
||||
"""构建增强的系统提示词"""
|
||||
if not self.base_prompt_template:
|
||||
return user_prompt
|
||||
|
||||
try:
|
||||
# 获取最新的时间信息(不缓存)
|
||||
current_time, today_date, today_weekday, lunar_date = (
|
||||
self._get_current_time_info()
|
||||
)
|
||||
|
||||
# 获取缓存的上下文信息
|
||||
local_address = ""
|
||||
weather_info = ""
|
||||
|
||||
if client_ip:
|
||||
# 获取位置信息(从全局缓存)
|
||||
local_address = (
|
||||
self.cache_manager.get(self.CacheType.LOCATION, client_ip) or ""
|
||||
)
|
||||
|
||||
# 获取天气信息(从全局缓存)
|
||||
if local_address:
|
||||
weather_info = (
|
||||
self.cache_manager.get(self.CacheType.WEATHER, local_address)
|
||||
or ""
|
||||
)
|
||||
|
||||
# 替换模板变量
|
||||
enhanced_prompt = self.base_prompt_template.format(
|
||||
base_prompt=user_prompt,
|
||||
current_time=current_time,
|
||||
today_date=today_date,
|
||||
today_weekday=today_weekday,
|
||||
lunar_date=lunar_date,
|
||||
local_address=local_address,
|
||||
weather_info=weather_info,
|
||||
)
|
||||
device_cache_key = f"device_prompt:{device_id}"
|
||||
self.cache_manager.set(
|
||||
self.CacheType.DEVICE_PROMPT, device_cache_key, enhanced_prompt
|
||||
)
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"构建增强提示词成功,长度: {len(enhanced_prompt)}"
|
||||
)
|
||||
return enhanced_prompt
|
||||
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"构建增强提示词失败: {e}")
|
||||
return user_prompt
|
||||
@@ -96,11 +96,23 @@ def is_private_ip(ip_addr):
|
||||
|
||||
def get_ip_info(ip_addr, logger):
|
||||
try:
|
||||
# 导入全局缓存管理器
|
||||
from core.utils.cache.manager import cache_manager, CacheType
|
||||
|
||||
# 先从缓存获取
|
||||
cached_ip_info = cache_manager.get(CacheType.IP_INFO, ip_addr)
|
||||
if cached_ip_info is not None:
|
||||
return cached_ip_info
|
||||
|
||||
# 缓存未命中,调用API
|
||||
if is_private_ip(ip_addr):
|
||||
ip_addr = ""
|
||||
url = f"https://whois.pconline.com.cn/ipJson.jsp?json=true&ip={ip_addr}"
|
||||
resp = requests.get(url).json()
|
||||
ip_info = {"city": resp.get("city")}
|
||||
|
||||
# 存入缓存
|
||||
cache_manager.set(CacheType.IP_INFO, ip_addr, ip_info)
|
||||
return ip_info
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"Error getting client ip info: {e}")
|
||||
@@ -982,3 +994,28 @@ def sanitize_tool_name(name: str) -> str:
|
||||
"""Sanitize tool names for OpenAI compatibility."""
|
||||
# 支持中文、英文字母、数字、下划线和连字符
|
||||
return re.sub(r"[^a-zA-Z0-9_\-\u4e00-\u9fff]", "_", name)
|
||||
|
||||
|
||||
def validate_mcp_endpoint(mcp_endpoint: str) -> bool:
|
||||
"""
|
||||
校验MCP接入点格式
|
||||
|
||||
Args:
|
||||
mcp_endpoint: MCP接入点字符串
|
||||
|
||||
Returns:
|
||||
bool: 是否有效
|
||||
"""
|
||||
# 1. 检查是否以ws开头
|
||||
if not mcp_endpoint.startswith("ws"):
|
||||
return False
|
||||
|
||||
# 2. 检查是否包含key、call字样
|
||||
if "key" in mcp_endpoint.lower() or "call" in mcp_endpoint.lower():
|
||||
return False
|
||||
|
||||
# 3. 检查是否包含/mcp/字样
|
||||
if "/mcp/" not in mcp_endpoint:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -57,8 +57,25 @@ class WebSocketServer:
|
||||
self.active_connections.add(handler)
|
||||
try:
|
||||
await handler.handle_connection(websocket)
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"处理连接时出错: {e}")
|
||||
finally:
|
||||
# 确保从活动连接集合中移除
|
||||
self.active_connections.discard(handler)
|
||||
# 强制关闭连接(如果还没有关闭的话)
|
||||
try:
|
||||
# 安全地检查WebSocket状态并关闭
|
||||
if hasattr(websocket, "closed") and not websocket.closed:
|
||||
await websocket.close()
|
||||
elif hasattr(websocket, "state") and websocket.state.name != "CLOSED":
|
||||
await websocket.close()
|
||||
else:
|
||||
# 如果没有closed属性,直接尝试关闭
|
||||
await websocket.close()
|
||||
except Exception as close_error:
|
||||
self.logger.bind(tag=TAG).error(
|
||||
f"服务器端强制关闭连接时出错: {close_error}"
|
||||
)
|
||||
|
||||
async def _http_response(self, websocket, request_headers):
|
||||
# 检查是否为 WebSocket 升级请求
|
||||
|
||||
@@ -2,59 +2,27 @@ from datetime import datetime
|
||||
import cnlunar
|
||||
from plugins_func.register import register_function, ToolType, ActionResponse, Action
|
||||
|
||||
# 添加星期映射字典
|
||||
WEEKDAY_MAP = {
|
||||
"Monday": "星期一",
|
||||
"Tuesday": "星期二",
|
||||
"Wednesday": "星期三",
|
||||
"Thursday": "星期四",
|
||||
"Friday": "星期五",
|
||||
"Saturday": "星期六",
|
||||
"Sunday": "星期日",
|
||||
}
|
||||
|
||||
get_time_function_desc = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_time",
|
||||
"description": "获取今天日期或者当前时间信息",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@register_function("get_time", get_time_function_desc, ToolType.WAIT)
|
||||
def get_time():
|
||||
"""
|
||||
获取当前的日期时间信息
|
||||
"""
|
||||
now = datetime.now()
|
||||
current_time = now.strftime("%H:%M:%S")
|
||||
current_date = now.strftime("%Y-%m-%d")
|
||||
current_weekday = WEEKDAY_MAP[now.strftime("%A")]
|
||||
response_text = (
|
||||
f"当前日期: {current_date},当前时间: {current_time}, {current_weekday}"
|
||||
)
|
||||
|
||||
return ActionResponse(Action.REQLLM, response_text, None)
|
||||
|
||||
|
||||
get_lunar_function_desc = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_lunar",
|
||||
"description": (
|
||||
"用于获取今天的阴历/农历和黄历信息。"
|
||||
"用于具体日期的阴历/农历和黄历信息。"
|
||||
"用户可以指定查询内容,如:阴历日期、天干地支、节气、生肖、星座、八字、宜忌等。"
|
||||
"如果没有指定查询内容,则默认查询干支年和农历日期。"
|
||||
"对于'今天农历是多少'、'今天农历日期'这样的基本查询,请直接使用context中的信息,不要调用此工具。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string",
|
||||
"description": "要查询的日期,格式为YYYY-MM-DD,例如2024-01-01。如果不提供,则使用当前日期",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "要查询的内容,例如阴历日期、天干地支、节日、节气、生肖、星座、八字、宜忌等",
|
||||
}
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
@@ -63,23 +31,41 @@ get_lunar_function_desc = {
|
||||
|
||||
|
||||
@register_function("get_lunar", get_lunar_function_desc, ToolType.WAIT)
|
||||
def get_lunar(query=None):
|
||||
def get_lunar(date=None, query=None):
|
||||
"""
|
||||
用于获取当前的阴历/农历,和天干地支、节气、生肖、星座、八字、宜忌等黄历信息
|
||||
"""
|
||||
now = datetime.now()
|
||||
current_time = now.strftime("%H:%M:%S")
|
||||
from core.utils.cache.manager import cache_manager, CacheType
|
||||
|
||||
# 如果提供了日期参数,则使用指定日期;否则使用当前日期
|
||||
if date:
|
||||
try:
|
||||
now = datetime.strptime(date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return ActionResponse(
|
||||
Action.REQLLM,
|
||||
f"日期格式错误,请使用YYYY-MM-DD格式,例如:2024-01-01",
|
||||
None,
|
||||
)
|
||||
else:
|
||||
now = datetime.now()
|
||||
|
||||
current_date = now.strftime("%Y-%m-%d")
|
||||
current_weekday = WEEKDAY_MAP[now.strftime("%A")]
|
||||
|
||||
# 如果 query 为 None,则使用默认文本
|
||||
if query is None:
|
||||
query = "默认查询干支年和农历日期"
|
||||
|
||||
# 尝试从缓存获取农历信息
|
||||
lunar_cache_key = f"lunar_info_{current_date}"
|
||||
cached_lunar_info = cache_manager.get(CacheType.LUNAR, lunar_cache_key)
|
||||
if cached_lunar_info:
|
||||
return ActionResponse(Action.REQLLM, cached_lunar_info, None)
|
||||
|
||||
response_text = f"根据以下信息回应用户的查询请求,并提供与{query}相关的信息:\n"
|
||||
|
||||
lunar = cnlunar.Lunar(now, godType="8char")
|
||||
response_text += (
|
||||
f"当前公历日期: {current_date},当前时间: {current_time},{current_weekday}\n"
|
||||
"农历信息:\n"
|
||||
"%s年%s%s\n" % (lunar.lunarYearCn, lunar.lunarMonthCn[:-1], lunar.lunarDayCn)
|
||||
+ "干支: %s年 %s月 %s日\n" % (lunar.year8Char, lunar.month8Char, lunar.day8Char)
|
||||
@@ -135,4 +121,7 @@ def get_lunar(query=None):
|
||||
+ "(默认返回干支年和农历日期;仅在要求查询宜忌信息时才返回本日宜忌)"
|
||||
)
|
||||
|
||||
# 缓存农历信息
|
||||
cache_manager.set(CacheType.LUNAR, lunar_cache_key, response_text)
|
||||
|
||||
return ActionResponse(Action.REQLLM, response_text, None)
|
||||
|
||||
@@ -151,20 +151,44 @@ def parse_weather_info(soup):
|
||||
|
||||
@register_function("get_weather", GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL)
|
||||
def get_weather(conn, location: str = None, lang: str = "zh_CN"):
|
||||
api_host = conn.config["plugins"]["get_weather"].get("api_host", "mj7p3y7naa.re.qweatherapi.com")
|
||||
api_key = conn.config["plugins"]["get_weather"].get("api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da")
|
||||
from core.utils.cache.manager import cache_manager, CacheType
|
||||
|
||||
api_host = conn.config["plugins"]["get_weather"].get(
|
||||
"api_host", "mj7p3y7naa.re.qweatherapi.com"
|
||||
)
|
||||
api_key = conn.config["plugins"]["get_weather"].get(
|
||||
"api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da"
|
||||
)
|
||||
default_location = conn.config["plugins"]["get_weather"]["default_location"]
|
||||
client_ip = conn.client_ip
|
||||
|
||||
# 优先使用用户提供的location参数
|
||||
if not location:
|
||||
# 通过客户端IP解析城市
|
||||
if client_ip:
|
||||
# 动态解析IP对应的城市信息
|
||||
ip_info = get_ip_info(client_ip, logger)
|
||||
location = ip_info.get("city") if ip_info and "city" in ip_info else None
|
||||
# 先从缓存获取IP对应的城市信息
|
||||
cached_ip_info = cache_manager.get(CacheType.IP_INFO, client_ip)
|
||||
if cached_ip_info:
|
||||
location = cached_ip_info.get("city")
|
||||
else:
|
||||
# 缓存未命中,调用API获取
|
||||
ip_info = get_ip_info(client_ip, logger)
|
||||
if ip_info:
|
||||
cache_manager.set(CacheType.IP_INFO, client_ip, ip_info)
|
||||
location = ip_info.get("city")
|
||||
|
||||
if not location:
|
||||
location = default_location
|
||||
else:
|
||||
# 若IP解析失败或无IP,使用默认位置
|
||||
# 若无IP,使用默认位置
|
||||
location = default_location
|
||||
# 尝试从缓存获取完整天气报告
|
||||
weather_cache_key = f"full_weather_{location}_{lang}"
|
||||
cached_weather_report = cache_manager.get(CacheType.WEATHER, weather_cache_key)
|
||||
if cached_weather_report:
|
||||
return ActionResponse(Action.REQLLM, cached_weather_report, None)
|
||||
|
||||
# 缓存未命中,获取实时天气数据
|
||||
city_info = fetch_city_info(location, api_key, api_host)
|
||||
if not city_info:
|
||||
return ActionResponse(
|
||||
@@ -192,4 +216,7 @@ def get_weather(conn, location: str = None, lang: str = "zh_CN"):
|
||||
# 提示语
|
||||
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
|
||||
|
||||
# 缓存完整的天气报告
|
||||
cache_manager.set(CacheType.WEATHER, weather_cache_key, weather_report)
|
||||
|
||||
return ActionResponse(Action.REQLLM, weather_report, None)
|
||||
|
||||
@@ -21,7 +21,7 @@ cozepy==0.12.0
|
||||
mem0ai==0.1.62
|
||||
bs4==0.0.2
|
||||
modelscope==1.23.2
|
||||
sherpa_onnx==1.12.0
|
||||
sherpa_onnx==1.12.4
|
||||
mcp==1.8.1
|
||||
cnlunar==0.2.0
|
||||
PySocks==1.7.1
|
||||
|
||||
Reference in New Issue
Block a user