From 599ce19ace3997936d5a4464ab17101860b0cf18 Mon Sep 17 00:00:00 2001 From: caixypromise Date: Thu, 29 May 2025 00:58:20 +0800 Subject: [PATCH 01/91] =?UTF-8?q?feat:=20=E6=99=BA=E6=8E=A7=E5=8F=B0?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E7=BA=A7=E6=8F=92=E4=BB=B6/?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E6=94=B9=E9=80=A0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增支持从控制台控制大模型插件工具与配置插件工具的管理能力。 关联issue: issue(#1358) --- .../xiaozhi/common/utils/ResultUtils.java | 31 +++ .../agent/controller/AgentController.java | 95 ++------ .../xiaozhi/modules/agent/dao/AgentDao.java | 8 + .../agent/dao/AgentPluginMappingMapper.java | 22 ++ .../modules/agent/dto/AgentUpdateDTO.java | 49 ++-- .../agent/entity/AgentPluginMapping.java | 53 +++++ .../service/AgentPluginMappingService.java | 15 ++ .../modules/agent/service/AgentService.java | 6 +- .../impl/AgentPluginMappingServiceImpl.java | 32 +++ .../agent/service/impl/AgentServiceImpl.java | 174 +++++++++++++- .../xiaozhi/modules/agent/vo/AgentInfoVO.java | 24 ++ .../config/controller/ConfigController.java | 2 +- .../service/impl/ConfigServiceImpl.java | 22 +- .../controller/ModelProviderController.java | 7 + .../model/service/ModelProviderService.java | 5 + .../impl/ModelProviderServiceImpl.java | 24 +- .../resources/db/changelog/202505232203.sql | 181 ++++++++++++++ .../db/changelog/db.changelog-master.yaml | 9 +- .../main/resources/mapper/agent/AgentDao.xml | 67 ++++++ .../mapper/agent/AgentPluginMappingMapper.xml | 48 ++++ main/manager-web/src/apis/module/model.js | 16 ++ .../src/components/FunctionDialog.vue | 224 ++++++++++++++---- main/manager-web/src/views/roleConfig.vue | 176 +++++++++----- main/xiaozhi-server/core/connection.py | 12 +- 24 files changed, 1067 insertions(+), 235 deletions(-) create mode 100644 main/manager-api/src/main/java/xiaozhi/common/utils/ResultUtils.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentPluginMappingMapper.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentPluginMapping.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentPluginMappingService.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentPluginMappingServiceImpl.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java create mode 100644 main/manager-api/src/main/resources/db/changelog/202505232203.sql create mode 100644 main/manager-api/src/main/resources/mapper/agent/AgentPluginMappingMapper.xml diff --git a/main/manager-api/src/main/java/xiaozhi/common/utils/ResultUtils.java b/main/manager-api/src/main/java/xiaozhi/common/utils/ResultUtils.java new file mode 100644 index 00000000..3f9fe27d --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/common/utils/ResultUtils.java @@ -0,0 +1,31 @@ +package xiaozhi.common.utils; + +/** + * 返回响应体工具类 + */ +public class ResultUtils +{ + public static Result success(T data) { + return new Result().ok(data); + } + + public static Result error() { + return new Result().error(); + } + + public static Result error(String msg) { + return new Result().error(msg); + } + + public static Result error(int errorCode, String msg) { + return new Result().error(errorCode, msg); + } + + public static Result error(int errorCode) { + return new Result().error(errorCode); + } + + public static Result empty() { + return new Result(); + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java index 54f3fa8c..c9cefce8 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java @@ -4,6 +4,7 @@ import java.util.Date; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.apache.shiro.authz.annotation.RequiresPermissions; @@ -34,7 +35,9 @@ import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.user.UserDetail; import xiaozhi.common.utils.ConvertUtils; +import xiaozhi.common.utils.JsonUtils; import xiaozhi.common.utils.Result; +import xiaozhi.common.utils.ResultUtils; import xiaozhi.modules.agent.dto.AgentChatHistoryDTO; import xiaozhi.modules.agent.dto.AgentChatSessionDTO; import xiaozhi.modules.agent.dto.AgentCreateDTO; @@ -42,13 +45,14 @@ import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentMemoryDTO; import xiaozhi.modules.agent.dto.AgentUpdateDTO; import xiaozhi.modules.agent.entity.AgentEntity; +import xiaozhi.modules.agent.entity.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentTemplateEntity; -import xiaozhi.modules.agent.service.AgentChatAudioService; -import xiaozhi.modules.agent.service.AgentChatHistoryService; -import xiaozhi.modules.agent.service.AgentService; -import xiaozhi.modules.agent.service.AgentTemplateService; +import xiaozhi.modules.agent.service.*; +import xiaozhi.modules.agent.vo.AgentInfoVO; import xiaozhi.modules.device.entity.DeviceEntity; import xiaozhi.modules.device.service.DeviceService; +import xiaozhi.modules.model.dto.ModelProviderDTO; +import xiaozhi.modules.model.service.ModelProviderService; import xiaozhi.modules.security.user.SecurityUser; @Tag(name = "智能体管理") @@ -88,9 +92,9 @@ public class AgentController { @GetMapping("/{id}") @Operation(summary = "获取智能体详情") @RequiresPermissions("sys:role:normal") - public Result getAgentById(@PathVariable("id") String id) { - AgentEntity agent = agentService.getAgentById(id); - return new Result().ok(agent); + public Result getAgentById(@PathVariable("id") String id) { + AgentInfoVO agent = agentService.getAgentById(id); + return ResultUtils.success(agent); } @PostMapping @@ -138,88 +142,19 @@ public class AgentController { } AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO(); agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory()); - return updateAgentById(device.getAgentId(), agentUpdateDTO); + agentService.updateAgentById(device.getAgentId(), agentUpdateDTO); + return new Result<>(); } @PutMapping("/{id}") @Operation(summary = "更新智能体") @RequiresPermissions("sys:role:normal") public Result update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) { - return updateAgentById(id, dto); - } - - private Result updateAgentById(String id, AgentUpdateDTO dto) { - // 先查询现有实体 - AgentEntity existingEntity = agentService.getAgentById(id); - if (existingEntity == null) { - return new Result().error("智能体不存在"); - } - - // 只更新提供的非空字段 - if (dto.getAgentName() != null) { - existingEntity.setAgentName(dto.getAgentName()); - } - if (dto.getAgentCode() != null) { - existingEntity.setAgentCode(dto.getAgentCode()); - } - if (dto.getAsrModelId() != null) { - existingEntity.setAsrModelId(dto.getAsrModelId()); - } - if (dto.getVadModelId() != null) { - existingEntity.setVadModelId(dto.getVadModelId()); - } - if (dto.getLlmModelId() != null) { - existingEntity.setLlmModelId(dto.getLlmModelId()); - } - if (dto.getTtsModelId() != null) { - existingEntity.setTtsModelId(dto.getTtsModelId()); - } - if (dto.getTtsVoiceId() != null) { - existingEntity.setTtsVoiceId(dto.getTtsVoiceId()); - } - if (dto.getMemModelId() != null) { - existingEntity.setMemModelId(dto.getMemModelId()); - } - if (dto.getIntentModelId() != null) { - existingEntity.setIntentModelId(dto.getIntentModelId()); - } - if (dto.getSystemPrompt() != null) { - existingEntity.setSystemPrompt(dto.getSystemPrompt()); - } - if (dto.getSummaryMemory() != null) { - existingEntity.setSummaryMemory(dto.getSummaryMemory()); - } - if (dto.getChatHistoryConf() != null) { - existingEntity.setChatHistoryConf(dto.getChatHistoryConf()); - } - if (dto.getLangCode() != null) { - existingEntity.setLangCode(dto.getLangCode()); - } - if (dto.getLanguage() != null) { - existingEntity.setLanguage(dto.getLanguage()); - } - if (dto.getSort() != null) { - existingEntity.setSort(dto.getSort()); - } - - // 设置更新者信息 - UserDetail user = SecurityUser.getUser(); - existingEntity.setUpdater(user.getId()); - existingEntity.setUpdatedAt(new Date()); - - // 更新记忆策略 - if (existingEntity.getMemModelId() == null || existingEntity.getMemModelId().equals(Constant.MEMORY_NO_MEM)) { - // 删除所有记录 - agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, true); - existingEntity.setSummaryMemory(""); - } else if (existingEntity.getChatHistoryConf() != null && existingEntity.getChatHistoryConf() == 1) { - // 删除音频数据 - agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, false); - } - agentService.updateById(existingEntity); + agentService.updateAgentById(id, dto); return new Result<>(); } + @DeleteMapping("/{id}") @Operation(summary = "删除智能体") @RequiresPermissions("sys:role:normal") diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java index 3b4be452..03e569cd 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java @@ -6,6 +6,7 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import xiaozhi.common.dao.BaseDao; import xiaozhi.modules.agent.entity.AgentEntity; +import xiaozhi.modules.agent.vo.AgentInfoVO; @Mapper public interface AgentDao extends BaseDao { @@ -28,4 +29,11 @@ public interface AgentDao extends BaseDao { " WHERE d.mac_address = #{macAddress} " + " ORDER BY d.id DESC LIMIT 1") AgentEntity getDefaultAgentByMacAddress(@Param("macAddress") String macAddress); + + /** + * 根据id查询agent信息,包括插件信息 + * + * @param agentId 智能体ID + */ + AgentInfoVO selectAgentInfoById(@Param("agentId") String agentId); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentPluginMappingMapper.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentPluginMappingMapper.java new file mode 100644 index 00000000..2fe81753 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentPluginMappingMapper.java @@ -0,0 +1,22 @@ +package xiaozhi.modules.agent.dao; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import xiaozhi.modules.agent.entity.AgentPluginMapping; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +import java.util.List; + +/** +* @description 针对表【ai_agent_plugin_mapping(Agent与插件的唯一映射表)】的数据库操作Mapper +* @createDate 2025-05-25 22:33:17 +* @Entity xiaozhi.modules.agent.entity.AgentPluginMapping +*/ +@Mapper +public interface AgentPluginMappingMapper extends BaseMapper { + List selectPluginsByAgentId(@Param("agentId") String agentId); +} + + + + diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java index ccfbd53f..dabcf322 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java @@ -1,6 +1,9 @@ package xiaozhi.modules.agent.dto; +import java.io.Serial; import java.io.Serializable; +import java.util.HashMap; +import java.util.List; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -15,49 +18,63 @@ import lombok.Data; public class AgentUpdateDTO implements Serializable { private static final long serialVersionUID = 1L; - @Schema(description = "智能体编码", example = "AGT_1234567890", required = false) + @Schema(description = "智能体编码", example = "AGT_1234567890", nullable = true) private String agentCode; - @Schema(description = "智能体名称", example = "客服助手", required = false) + @Schema(description = "智能体名称", example = "客服助手", nullable = true) private String agentName; - @Schema(description = "语音识别模型标识", example = "asr_model_02", required = false) + @Schema(description = "语音识别模型标识", example = "asr_model_02", nullable = true) private String asrModelId; - @Schema(description = "语音活动检测标识", example = "vad_model_02", required = false) + @Schema(description = "语音活动检测标识", example = "vad_model_02", nullable = true) private String vadModelId; - @Schema(description = "大语言模型标识", example = "llm_model_02", required = false) + @Schema(description = "大语言模型标识", example = "llm_model_02", nullable = true) private String llmModelId; - @Schema(description = "语音合成模型标识", example = "tts_model_02", required = false) + @Schema(description = "语音合成模型标识", example = "tts_model_02", nullable = true) private String ttsModelId; - @Schema(description = "音色标识", example = "voice_02", required = false) + @Schema(description = "音色标识", example = "voice_02", nullable = true) private String ttsVoiceId; - @Schema(description = "记忆模型标识", example = "mem_model_02", required = false) + @Schema(description = "记忆模型标识", example = "mem_model_02", nullable = true) private String memModelId; - @Schema(description = "意图模型标识", example = "intent_model_02", required = false) + @Schema(description = "意图模型标识", example = "intent_model_02", nullable = true) private String intentModelId; - @Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助", required = false) + @Schema(description = "插件函数信息", nullable = true) + private List functions; + + @Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助", nullable = true) private String systemPrompt; - @Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" + - "根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false) + @Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" + "根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", nullable = true) private String summaryMemory; - @Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)", example = "3", required = false) + @Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)", example = "3", nullable = true) private Integer chatHistoryConf; - @Schema(description = "语言编码", example = "zh_CN", required = false) + @Schema(description = "语言编码", example = "zh_CN", nullable = true) private String langCode; - @Schema(description = "交互语种", example = "中文", required = false) + @Schema(description = "交互语种", example = "中文", nullable = true) private String language; - @Schema(description = "排序", example = "1", required = false) + @Schema(description = "排序", example = "1", nullable = true) private Integer sort; + + @Data + @Schema(description = "插件函数信息") + public static class FunctionInfo implements Serializable { + @Schema(description = "插件ID", example = "plugin_01") + private String pluginId; + + @Schema(description = "函数参数信息", nullable = true) + private HashMap paramInfo; + + private static final long serialVersionUID = 1L; + } } \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentPluginMapping.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentPluginMapping.java new file mode 100644 index 00000000..d0597590 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentPluginMapping.java @@ -0,0 +1,53 @@ +package xiaozhi.modules.agent.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.io.Serializable; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * Agent与插件的唯一映射表 + * @TableName ai_agent_plugin_mapping + */ +@Data +@TableName(value ="ai_agent_plugin_mapping") +@Schema(description = "Agent与插件的唯一映射表") +public class AgentPluginMapping implements Serializable { + /** + * 主键 + */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "映射信息主键ID") + private Long id; + + /** + * 智能体ID + */ + @Schema(description = "智能体ID") + private String agentId; + + /** + * 插件ID + */ + @Schema(description = "插件ID") + private String pluginId; + + /** + * 插件参数(Json)格式 + */ + @Schema(description = "插件参数(Json)格式") + private String paramInfo; + + + // 冗余字段,用于方便在根据id查询插件时,对照查出插件的Provider_code,详见dao层xml文件 + @TableField(exist = false) + @Schema(description = "插件provider_code, 对应表ai_model_provider") + private String providerCode; + + @TableField(exist = false) + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentPluginMappingService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentPluginMappingService.java new file mode 100644 index 00000000..c2de4ea6 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentPluginMappingService.java @@ -0,0 +1,15 @@ +package xiaozhi.modules.agent.service; + +import xiaozhi.modules.agent.entity.AgentPluginMapping; +import com.baomidou.mybatisplus.extension.service.IService; + +import java.util.List; + +/** +* @description 针对表【ai_agent_plugin_mapping(Agent与插件的唯一映射表)】的数据库操作Service +* @createDate 2025-05-25 22:33:17 +*/ +public interface AgentPluginMappingService extends IService { + + List agentPluginParamsByAgentId(String agentId); +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java index 2104d353..990ac61e 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java @@ -6,7 +6,9 @@ import java.util.Map; import xiaozhi.common.page.PageData; import xiaozhi.common.service.BaseService; import xiaozhi.modules.agent.dto.AgentDTO; +import xiaozhi.modules.agent.dto.AgentUpdateDTO; import xiaozhi.modules.agent.entity.AgentEntity; +import xiaozhi.modules.agent.vo.AgentInfoVO; /** * 智能体表处理service @@ -30,7 +32,7 @@ public interface AgentService extends BaseService { * @param id 智能体ID * @return 智能体实体 */ - AgentEntity getAgentById(String id); + AgentInfoVO getAgentById(String id); /** * 插入智能体 @@ -79,4 +81,6 @@ public interface AgentService extends BaseService { * @return 是否有权限 */ boolean checkAgentPermission(String agentId, Long userId); + + void updateAgentById(String agentId, AgentUpdateDTO dto); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentPluginMappingServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentPluginMappingServiceImpl.java new file mode 100644 index 00000000..38d01915 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentPluginMappingServiceImpl.java @@ -0,0 +1,32 @@ +package xiaozhi.modules.agent.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import xiaozhi.modules.agent.entity.AgentPluginMapping; +import xiaozhi.modules.agent.service.AgentPluginMappingService; +import xiaozhi.modules.agent.dao.AgentPluginMappingMapper; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** +* @description 针对表【ai_agent_plugin_mapping(Agent与插件的唯一映射表)】的数据库操作Service实现 +* @createDate 2025-05-25 22:33:17 +*/ +@Service +@RequiredArgsConstructor +public class AgentPluginMappingServiceImpl extends ServiceImpl + implements AgentPluginMappingService{ + private final AgentPluginMappingMapper agentPluginMappingMapper; + + @Override + public List agentPluginParamsByAgentId(String agentId) { + return agentPluginMappingMapper.selectPluginsByAgentId(agentId); + } + +} + + + + diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java index 37bb7d93..4fb4e09c 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java @@ -1,10 +1,11 @@ package xiaozhi.modules.agent.service.impl; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.util.*; +import java.util.function.Function; import java.util.stream.Collectors; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; @@ -13,17 +14,29 @@ import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import lombok.AllArgsConstructor; +import org.springframework.transaction.annotation.Transactional; import xiaozhi.common.constant.Constant; +import xiaozhi.common.exception.RenException; import xiaozhi.common.page.PageData; import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.service.impl.BaseServiceImpl; +import xiaozhi.common.user.UserDetail; +import xiaozhi.common.utils.JsonUtils; +import xiaozhi.common.utils.Result; import xiaozhi.modules.agent.dao.AgentDao; import xiaozhi.modules.agent.dto.AgentDTO; +import xiaozhi.modules.agent.dto.AgentUpdateDTO; import xiaozhi.modules.agent.entity.AgentEntity; +import xiaozhi.modules.agent.entity.AgentPluginMapping; +import xiaozhi.modules.agent.service.AgentChatHistoryService; +import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentService; +import xiaozhi.modules.agent.vo.AgentInfoVO; import xiaozhi.modules.device.service.DeviceService; +import xiaozhi.modules.model.dto.ModelProviderDTO; import xiaozhi.modules.model.service.ModelConfigService; +import xiaozhi.modules.model.service.ModelProviderService; import xiaozhi.modules.security.user.SecurityUser; import xiaozhi.modules.sys.enums.SuperAdminEnum; import xiaozhi.modules.timbre.service.TimbreService; @@ -36,6 +49,10 @@ public class AgentServiceImpl extends BaseServiceImpl imp private final ModelConfigService modelConfigService; private final RedisUtils redisUtils; private final DeviceService deviceService; + private final AgentPluginMappingService agentPluginMappingService; + private final ModelProviderService modelProviderService; + private final AgentChatHistoryService agentChatHistoryService; + @Override public PageData adminAgentList(Map params) { @@ -46,15 +63,20 @@ public class AgentServiceImpl extends BaseServiceImpl imp } @Override - public AgentEntity getAgentById(String id) { - AgentEntity agent = agentDao.selectById(id); - if (agent != null && agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) { - agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode()); - } else if (agent != null && agent.getMemModelId() != null - && !agent.getMemModelId().equals(Constant.MEMORY_NO_MEM) - && agent.getChatHistoryConf() == null) { - agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode()); + public AgentInfoVO getAgentById(String id) { + AgentInfoVO agent = agentDao.selectAgentInfoById(id); + + if (agent == null) { + throw new RenException("智能体不存在"); } + + if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) { + agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode()); + if (agent.getChatHistoryConf() == null) { + agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode()); + } + } + // 无需额外查询插件列表,已通过SQL查询出来 return agent; } @@ -164,4 +186,134 @@ public class AgentServiceImpl extends BaseServiceImpl imp // 检查是否是智能体的所有者 return userId.equals(agent.getUserId()); } + + // 根据id更新智能体信息 + @Override + @Transactional(rollbackFor = Exception.class) + public void updateAgentById(String agentId, AgentUpdateDTO dto) { + // 先查询现有实体 + AgentEntity existingEntity = this.getAgentById(agentId); + if (existingEntity == null) { + throw new RuntimeException("智能体不存在"); + } + + // 只更新提供的非空字段 + if (dto.getAgentName() != null) { + existingEntity.setAgentName(dto.getAgentName()); + } + if (dto.getAgentCode() != null) { + existingEntity.setAgentCode(dto.getAgentCode()); + } + if (dto.getAsrModelId() != null) { + existingEntity.setAsrModelId(dto.getAsrModelId()); + } + if (dto.getVadModelId() != null) { + existingEntity.setVadModelId(dto.getVadModelId()); + } + if (dto.getLlmModelId() != null) { + existingEntity.setLlmModelId(dto.getLlmModelId()); + } + if (dto.getTtsModelId() != null) { + existingEntity.setTtsModelId(dto.getTtsModelId()); + } + if (dto.getTtsVoiceId() != null) { + existingEntity.setTtsVoiceId(dto.getTtsVoiceId()); + } + if (dto.getMemModelId() != null) { + existingEntity.setMemModelId(dto.getMemModelId()); + } + if (dto.getIntentModelId() != null) { + existingEntity.setIntentModelId(dto.getIntentModelId()); + } + if (dto.getSystemPrompt() != null) { + existingEntity.setSystemPrompt(dto.getSystemPrompt()); + } + if (dto.getSummaryMemory() != null) { + existingEntity.setSummaryMemory(dto.getSummaryMemory()); + } + if (dto.getChatHistoryConf() != null) { + existingEntity.setChatHistoryConf(dto.getChatHistoryConf()); + } + if (dto.getLangCode() != null) { + existingEntity.setLangCode(dto.getLangCode()); + } + if (dto.getLanguage() != null) { + existingEntity.setLanguage(dto.getLanguage()); + } + if (dto.getSort() != null) { + existingEntity.setSort(dto.getSort()); + } + + // 更新函数插件信息 + List functions = dto.getFunctions(); + if (functions != null) { + // 1. 收集本次提交的 pluginId + // TODO: 提交的参数信息里,这里是允许为空,后续可以扩展required字段限制避免为空 + List newPluginIds = functions.stream() + .map(AgentUpdateDTO.FunctionInfo::getPluginId) + .toList(); + + // 2. 查询当前agent现有的所有映射 + List existing = agentPluginMappingService.list( + new QueryWrapper() + .eq("agent_id", agentId) + ); + Map existMap = existing.stream() + .collect(Collectors.toMap(AgentPluginMapping::getPluginId, Function.identity())); + + // 3. 构造所有要 保存或更新 的实体 + List allToPersist = functions.stream().map(info -> { + AgentPluginMapping m = new AgentPluginMapping(); + m.setAgentId(agentId); + m.setPluginId(info.getPluginId()); + m.setParamInfo(JsonUtils.toJsonString(info.getParamInfo())); + AgentPluginMapping old = existMap.get(info.getPluginId()); + if (old != null) { + // 已存在,设置id表示更新 + m.setId(old.getId()); + } + return m; + }).toList(); + + // 4. 拆分:已有ID的走更新,无ID的走插入 + List toUpdate = allToPersist.stream() + .filter(m -> m.getId() != null) + .toList(); + List toInsert = allToPersist.stream() + .filter(m -> m.getId() == null) + .toList(); + + if (!toUpdate.isEmpty()) { + agentPluginMappingService.updateBatchById(toUpdate); + } + if (!toInsert.isEmpty()) { + agentPluginMappingService.saveBatch(toInsert); + } + + // 5. 删除本次不在提交列表里的插件映射 + List toDelete = existing.stream() + .filter(old -> !newPluginIds.contains(old.getPluginId())) + .map(AgentPluginMapping::getId) + .toList(); + if (!toDelete.isEmpty()) { + agentPluginMappingService.removeBatchByIds(toDelete); + } + } + + // 设置更新者信息 + UserDetail user = SecurityUser.getUser(); + existingEntity.setUpdater(user.getId()); + existingEntity.setUpdatedAt(new Date()); + + // 更新记忆策略 + if (existingEntity.getMemModelId() == null || existingEntity.getMemModelId().equals(Constant.MEMORY_NO_MEM)) { + // 删除所有记录 + agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, true); + existingEntity.setSummaryMemory(""); + } else if (existingEntity.getChatHistoryConf() != null && existingEntity.getChatHistoryConf() == 1) { + // 删除音频数据 + agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, false); + } + this.updateById(existingEntity); + } } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java new file mode 100644 index 00000000..d56c8bb4 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java @@ -0,0 +1,24 @@ +package xiaozhi.modules.agent.vo; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import xiaozhi.modules.agent.entity.AgentEntity; +import xiaozhi.modules.agent.entity.AgentPluginMapping; + +import java.util.List; + +/** + * Agent信息返回体VO + * 这里直接extend了Agent实体类AgentEntity,后续需要规范返回字段可以copy字段出来 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class AgentInfoVO extends AgentEntity +{ + @Schema(description = "插件列表Id") + @TableField(typeHandler = JacksonTypeHandler.class) + private List functions; +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/config/controller/ConfigController.java b/main/manager-api/src/main/java/xiaozhi/modules/config/controller/ConfigController.java index f83efb3a..f21795a3 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/config/controller/ConfigController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/config/controller/ConfigController.java @@ -27,7 +27,7 @@ public class ConfigController { private final ConfigService configService; @PostMapping("server-base") - @Operation(summary = "获取配置") + @Operation(summary = "服务端获取配置接口") public Result getConfig() { Object config = configService.getConfig(true); return new Result().ok(config); diff --git a/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java index 592b3c1d..b8ab988a 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java @@ -1,9 +1,6 @@ package xiaozhi.modules.config.service.impl; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; @@ -16,7 +13,9 @@ import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.utils.JsonUtils; import xiaozhi.modules.agent.entity.AgentEntity; +import xiaozhi.modules.agent.entity.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentTemplateEntity; +import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.config.service.ConfigService; @@ -39,6 +38,7 @@ public class ConfigServiceImpl implements ConfigService { private final AgentTemplateService agentTemplateService; private final RedisUtils redisUtils; private final TimbreService timbreService; + private final AgentPluginMappingService agentPluginMappingService; @Override public Object getConfig(Boolean isCache) { @@ -131,6 +131,19 @@ public class ConfigServiceImpl implements ConfigService { agent.setAsrModelId(null); } + // 添加函数调用参数信息 + if (!Objects.equals(agent.getIntentModelId(), "Intent_nointent")) { + String agentId = agent.getId(); + List pluginMappings = agentPluginMappingService.agentPluginParamsByAgentId(agentId); + if (pluginMappings != null && !pluginMappings.isEmpty()) { + Map pluginParams = new HashMap<>(); + for (AgentPluginMapping pluginMapping : pluginMappings) { + pluginParams.put(pluginMapping.getProviderCode(), pluginMapping.getParamInfo()); + } + result.put("plugins", pluginParams); + } + } + // 构建模块配置 buildModuleConfig( agent.getAgentName(), @@ -280,6 +293,7 @@ public class ConfigServiceImpl implements ConfigService { map.put("functions", functions); } } + System.out.println("map: " + map); } // 如果是LLM类型,且intentLLMModelId不为空,则添加附加模型 if ("LLM".equals(modelTypes[i]) && intentLLMModelId != null) { diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java b/main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java index fc534a0a..24a7c7ca 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java @@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.AllArgsConstructor; import xiaozhi.common.page.PageData; import xiaozhi.common.utils.Result; +import xiaozhi.common.utils.ResultUtils; import xiaozhi.common.validator.group.UpdateGroup; import xiaozhi.modules.model.dto.ModelProviderDTO; import xiaozhi.modules.model.service.ModelProviderService; @@ -65,4 +66,10 @@ public class ModelProviderController { return new Result<>(); } + @GetMapping("/plugin/names") + @Tag(name = "获取插件名称列表") + public Result> getPluginNameList() { + return ResultUtils.success(modelProviderService.getPluginList()); + } + } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelProviderService.java b/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelProviderService.java index d41766f6..92a8da80 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelProviderService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelProviderService.java @@ -1,5 +1,6 @@ package xiaozhi.modules.model.service; +import java.util.Collection; import java.util.List; import xiaozhi.common.page.PageData; @@ -9,6 +10,10 @@ public interface ModelProviderService { // List getModelNames(String modelType, String modelName); + List getPluginList(); + + List getPluginListByIds(Collection ids); + List getListByModelType(String modelType); ModelProviderDTO add(ModelProviderDTO modelProviderDTO); diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelProviderServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelProviderServiceImpl.java index ffeec288..392454a0 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelProviderServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelProviderServiceImpl.java @@ -1,10 +1,8 @@ package xiaozhi.modules.model.service.impl; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; @@ -32,6 +30,24 @@ public class ModelProviderServiceImpl extends BaseServiceImpl getPluginList() { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(ModelProviderEntity::getModelType, "Plugin"); + List providerEntities = modelProviderDao.selectList(queryWrapper); + return ConvertUtils.sourceToTarget(providerEntities, ModelProviderDTO.class); + } + + @Override + public List getPluginListByIds(Collection ids) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(ModelProviderEntity::getId, ids); + queryWrapper.eq(ModelProviderEntity::getModelType, "Plugin"); + List providerEntities = modelProviderDao.selectList(queryWrapper); + return ConvertUtils.sourceToTarget(providerEntities, ModelProviderDTO.class); + } + + @Override public List getListByModelType(String modelType) { diff --git a/main/manager-api/src/main/resources/db/changelog/202505232203.sql b/main/manager-api/src/main/resources/db/changelog/202505232203.sql new file mode 100644 index 00000000..6b0f03ee --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202505232203.sql @@ -0,0 +1,181 @@ +-- =============================== +-- 一、在ai_model_provider中插入plugin 记录 +-- =============================== +START TRANSACTION; + +-- 1. 天气查询 +INSERT INTO ai_model_provider (id, model_type, provider_code, name, fields, + sort, creator, create_date, updater, update_date) +VALUES ('SYSTEM_PLUGIN_WEATHER', + 'Plugin', + 'get_weather', + '天气查询', + JSON_ARRAY( + JSON_OBJECT( + 'key', 'api_key', + 'type', 'string', + 'label', '天气插件 API 密钥', + 'default', (SELECT param_value FROM sys_params WHERE param_code = 'plugins.get_weather.api_key') + ), + JSON_OBJECT( + 'key', 'default_location', + 'type', 'string', + 'label', '默认查询城市', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.get_weather.default_location') + ), + JSON_OBJECT( + 'key', 'api_host', + 'type', 'string', + 'label', '开发者 API Host', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.get_weather.api_host') + ) + ), + 10, 0, NOW(), 0, NOW()); + +-- 2. 新闻订阅 +INSERT INTO ai_model_provider (id, model_type, provider_code, name, fields, + sort, creator, create_date, updater, update_date) +VALUES ('SYSTEM_PLUGIN_NEWS', + 'Plugin', + 'get_news_from_newsnow', + '新闻订阅', + JSON_ARRAY( + JSON_OBJECT( + 'key', 'default_rss_url', + 'type', 'string', + 'label', '默认 RSS 源', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.get_news.default_rss_url') + ), + JSON_OBJECT( + 'key', 'category_urls', + 'type', 'json', + 'label', '分类 RSS 地址映射', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.get_news.category_urls') + ) + ), + 20, 0, NOW(), 0, NOW()); + +-- 3. HomeAssistant 状态查询 +INSERT INTO ai_model_provider (id, model_type, provider_code, name, fields, + sort, creator, create_date, updater, update_date) +VALUES ('SYSTEM_PLUGIN_HA_GET_STATE', + 'Plugin', + 'hass_get_state', + 'HomeAssistant 状态查询', + JSON_ARRAY( + JSON_OBJECT( + 'key', 'base_url', + 'type', 'string', + 'label', 'HA 服务器地址', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.base_url') + ), + JSON_OBJECT( + 'key', 'api_key', + 'type', 'string', + 'label', 'HA API 访问令牌', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.api_key') + ), + JSON_OBJECT( + 'key', 'devices', + 'type', 'array', + 'label', '设备列表(名称,实体ID;…)', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.devices') + ) + ), + 30, 0, NOW(), 0, NOW()); + +-- 4. HomeAssistant 状态写入 +INSERT INTO ai_model_provider (id, model_type, provider_code, name, fields, + sort, creator, create_date, updater, update_date) +VALUES ('SYSTEM_PLUGIN_HA_SET_STATE', + 'Plugin', + 'hass_set_state', + 'HomeAssistant 状态写入', + JSON_ARRAY( + JSON_OBJECT( + 'key', 'base_url', + 'type', 'string', + 'label', 'HA 服务器地址', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.base_url') + ), + JSON_OBJECT( + 'key', 'api_key', + 'type', 'string', + 'label', 'HA API 访问令牌', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.api_key') + ), + JSON_OBJECT( + 'key', 'devices', + 'type', 'array', + 'label', '设备列表(名称,实体ID;…)', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.home_assistant.devices') + ) + ), + 40, 0, NOW(), 0, NOW()); + +-- 5. 本地播放 +INSERT INTO ai_model_provider (id, model_type, provider_code, name, fields, + sort, creator, create_date, updater, update_date) +VALUES ('SYSTEM_PLUGIN_MUSIC', + 'Plugin', + 'play_music', + '本地播放', + JSON_ARRAY( + JSON_OBJECT( + 'key', 'music_dir', + 'type', 'string', + 'label', '音乐文件根目录', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.play_music.music_dir') + ), + JSON_OBJECT( + 'key', 'music_ext', + 'type', 'array', + 'label', '支持的文件后缀', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.play_music.music_ext') + ), + JSON_OBJECT( + 'key', 'refresh_time', + 'type', 'number', + 'label', '列表刷新间隔(秒)', + 'default', + (SELECT param_value FROM sys_params WHERE param_code = 'plugins.play_music.refresh_time') + ) + ), + 50, 0, NOW(), 0, NOW()); + + +-- =============================== +-- 二、删除sys_params中旧的plugins.*参数 +-- =============================== +DELETE +FROM sys_params +WHERE param_code LIKE 'plugins.%'; + + +-- =============================== +-- 三、添加智能体插件id字段 +-- =============================== +CREATE TABLE IF NOT EXISTS ai_agent_plugin_mapping +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', + agent_id VARCHAR(32) NOT NULL COMMENT '智能体ID', + plugin_id VARCHAR(32) NOT NULL COMMENT '插件ID', + param_info JSON NOT NULL COMMENT '参数信息', + UNIQUE KEY uk_agent_provider (agent_id, plugin_id) +) COMMENT 'Agent与插件的唯一映射表'; + + +COMMIT; + diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index 9d3808da..77b8d869 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -162,4 +162,11 @@ databaseChangeLog: changes: - sqlFile: encoding: utf8 - path: classpath:db/changelog/202505151451.sql \ No newline at end of file + path: classpath:db/changelog/202505151451.sql + - changeSet: + id: 202505232203 + author: CAIXYPROMISE + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202505232203.sql \ No newline at end of file diff --git a/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml b/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml index 322ca172..0dc7deab 100644 --- a/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml +++ b/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml @@ -5,4 +5,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/main/manager-api/src/main/resources/mapper/agent/AgentPluginMappingMapper.xml b/main/manager-api/src/main/resources/mapper/agent/AgentPluginMappingMapper.xml new file mode 100644 index 00000000..659fcd6e --- /dev/null +++ b/main/manager-api/src/main/resources/mapper/agent/AgentPluginMappingMapper.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + id,agent_id,plugin_id + + + + + + + diff --git a/main/manager-web/src/apis/module/model.js b/main/manager-web/src/apis/module/model.js index badf4174..7db2b288 100644 --- a/main/manager-web/src/apis/module/model.js +++ b/main/manager-web/src/apis/module/model.js @@ -305,4 +305,20 @@ export default { }) }).send() }, + // 获取插件列表 + getPluginFunctionList(params, callback) { + RequestService.sendRequest() + .url(`${getServiceUrl()}/models/provider/plugin/names`) + .method('GET') + .success((res) => { + RequestService.clearRequestTime() + callback(res) + }) + .networkFail((err) => { + this.$message.error(err.msg || '获取插件列表失败') + RequestService.reAjaxFun(() => { + this.getPluginFunctionList(params, callback) + }) + }).send() + } } diff --git a/main/manager-web/src/components/FunctionDialog.vue b/main/manager-web/src/components/FunctionDialog.vue index b4fec417..e1999cc7 100644 --- a/main/manager-web/src/components/FunctionDialog.vue +++ b/main/manager-web/src/components/FunctionDialog.vue @@ -16,15 +16,21 @@ 全选
-
- -
-
- {{ func.name }} +
+
+ +
+
+ {{ func.name }} +
+ + +
- - - +
+
+
@@ -36,26 +42,83 @@ 全选
-
- -
-
- {{ func.name }} +
+
+ +
+
+ {{ func.name }} +
+
+ +

参数配置 - {{ currentFunction.name }}

-
- - - - - -
+
+ + + + + + + + + + + + + + + + + + + + +
请选择已配置的功能进行参数设置
@@ -74,24 +137,19 @@ export default { functions: { type: Array, default: () => [] + }, + allFunctions: { + type: Array, + default: () => [] } }, data() { return { + textCache: {}, dialogVisible: this.value, selectedNames: [], currentFunction: null, modifiedFunctions: {}, - allFunctions: [ - {name: '天气', params: {city: '北京'}, description: '查看指定城市的天气情况'}, - {name: '新闻', params: {type: '科技'}, description: '获取最新科技类新闻资讯'}, - {name: '工具', params: {category: '常用'}, description: '提供常用工具集合'}, - {name: '退出', params: {}, description: '退出当前系统'}, - {name: '音乐', params: {genre: '流行'}, description: '播放流行音乐'}, - {name: '翻译', params: {from: '中文', to: '英文'}, description: '提供中英文互译功能'}, - {name: '计算', params: {precision: '2'}, description: '提供精确计算功能'}, - {name: '日历', params: {view: '月'}, description: '查看月历视图'} - ], functionColorMap: [ '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD', '#D4A5A5', '#A2836E' @@ -111,10 +169,37 @@ export default { } }, watch: { - value(newVal) { - this.dialogVisible = newVal; - if (newVal) { + currentFunction(newFn) { + if (!newFn) return; + // 对每个字段,如果是 array 或 json,就在 textCache 里生成初始字符串 + newFn.fieldsMeta.forEach(f => { + const v = newFn.params[f.key]; + if (f.type === 'array') { + this.$set(this.textCache, f.key, Array.isArray(v) ? v.join('\n') : ''); + } + else if (f.type === 'json') { + try { + this.$set(this.textCache, f.key, JSON.stringify(v ?? {}, null, 2)); + } catch { + this.$set(this.textCache, f.key, ''); + } + } + }); + }, + value(v) { + this.dialogVisible = v; + if (v) { + // 对话框打开时,初始化选中态 this.selectedNames = this.functions.map(f => f.name); + // 把后端传来的 this.functions(带 params)merge 到 allFunctions 上 + this.functions.forEach(saved => { + const idx = this.allFunctions.findIndex(f => f.name === saved.name); + if (idx >= 0) { + // 保留用户之前在 saved.params 上的改动 + this.allFunctions[idx].params = {...saved.params}; + } + }); + // 右侧默认指向第一个 this.currentFunction = this.selectedList[0] || null; } }, @@ -123,14 +208,33 @@ export default { } }, methods: { + flushArray(key) { + const text = this.textCache[key] || ''; + const arr = text + .split('\n') + .map(s => s.trim()) + .filter(Boolean); + this.handleParamChange(this.currentFunction, key, arr); + }, + + flushJson(field) { + const key = field.key; + if (!key) { + return; + } + console.log(field); + const text = this.textCache[key] || ''; + try { + const obj = JSON.parse(text); + this.handleParamChange(this.currentFunction, key, obj); + } catch { + this.$message.error(`${this.currentFunction.name}的${key}字段格式错误:JSON格式有误`); + } + }, handleFunctionClick(func) { if (this.selectedNames.includes(func.name)) { - this.loading = true; - setTimeout(() => { - const tempFunc = this.tempFunctions[func.name]; - this.currentFunction = tempFunc ? tempFunc : JSON.parse(JSON.stringify(func)); - this.loading = false; - }, 300); + const tempFunc = this.tempFunctions[func.name]; + this.currentFunction = tempFunc ? tempFunc : func; } }, handleParamChange(func, key, value) { @@ -185,11 +289,15 @@ export default { const selected = this.selectedList.map(f => { const modified = this.modifiedFunctions[f.name]; - return modified || f; - }).map(f => ({ - ...f, - params: JSON.parse(JSON.stringify(f.params)) - })); + return { + id: f.id, + name: f.name, + params: modified + ? {...modified.params} + : {...f.params} + } + }); + console.log(selected); this.$emit('update-functions', selected); this.dialogVisible = false; @@ -197,11 +305,17 @@ export default { // 通知父组件对话框已关闭且已保存 this.$emit('dialog-closed', true); }, - getFunctionColor(name) { const hash = [...name].reduce((acc, char) => acc + char.charCodeAt(0), 0); - return this.functionColorMap[hash % 7]; - } + return this.functionColorMap[hash % this.functionColorMap.length]; + }, + fieldRemark(field) { + let description = (field && field.label) ? field.label : ''; + if (field.default) { + description += `(默认值:${field.default})`; + } + return description; + }, } } @@ -209,7 +323,7 @@ export default { \ No newline at end of file From 896b318c49aaa58493ca455797d6dc44ba400649 Mon Sep 17 00:00:00 2001 From: CGD <3030332422@qq.com> Date: Tue, 24 Jun 2025 14:55:34 +0800 Subject: [PATCH 50/91] =?UTF-8?q?update:iot=E8=AE=BE=E5=A4=87=E5=A4=9A?= =?UTF-8?q?=E6=8C=87=E4=BB=A4=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/handle/functionHandler.py | 10 ++++++++++ main/xiaozhi-server/core/handle/iotHandle.py | 6 +++++- .../core/providers/intent/intent_llm/intent_llm.py | 4 ++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/main/xiaozhi-server/core/handle/functionHandler.py b/main/xiaozhi-server/core/handle/functionHandler.py index 4d7c55e9..e1292553 100644 --- a/main/xiaozhi-server/core/handle/functionHandler.py +++ b/main/xiaozhi-server/core/handle/functionHandler.py @@ -61,6 +61,16 @@ class FunctionHandler: return self.function_registry.get_function(name) def handle_llm_function_call(self, conn, function_call_data): + # 多函数调用处理 + if "function_calls" in function_call_data: + responses = [] + for call in function_call_data["function_calls"]: + func = self.get_function(call["name"]) + if func: + # 执行函数并收集响应 + response = func(conn, **call.get("arguments", {})) + responses.append(response) + return self._combine_responses(responses) # 合并响应 try: function_name = function_call_data["name"] funcItem = self.get_function(function_name) diff --git a/main/xiaozhi-server/core/handle/iotHandle.py b/main/xiaozhi-server/core/handle/iotHandle.py index 6406c659..bebb44fc 100644 --- a/main/xiaozhi-server/core/handle/iotHandle.py +++ b/main/xiaozhi-server/core/handle/iotHandle.py @@ -82,7 +82,11 @@ def create_iot_function(device_name, method_name, method_info): response = response.replace("{value}", str(param_value)) break - return ActionResponse(Action.RESPONSE, result, response) + return ActionResponse( + Action.REQLLM, + result=f"{device_name}操作执行成功,请继续处理剩余指令", + response=response_success # 保留成功提示 + ) except Exception as e: conn.logger.bind(tag=TAG).error( f"执行{device_name}的{method_name}操作失败: {e}" diff --git a/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py b/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py index f2c5c306..d13c4df4 100644 --- a/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py +++ b/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py @@ -95,6 +95,10 @@ class IntentProvider(IntentProviderBase): "1. 只返回JSON格式,不要包含任何其他文字\n" '2. 如果没有找到匹配的函数,返回{"function_call": {"name": "continue_chat"}}\n' "3. 确保返回的JSON格式正确,包含所有必要的字段\n" + "特殊说明:\n" + "- 当用户单次输入包含多个指令时(如'打开灯并且调高音量')\n" + "- 请返回多个function_call组成的JSON数组\n" + "- 示例:{'function_calls': [{name:'light_on'}, {name:'volume_up'}]}" ) return prompt From b87906234da140470c24853e538b549b5a670767 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Tue, 24 Jun 2025 16:26:14 +0800 Subject: [PATCH 51/91] =?UTF-8?q?update:=E6=96=B0=E5=A2=9EAES=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=E6=96=B9=E6=B3=95=EF=BC=8C=E5=92=8Cpython=E7=AB=AFAES?= =?UTF-8?q?=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xiaozhi/common/utils/AESUtils.java | 78 +++++++++++++++++ .../xiaozhi/common/utils/AESUtilsTest.java | 85 +++++++++++++++++++ main/xiaozhi-server/docker-compose_all.yml | 3 +- 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 main/manager-api/src/main/java/xiaozhi/common/utils/AESUtils.java create mode 100644 main/manager-api/src/test/java/xiaozhi/common/utils/AESUtilsTest.java diff --git a/main/manager-api/src/main/java/xiaozhi/common/utils/AESUtils.java b/main/manager-api/src/main/java/xiaozhi/common/utils/AESUtils.java new file mode 100644 index 00000000..660e3cba --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/common/utils/AESUtils.java @@ -0,0 +1,78 @@ +package xiaozhi.common.utils; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; + +public class AESUtils { + + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/ECB/PKCS5Padding"; + + /** + * AES加密 + * + * @param key 密钥(16位、24位或32位) + * @param plainText 待加密字符串 + * @return 加密后的Base64字符串 + */ + public static String encrypt(String key, String plainText) { + try { + // 确保密钥长度为16、24或32位 + byte[] keyBytes = padKey(key.getBytes(StandardCharsets.UTF_8)); + SecretKeySpec secretKey = new SecretKeySpec(keyBytes, ALGORITHM); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + + byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encryptedBytes); + } catch (Exception e) { + throw new RuntimeException("AES加密失败", e); + } + } + + /** + * AES解密 + * + * @param key 密钥(16位、24位或32位) + * @param encryptedText 待解密的Base64字符串 + * @return 解密后的字符串 + */ + public static String decrypt(String key, String encryptedText) { + try { + // 确保密钥长度为16、24或32位 + byte[] keyBytes = padKey(key.getBytes(StandardCharsets.UTF_8)); + SecretKeySpec secretKey = new SecretKeySpec(keyBytes, ALGORITHM); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + + byte[] encryptedBytes = Base64.getDecoder().decode(encryptedText); + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + return new String(decryptedBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("AES解密失败", e); + } + } + + /** + * 填充密钥到指定长度(16、24或32位) + * + * @param keyBytes 原始密钥字节数组 + * @return 填充后的密钥字节数组 + */ + private static byte[] padKey(byte[] keyBytes) { + int keyLength = keyBytes.length; + if (keyLength == 16 || keyLength == 24 || keyLength == 32) { + return keyBytes; + } + + // 如果密钥长度不足,用0填充;如果超过,截取前32位 + byte[] paddedKey = new byte[32]; + System.arraycopy(keyBytes, 0, paddedKey, 0, Math.min(keyLength, 32)); + return paddedKey; + } +} diff --git a/main/manager-api/src/test/java/xiaozhi/common/utils/AESUtilsTest.java b/main/manager-api/src/test/java/xiaozhi/common/utils/AESUtilsTest.java new file mode 100644 index 00000000..c4f37115 --- /dev/null +++ b/main/manager-api/src/test/java/xiaozhi/common/utils/AESUtilsTest.java @@ -0,0 +1,85 @@ +package xiaozhi.common.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class AESUtilsTest { + + @Test + public void testEncryptAndDecrypt() { + String key = "xiaozhi1234567890"; + String plainText = "Hello, 小智!"; + + System.out.println("原始文本: " + plainText); + System.out.println("密钥: " + key); + + // 加密 + String encrypted = AESUtils.encrypt(key, plainText); + System.out.println("加密结果: " + encrypted); + + // 解密 + String decrypted = AESUtils.decrypt(key, encrypted); + System.out.println("解密结果: " + decrypted); + + // 验证 + assertEquals(plainText, decrypted, "加解密结果应该一致"); + System.out.println("加解密一致性: " + plainText.equals(decrypted)); + } + + @Test + public void testDifferentKeyLengths() { + String[] keys = { + "1234567890123456", // 16位 + "123456789012345678901234", // 24位 + "12345678901234567890123456789012", // 32位 + "short", // 短密钥 + "verylongkeythatwillbetruncatedto32bytes" // 长密钥 + }; + + String plainText = "测试文本"; + + for (String key : keys) { + String encrypted = AESUtils.encrypt(key, plainText); + String decrypted = AESUtils.decrypt(key, encrypted); + assertEquals(plainText, decrypted, "密钥长度: " + key.length()); + } + } + + @Test + public void testSpecialCharacters() { + String key = "xiaozhi1234567890"; + String[] testTexts = { + "Hello World", + "你好世界", + "Hello, 小智!", + "特殊字符: !@#$%^&*()", + "数字123和中文混合", + "Emoji: 😀🎉🚀", + "空字符串测试", + "" + }; + + for (String text : testTexts) { + String encrypted = AESUtils.encrypt(key, text); + String decrypted = AESUtils.decrypt(key, encrypted); + assertEquals(text, decrypted, "测试文本: " + text); + } + } + + @Test + public void testCrossLanguageCompatibility() { + // 这些是Python版本生成的加密结果,用于测试跨语言兼容性 + String key = "xiaozhi1234567890"; + String plainText = "Hello, 小智!"; + + // Python版本生成的加密结果(需要运行Python测试后获取) + // String pythonEncrypted = "从Python测试中获取的加密结果"; + // String decrypted = AESUtils.decrypt(key, pythonEncrypted); + // assertEquals(plainText, decrypted, "Java应该能解密Python加密的结果"); + + // 生成Java加密结果供Python测试 + String javaEncrypted = AESUtils.encrypt(key, plainText); + System.out.println("Java加密结果供Python测试: " + javaEncrypted); + } +} \ No newline at end of file diff --git a/main/xiaozhi-server/docker-compose_all.yml b/main/xiaozhi-server/docker-compose_all.yml index c8672fb5..4707a449 100644 --- a/main/xiaozhi-server/docker-compose_all.yml +++ b/main/xiaozhi-server/docker-compose_all.yml @@ -52,7 +52,7 @@ services: volumes: # 配置文件目录 - ./uploadfile:/uploadfile - + # 数据库模块 xiaozhi-esp32-server-db: image: mysql:latest container_name: xiaozhi-esp32-server-db @@ -73,6 +73,7 @@ services: - MYSQL_ROOT_PASSWORD=123456 - MYSQL_DATABASE=xiaozhi_esp32_server - MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci" + # redis模块 xiaozhi-esp32-server-redis: image: redis expose: From 0ee69fa2b20da4a5de42aa4f6a7a2f32f576da53 Mon Sep 17 00:00:00 2001 From: JianYu Zheng <2375294554@qq.com> Date: Wed, 25 Jun 2025 11:06:36 +0800 Subject: [PATCH 52/91] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86http=E5=8F=91?= =?UTF-8?q?=E9=80=81=E5=B7=A5=E5=85=B7=E7=B1=BB=20--HttpSendUtils.java=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=E5=8F=91=E9=80=81get=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=EF=BC=8C=E8=8E=B7=E5=8F=96=E8=BF=94=E5=9B=9E=E7=9A=84?= =?UTF-8?q?body=E8=BD=AC=E6=8D=A2=E6=88=90=E5=AD=97=E7=AC=A6=E4=B8=B2=20?= =?UTF-8?q?=E6=96=B9=E6=B3=95=20=E5=8F=91=E9=80=81post=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=EF=BC=8C=E5=8F=82=E6=95=B0=E4=B8=BAjson=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E3=80=82=E5=8F=96=E8=BF=94=E5=9B=9E=E7=9A=84body=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E6=88=90=E5=AD=97=E7=AC=A6=E4=B8=B2=20=E6=96=B9?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xiaozhi/common/utils/HttpSendUtils.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 main/manager-api/src/main/java/xiaozhi/common/utils/HttpSendUtils.java diff --git a/main/manager-api/src/main/java/xiaozhi/common/utils/HttpSendUtils.java b/main/manager-api/src/main/java/xiaozhi/common/utils/HttpSendUtils.java new file mode 100644 index 00000000..0e3429e7 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/common/utils/HttpSendUtils.java @@ -0,0 +1,64 @@ +package xiaozhi.common.utils; + +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Component; +import xiaozhi.common.exception.RenException; + +import java.io.IOException; + +@Slf4j +@Component +public class HttpSendUtils { + private OkHttpClient client = new OkHttpClient(); + + /** + * 发送get请求,获取返回的body转换成字符串 + * @param url get请求地址 + * @return body内容 + */ + public String fetchGetBodyAsString(String url) { + Request request = new Request.Builder() + .url(url) + .build(); + return getString(url, request); + } + + /** + * 发送post请求,参数为json格式。取返回的body转换成字符串 + * @param url post请求地址 + * @param json json参数 + * @return body内容 + */ + public String fetchJsonPostBodyAsString(String url,String json) { + // 创建请求体 + MediaType JSON = MediaType.get("application/json; charset=utf-8"); + RequestBody body = RequestBody.create(json, JSON); + + Request request = new Request.Builder() + .url(url) + .post(body) + .build(); + + return getString(url, request); + } + + private String getString(String url, Request request) { + String body = null; + try (Response response = client.newCall(request).execute()) { + if (response.isSuccessful()) { + if (response.body() != null){ + body = response.body().string(); + } + } else { + throw new RenException("请求失败,错误码:"+response.code()); + } + } catch (Exception e) { + String method = request.method(); + log.error("{}请求发送错误地址:{} \n 发送错误信息:{}",method, url,e.getMessage()); + throw new RenException("请求发送失败"); + } + return body; + } +} From dde203b158eb7de472984f8a55f6e01616662624 Mon Sep 17 00:00:00 2001 From: JianYu Zheng <2375294554@qq.com> Date: Wed, 25 Jun 2025 11:21:41 +0800 Subject: [PATCH 53/91] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E6=96=B0=E7=9A=84=E7=B3=BB=E7=BB=9F=E5=8F=82=E6=95=B0?= =?UTF-8?q?=EF=BC=8Cmcp=E6=8E=A5=E5=85=A5=E7=82=B9=E5=8F=82=E6=95=B0=20--C?= =?UTF-8?q?onstant.java=20mcp=E6=8E=A5=E5=85=A5=E7=82=B9=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E7=9A=84key=E5=B8=B8=E9=87=8F=20--SysParamsServiceImpl.java=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86mcp=E5=8F=82=E6=95=B0=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E6=96=B9=E6=B3=95=EF=BC=8C=E4=BF=AE=E6=94=B9=E6=98=AF?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xiaozhi/common/constant/Constant.java | 5 +++++ .../sys/service/impl/SysParamsServiceImpl.java | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java index b5837eb5..667b9d98 100644 --- a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java +++ b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java @@ -111,6 +111,11 @@ public interface Constant { */ String FILE_EXTENSION_SEG = "."; + /** + * mcp接入点路径 + */ + String SERVER_MCP_ENDPOINT = "server.mcp_endpoint"; + /** * 无记忆 */ diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java index 02f595d2..17dca161 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/service/impl/SysParamsServiceImpl.java @@ -20,6 +20,7 @@ import xiaozhi.common.exception.RenException; import xiaozhi.common.page.PageData; import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.utils.ConvertUtils; +import xiaozhi.common.utils.HttpSendUtils; import xiaozhi.common.utils.JsonUtils; import xiaozhi.modules.sys.dao.SysParamsDao; import xiaozhi.modules.sys.dto.SysParamsDTO; @@ -34,6 +35,7 @@ import xiaozhi.modules.sys.service.SysParamsService; @Service public class SysParamsServiceImpl extends BaseServiceImpl implements SysParamsService { private final SysParamsRedis sysParamsRedis; + private final HttpSendUtils httpSendUtils; @Override public PageData page(Map params) { @@ -86,6 +88,7 @@ public class SysParamsServiceImpl extends BaseServiceImpl Date: Wed, 25 Jun 2025 16:15:06 +0800 Subject: [PATCH 54/91] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=93=88=E5=B8=8C=E5=8A=A0=E5=AF=86=E7=9A=84=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=B1=BB=20--HashEncryptionUtil.java=201.=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E5=93=88=E5=B8=8C=E7=AE=97=E6=B3=95=E8=BF=9B=E8=A1=8C=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=E6=96=B9=E6=B3=95=202.=E4=BD=BF=E7=94=A8md5=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E5=8A=A0=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/utils/HashEncryptionUtil.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 main/manager-api/src/main/java/xiaozhi/common/utils/HashEncryptionUtil.java diff --git a/main/manager-api/src/main/java/xiaozhi/common/utils/HashEncryptionUtil.java b/main/manager-api/src/main/java/xiaozhi/common/utils/HashEncryptionUtil.java new file mode 100644 index 00000000..79b05192 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/common/utils/HashEncryptionUtil.java @@ -0,0 +1,52 @@ +package xiaozhi.common.utils; + +import lombok.extern.slf4j.Slf4j; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * 哈希加密算法的工具类 + * @author zjy + */ +@Slf4j +public class HashEncryptionUtil { + /** + * 使用md5进行加密 + * @param context 被加密的内容 + * @return 哈希值 + */ + public static String Md5hexDigest(String context){ + return hexDigest(context,"MD5"); + } + + /** + * 指定哈希算法进行加密 + * @param context 被加密的内容 + * @param algorithm 哈希算法 + * @return 哈希值 + */ + public static String hexDigest(String context,String algorithm ){ + // 获取MD5算法实例 + MessageDigest md = null; + try { + md = MessageDigest.getInstance(algorithm); + } catch (NoSuchAlgorithmException e) { + log.error("加密失败的算法:{}",algorithm); + throw new RuntimeException("加密失败,"+ algorithm +"哈希算法系统不支持"); + } + // 计算智能体id的MD5值 + byte[] messageDigest = md.digest(context.getBytes()); + // 将字节数组转换为十六进制字符串 + StringBuilder hexString = new StringBuilder(); + for (byte b : messageDigest) { + String hex = Integer.toHexString(0xFF & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } + +} From 21339aba4272256f314efdbd7c3da1bdf4b6c0d7 Mon Sep 17 00:00:00 2001 From: JianYu Zheng <2375294554@qq.com> Date: Wed, 25 Jun 2025 16:16:39 +0800 Subject: [PATCH 55/91] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93mcp=E6=8E=A5=E5=85=A5=E7=82=B9=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E5=AE=9A=E4=B9=89=20--AgentMcpAccessPointSer?= =?UTF-8?q?vice.java=201.=E8=8E=B7=E5=8F=96=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E7=9A=84mcp=E6=8E=A5=E5=85=A5=E7=82=B9=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=202.=E8=8E=B7=E5=8F=96=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E7=9A=84mcp=E6=8E=A5=E5=85=A5=E7=82=B9=E5=B7=B2?= =?UTF-8?q?=E6=9C=89=E7=9A=84=E5=B7=A5=E5=85=B7=E5=88=97=E8=A1=A8=E5=AE=9A?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AgentMcpAccessPointService.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentMcpAccessPointService.java diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentMcpAccessPointService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentMcpAccessPointService.java new file mode 100644 index 00000000..336ade65 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentMcpAccessPointService.java @@ -0,0 +1,25 @@ +package xiaozhi.modules.agent.service; + + +import java.util.List; + +/** + * 智能体Mcp接入点处理service + * + * @author zjy + */ +public interface AgentMcpAccessPointService { + /** + * 获取智能体的mcp接入点地址 + * @param id 智能体id + * @return mcp接入点地址 + */ + String getAgentMcpAccessAddress(String id); + + /** + * 获取智能体的mcp接入点已有的工具列表 + * @param id 智能体id + * @return 工具列表 + */ + List getAgentMcpToolsList(String id); +} From 8828f8401daab8cdeeae8211c6d3fe4b955e314c Mon Sep 17 00:00:00 2001 From: Tink Date: Wed, 25 Jun 2025 16:32:49 +0800 Subject: [PATCH 56/91] =?UTF-8?q?newsnow=20=E6=8F=92=E4=BB=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=89=8D=E7=AB=AF=E9=85=8D=E7=BD=AE=E6=96=B0=E9=97=BB?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/newsnow_plugin_config.md | 107 ++++++++++++++++++ .../resources/db/changelog/202506251619.sql | 21 ++++ .../db/changelog/db.changelog-master.yaml | 9 +- main/xiaozhi-server/config.yaml | 9 +- .../functions/get_news_from_newsnow.py | 52 ++++++--- 5 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 docs/newsnow_plugin_config.md create mode 100644 main/manager-api/src/main/resources/db/changelog/202506251619.sql diff --git a/docs/newsnow_plugin_config.md b/docs/newsnow_plugin_config.md new file mode 100644 index 00000000..3aff257d --- /dev/null +++ b/docs/newsnow_plugin_config.md @@ -0,0 +1,107 @@ +# get_news_from_newsnow 插件新闻源配置指南 + +## 概述 + +`get_news_from_newsnow` 插件现在支持通过Web管理界面动态配置新闻源,不再需要修改代码。用户可以在智控台中为每个智能体配置不同的新闻源。 + +## 配置方式 + +### 1. 通过Web管理界面配置(推荐) + +1. 登录智控台 +2. 进入"角色配置"页面 +3. 选择要配置的智能体 +4. 点击"编辑功能"按钮 +5. 在右侧参数配置区域找到"newsnow新闻聚合"插件 +6. 在"新闻源配置"字段中输入JSON格式的新闻源配置 + +### 2. 配置文件方式 + +在 `config.yaml` 中配置: + +```yaml +plugins: + get_news_from_newsnow: + url: "https://newsnow.busiyi.world/api/s?id=" + news_sources: + thepaper: "澎湃新闻" + baidu: "百度热搜" + cls-depth: "财联社" + # 可以添加更多新闻源 + sina: "新浪新闻" + sohu: "搜狐新闻" +``` + +## 新闻源配置格式 + +新闻源配置使用JSON格式,格式为: + +```json +{ + "source_id": "显示名称", + "source_id2": "显示名称2" +} +``` + +### 配置示例 + +```json +{ + "thepaper": "澎湃新闻", + "baidu": "百度热搜", + "cls-depth": "财联社", + "sina": "新浪新闻", + "sohu": "搜狐新闻" +} +``` + +## 默认配置 + +如果未配置新闻源,插件将使用以下默认配置: + +```json +{ + "thepaper": "澎湃新闻", + "baidu": "百度热搜", + "cls-depth": "财联社" +} +``` + +## 使用说明 + +1. **配置新闻源**:在Web界面或配置文件中设置新闻源 +2. **调用插件**:用户可以说"播报新闻"或"获取新闻" +3. **指定新闻源**:用户可以说"播报澎湃新闻"或"获取百度热搜" +4. **获取详情**:用户可以说"详细介绍这条新闻" + +## 注意事项 + +1. 新闻源的 `source_id` 必须与API接口支持的ID一致 +2. 配置更改后需要重启服务或重新加载配置 +3. 如果配置的新闻源无效,插件会自动使用默认新闻源 +4. JSON格式必须正确,否则会使用默认配置 + +## 故障排除 + +### 问题:配置的新闻源不生效 + +**解决方案**: +1. 检查JSON格式是否正确 +2. 确认新闻源ID是否有效 +3. 查看日志文件中的错误信息 +4. 重启服务 + +### 问题:无法获取新闻 + +**解决方案**: +1. 检查网络连接 +2. 确认API接口地址是否正确 +3. 查看API接口是否可用 +4. 检查日志文件中的错误信息 + +## 技术实现 + +- 插件会从 `conn.config["plugins"]["get_news_from_newsnow"]["news_sources"]` 读取配置 +- 支持字符串格式的JSON和字典格式的配置 +- 配置验证和错误处理机制 +- 向后兼容,未配置时使用默认值 \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/202506251619.sql b/main/manager-api/src/main/resources/db/changelog/202506251619.sql new file mode 100644 index 00000000..a458daea --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202506251619.sql @@ -0,0 +1,21 @@ +-- 更新 get_news_from_newsnow 插件配置,添加新闻源配置字段 +-- 执行时间:2025-06-25 + +-- 更新现有的 get_news_from_newsnow 插件配置 +UPDATE ai_model_provider +SET fields = JSON_ARRAY( + JSON_OBJECT( + 'key', 'url', + 'type', 'string', + 'label', '接口地址', + 'default', 'https://newsnow.busiyi.world/api/s?id=' + ), + JSON_OBJECT( + 'key', 'news_sources', + 'type', 'json', + 'label', '新闻源配置', + 'default', '{"thepaper":"澎湃新闻","baidu":"百度热搜","cls-depth":"财联社"}' + ) +) +WHERE provider_code = 'get_news_from_newsnow' +AND model_type = 'Plugin'; \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index 1d6f94e3..c527398a 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -218,4 +218,11 @@ databaseChangeLog: changes: - sqlFile: encoding: utf8 - path: classpath:db/changelog/202506191643.sql \ No newline at end of file + path: classpath:db/changelog/202506191643.sql + - changeSet: + id: 202506251619 + author: Tink + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202506251619.sql \ No newline at end of file diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index 6dc0b25d..07d21cf8 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -120,7 +120,12 @@ plugins: society_rss_url: "https://www.chinanews.com.cn/rss/society.xml" world_rss_url: "https://www.chinanews.com.cn/rss/world.xml" finance_rss_url: "https://www.chinanews.com.cn/rss/finance.xml" - get_news_from_newsnow: {"url": "https://newsnow.busiyi.world/api/s?id="} + get_news_from_newsnow: + url: "https://newsnow.busiyi.world/api/s?id=" + news_sources: + thepaper: "澎湃新闻" + baidu: "百度热搜" + cls-depth: "财联社" home_assistant: devices: - 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1 @@ -765,4 +770,4 @@ TTS: # 支持声音克隆,可自行上传音频,填入voice参数,voice参数为空时,使用默认声音 access_token: "U4YdYXVfpwWnk2t5Gp822zWPCuORyeJL" voice: "OUeAo1mhq6IBExi" - output_dir: tmp/ \ No newline at end of file + output_dir: tmp/ diff --git a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py index 1b98539d..6fcb7ba6 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py +++ b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py @@ -8,38 +8,61 @@ from markitdown import MarkItDown TAG = __name__ logger = setup_logging() -# 新闻来源字典,包含名称和对应的API ID -NEWS_SOURCES = { +# 默认新闻来源字典,当配置中没有指定时使用 +DEFAULT_NEWS_SOURCES = { "thepaper": "澎湃新闻", "baidu": "百度热搜", "cls-depth": "财联社", } -# 动态生成新闻源描述 -def generate_news_sources_description(): - sources_desc = [] - for source_id, source_name in NEWS_SOURCES.items(): - sources_desc.append(f"{source_name}({source_id})") - return "、".join(sources_desc) +def get_news_sources_from_config(conn): + """从配置中获取新闻源字典""" + try: + # 尝试从插件配置中获取新闻源 + if (conn.config.get("plugins") and + conn.config["plugins"].get("get_news_from_newsnow") and + conn.config["plugins"]["get_news_from_newsnow"].get("news_sources")): + + # 如果配置中是字符串,尝试解析JSON + news_sources_config = conn.config["plugins"]["get_news_from_newsnow"]["news_sources"] + if isinstance(news_sources_config, str): + try: + return json.loads(news_sources_config) + except json.JSONDecodeError: + logger.bind(tag=TAG).warning("新闻源配置JSON格式错误,使用默认配置") + return DEFAULT_NEWS_SOURCES + elif isinstance(news_sources_config, dict): + return news_sources_config + else: + logger.bind(tag=TAG).warning("新闻源配置格式错误,使用默认配置") + return DEFAULT_NEWS_SOURCES + else: + logger.bind(tag=TAG).debug("未找到新闻源配置,使用默认配置") + return DEFAULT_NEWS_SOURCES + except Exception as e: + logger.bind(tag=TAG).error(f"获取新闻源配置失败: {e},使用默认配置") + return DEFAULT_NEWS_SOURCES +# 静态函数描述,使用默认新闻源生成描述 GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = { "type": "function", "function": { "name": "get_news_from_newsnow", "description": ( "获取最新新闻,随机选择一条新闻进行播报。" - f"用户可以选择不同的新闻源,如{generate_news_sources_description()}等。" + "用户可以选择不同的新闻源,如澎湃新闻(thepaper)、百度热搜(baidu)、财联社(cls-depth)等。" "如果没有指定,默认从澎湃新闻获取。" "用户可以要求获取详细内容,此时会获取新闻的详细内容。" + "注意:实际可用的新闻源取决于系统配置。" ), "parameters": { "type": "object", "properties": { "source": { "type": "string", - "description": f"新闻源,例如{generate_news_sources_description()}等。可选参数,如果不提供则使用默认新闻源", + "description": "新闻源,例如thepaper、baidu、cls-depth等。可选参数,如果不提供则使用默认新闻源", }, "detail": { "type": "boolean", @@ -115,6 +138,9 @@ def get_news_from_newsnow( ): """获取新闻并随机选择一条进行播报,或获取上一条新闻的详细内容""" try: + # 获取当前配置的新闻源 + news_sources = get_news_sources_from_config(conn) + # 如果detail为True,获取上一条新闻的详细内容 detail = str(detail).lower() == "true" if detail: @@ -132,7 +158,7 @@ def get_news_from_newsnow( url = conn.last_newsnow_link.get("url") title = conn.last_newsnow_link.get("title", "未知标题") source_id = conn.last_newsnow_link.get("source_id", "thepaper") - source_name = NEWS_SOURCES.get(source_id, "未知来源") + source_name = news_sources.get(source_id, "未知来源") if not url or url == "#": return ActionResponse( @@ -167,11 +193,11 @@ def get_news_from_newsnow( # 否则,获取新闻列表并随机选择一条 # 验证新闻源是否有效,如果无效则使用默认源 - if source not in NEWS_SOURCES: + if source not in news_sources: logger.bind(tag=TAG).warning(f"无效的新闻源: {source},使用默认源thepaper") source = "thepaper" - source_name = NEWS_SOURCES.get(source, "澎湃新闻") + source_name = news_sources.get(source, "澎湃新闻") logger.bind(tag=TAG).info(f"获取新闻: 新闻源={source}({source_name})") # 获取新闻列表 From 0631fe37ead25c97f40b0bf46b86e7e47faa6bea Mon Sep 17 00:00:00 2001 From: JianYu Zheng <2375294554@qq.com> Date: Wed, 25 Jun 2025 16:37:04 +0800 Subject: [PATCH 57/91] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=B0=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=9A=E8=8E=B7=E5=8F=96=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E7=9A=84Mcp=E6=8E=A5=E5=85=A5=E7=82=B9=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=EF=BC=88/agent/mcp/address/{audioId}=EF=BC=89=20--AgentMcpAcce?= =?UTF-8?q?ssPointServiceImpl.java=20=E5=AE=9E=E7=8E=B0=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E7=9A=84mcp=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E7=82=B9=E5=9C=B0=E5=9D=80=E5=AE=9A=E4=B9=89=20--AgentMcpAcces?= =?UTF-8?q?sPointController.java=20=E6=B7=BB=E5=8A=A0=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E7=9A=84Mcp=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E7=82=B9=E5=9C=B0=E5=9D=80=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AgentMcpAccessPointController.java | 31 ++++++++++ .../impl/AgentMcpAccessPointServiceImpl.java | 62 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentMcpAccessPointController.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentMcpAccessPointController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentMcpAccessPointController.java new file mode 100644 index 00000000..9792fd0f --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentMcpAccessPointController.java @@ -0,0 +1,31 @@ +package xiaozhi.modules.agent.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import xiaozhi.common.utils.Result; +import xiaozhi.modules.agent.service.AgentMcpAccessPointService; + +@Tag(name = "智能体Mcp接入点管理") +@RequiredArgsConstructor +@RestController +@RequestMapping("/agent/mcp") +public class AgentMcpAccessPointController { + private final AgentMcpAccessPointService agentMcpAccessPointService; + + /** + * 获取智能体的Mcp接入点地址 + * @param audioId 智能体id + * @return 返回错误提醒或者Mcp接入点地址 + */ + @Operation(summary = "获取智能体的Mcp接入点地址") + @GetMapping("/address/{audioId}") + public Result getAgentMcpAccessAddress(@PathVariable("audioId") String audioId) { + String agentMcpAccessAddress = agentMcpAccessPointService.getAgentMcpAccessAddress(audioId); + if (agentMcpAccessAddress == null) { + return new Result().error("请进入参数管理配置mcp接入点地址"); + } + return new Result().ok(agentMcpAccessAddress); + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java new file mode 100644 index 00000000..9198fe29 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java @@ -0,0 +1,62 @@ +package xiaozhi.modules.agent.service.impl; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import xiaozhi.common.constant.Constant; +import xiaozhi.common.utils.AESUtils; +import xiaozhi.common.utils.HashEncryptionUtil; +import xiaozhi.modules.agent.service.AgentMcpAccessPointService; +import xiaozhi.modules.sys.service.SysParamsService; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; + +@AllArgsConstructor +@Service +@Slf4j +public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointService { + private SysParamsService sysParamsService; + + @Override + public String getAgentMcpAccessAddress(String id) { + // 获取到mcp的地址 + String url = sysParamsService.getValue(Constant.SERVER_MCP_ENDPOINT, true); + if (StringUtils.isBlank(url)) { + return null; + } + try { + // 使用md5对智能体id进行加密 + String md5 = HashEncryptionUtil.Md5hexDigest(id); + // aes需要加密文本 + String json = "{\"agentId\": %s}".formatted(md5); + URI uri = new URI(url); + String query = uri.getQuery(); + // 获取aes加密密钥 + String str = "key="; + String key = query.substring(query.indexOf(str) + str.length()); + // 加密后成token值 + String encryptToken = AESUtils.encrypt(key, json); + // 获取路径组成部分 + // 获取协议 + String wsScheme = (uri.getScheme().equals("https")) ? "wss" : "ws" ; + // 获取主机,端口,路径 + String path = uri.getSchemeSpecificPart(); + // 获取参数 + path = path.substring(0,path.lastIndexOf("/")); + // 返回智能体Mcp路径的格式 + String AgentMcpUrl = "%s:%s/mcp?token=%s"; + return AgentMcpUrl.formatted(wsScheme,path,encryptToken); + } catch (URISyntaxException e) { + log.error("路径格式不正确路径:{},\n错误信息:{}",url,e.getMessage()); + throw new RuntimeException("mcp的地址存在错误,请进入参数管理修改mcp接入点地址"); + } + } + + @Override + public List getAgentMcpToolsList(String id) { + return List.of(); + } +} From 78dc266eeecdbdf85918700c7fb3fb6b2e9490af Mon Sep 17 00:00:00 2001 From: Tink Date: Wed, 25 Jun 2025 16:49:54 +0800 Subject: [PATCH 58/91] change newsnow plugin doc --- docs/newsnow_plugin_config.md | 37 +++++++---------------------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/docs/newsnow_plugin_config.md b/docs/newsnow_plugin_config.md index 3aff257d..88744423 100644 --- a/docs/newsnow_plugin_config.md +++ b/docs/newsnow_plugin_config.md @@ -28,8 +28,9 @@ plugins: baidu: "百度热搜" cls-depth: "财联社" # 可以添加更多新闻源 - sina: "新浪新闻" - sohu: "搜狐新闻" + weibo: "微博头条" + douyin: "抖音热搜" + solidot: "奇客Solidot" ``` ## 新闻源配置格式 @@ -50,8 +51,9 @@ plugins: "thepaper": "澎湃新闻", "baidu": "百度热搜", "cls-depth": "财联社", - "sina": "新浪新闻", - "sohu": "搜狐新闻" + "weibo": "微博头条", + "douyin": "抖音热搜", + "solidot": "奇客Solidot" } ``` @@ -79,29 +81,4 @@ plugins: 1. 新闻源的 `source_id` 必须与API接口支持的ID一致 2. 配置更改后需要重启服务或重新加载配置 3. 如果配置的新闻源无效,插件会自动使用默认新闻源 -4. JSON格式必须正确,否则会使用默认配置 - -## 故障排除 - -### 问题:配置的新闻源不生效 - -**解决方案**: -1. 检查JSON格式是否正确 -2. 确认新闻源ID是否有效 -3. 查看日志文件中的错误信息 -4. 重启服务 - -### 问题:无法获取新闻 - -**解决方案**: -1. 检查网络连接 -2. 确认API接口地址是否正确 -3. 查看API接口是否可用 -4. 检查日志文件中的错误信息 - -## 技术实现 - -- 插件会从 `conn.config["plugins"]["get_news_from_newsnow"]["news_sources"]` 读取配置 -- 支持字符串格式的JSON和字典格式的配置 -- 配置验证和错误处理机制 -- 向后兼容,未配置时使用默认值 \ No newline at end of file +4. JSON格式必须正确,否则会使用默认配置 \ No newline at end of file From a8620f21a08a97096fc77ea5f12de3aa52de4dd8 Mon Sep 17 00:00:00 2001 From: JianYu Zheng <2375294554@qq.com> Date: Wed, 25 Jun 2025 17:17:26 +0800 Subject: [PATCH 59/91] =?UTF-8?q?=E4=BC=98=E5=8C=96:=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E5=88=86=E5=89=B2=E6=8F=90=E5=8F=96=20--AgentMcpAccessPointSer?= =?UTF-8?q?viceImpl.java=20=E6=96=B9=E6=B3=95=E5=88=86=E5=89=B2=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E5=85=B1=E7=94=A8=201.=E8=8E=B7=E5=8F=96=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E6=96=B9=E6=B3=95=202.=E8=8E=B7=E5=8F=96=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93mcp=E6=8E=A5=E5=85=A5=E7=82=B9url=E9=83=A8?= =?UTF-8?q?=E5=88=86=203.=E8=8E=B7=E5=8F=96=E5=AF=B9=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93id=E5=8A=A0=E5=AF=86=E7=9A=84token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/AgentMcpAccessPointServiceImpl.java | 83 ++++++++++++++----- 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java index 9198fe29..c962e25f 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java @@ -3,6 +3,7 @@ package xiaozhi.modules.agent.service.impl; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import xiaozhi.common.constant.Constant; import xiaozhi.common.utils.AESUtils; @@ -28,35 +29,75 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic return null; } try { - // 使用md5对智能体id进行加密 - String md5 = HashEncryptionUtil.Md5hexDigest(id); - // aes需要加密文本 - String json = "{\"agentId\": %s}".formatted(md5); URI uri = new URI(url); - String query = uri.getQuery(); - // 获取aes加密密钥 - String str = "key="; - String key = query.substring(query.indexOf(str) + str.length()); - // 加密后成token值 - String encryptToken = AESUtils.encrypt(key, json); - // 获取路径组成部分 - // 获取协议 - String wsScheme = (uri.getScheme().equals("https")) ? "wss" : "ws" ; - // 获取主机,端口,路径 - String path = uri.getSchemeSpecificPart(); - // 获取参数 - path = path.substring(0,path.lastIndexOf("/")); + // 获取智能体mcp的url前缀 + String agentMcpUrl = getAgentMcpUrl(uri); + // 获取密钥 + String key = getSecretKey(uri); + // 获取加密的token + String encryptToken = encryptToken(id, key); // 返回智能体Mcp路径的格式 - String AgentMcpUrl = "%s:%s/mcp?token=%s"; - return AgentMcpUrl.formatted(wsScheme,path,encryptToken); + String AgentMcpUrl = "%s/mcp?token=%s"; + return AgentMcpUrl.formatted(agentMcpUrl,encryptToken); } catch (URISyntaxException e) { log.error("路径格式不正确路径:{},\n错误信息:{}",url,e.getMessage()); throw new RuntimeException("mcp的地址存在错误,请进入参数管理修改mcp接入点地址"); } } - @Override public List getAgentMcpToolsList(String id) { - return List.of(); + List list = List.of(); + String url = sysParamsService.getValue(Constant.SERVER_MCP_ENDPOINT, true); + if (StringUtils.isBlank(url)) { + return list; + } + + return list; + } + + /** + * 获取密钥 + * @param uri mcp地址 + * @return 密钥 + */ + private static String getSecretKey(URI uri) { + // 获取参数 + String query = uri.getQuery(); + // 获取aes加密密钥 + String str = "key="; + return query.substring(query.indexOf(str) + str.length()); + } + + /** + * 获取智能体mcp接入点url + * @param uri mcp地址 + * @return 智能体mcp接入点url + */ + private String getAgentMcpUrl(URI uri) { + // 获取协议 + String wsScheme = (uri.getScheme().equals("https")) ? "wss" : "ws" ; + // 获取主机,端口,路径 + String path = uri.getSchemeSpecificPart(); + // 获取到最后一个/前的path + path = path.substring(0,path.lastIndexOf("/")); + return wsScheme+":"+path; + } + + + + + /** + * 获取对智能体id加密的token + * @param agentId 智能体id + * @param key 加密密钥 + * @return 加密后token + */ + private static String encryptToken(String agentId, String key) { + // 使用md5对智能体id进行加密 + String md5 = HashEncryptionUtil.Md5hexDigest(agentId); + // aes需要加密文本 + String json = "{\"agentId\": %s}".formatted(md5); + // 加密后成token值 + return AESUtils.encrypt(key, json); } } From 61b4b6617bfcacd0437742c412e57620ef977a89 Mon Sep 17 00:00:00 2001 From: JianYu Zheng <2375294554@qq.com> Date: Wed, 25 Jun 2025 17:39:01 +0800 Subject: [PATCH 60/91] =?UTF-8?q?=E4=BC=98=E5=8C=96:=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E6=96=B9=E6=B3=95=EF=BC=8C=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E9=83=A8=E5=88=86getAgentMcpToolsList=E5=86=85=E5=AE=B9=20--Ag?= =?UTF-8?q?entMcpAccessPointServiceImpl.java=201.=E8=8E=B7=E5=8F=96URI?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=EF=BC=8C=E7=BB=9F=E4=B8=802=E4=B8=AA?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E8=8E=B7=E5=8F=96URI=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=E7=9A=84=E5=BC=82=E5=B8=B8=E5=A4=84=E7=90=86=202.=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0getAgentMcpToolsList=E9=83=A8=E5=88=86=E5=86=85?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/AgentMcpAccessPointServiceImpl.java | 65 +++++++++++++------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java index c962e25f..f05ab72f 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java @@ -28,22 +28,19 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic if (StringUtils.isBlank(url)) { return null; } - try { - URI uri = new URI(url); - // 获取智能体mcp的url前缀 - String agentMcpUrl = getAgentMcpUrl(uri); - // 获取密钥 - String key = getSecretKey(uri); - // 获取加密的token - String encryptToken = encryptToken(id, key); - // 返回智能体Mcp路径的格式 - String AgentMcpUrl = "%s/mcp?token=%s"; - return AgentMcpUrl.formatted(agentMcpUrl,encryptToken); - } catch (URISyntaxException e) { - log.error("路径格式不正确路径:{},\n错误信息:{}",url,e.getMessage()); - throw new RuntimeException("mcp的地址存在错误,请进入参数管理修改mcp接入点地址"); - } + URI uri = getURI(url); + // 获取智能体mcp的url前缀 + String agentMcpUrl = getAgentMcpUrl(uri); + // 获取密钥 + String key = getSecretKey(uri); + // 获取加密的token + String encryptToken = encryptToken(id, key); + // 返回智能体Mcp路径的格式 + String AgentMcpUrl = "%s/mcp?token=%s"; + return AgentMcpUrl.formatted(agentMcpUrl, encryptToken); + } + @Override public List getAgentMcpToolsList(String id) { List list = List.of(); @@ -51,12 +48,38 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic if (StringUtils.isBlank(url)) { return list; } + URI uri = getURI(url); + // 获取智能体mcp的url前缀 + String agentMcpUrl = getAgentMcpUrl(uri); + // 获取密钥 + String key = getSecretKey(uri); + // 获取加密的token + String encryptToken = encryptToken(id, key); + // 返回智能体Mcp路径的格式 + String AgentMcpUrl = "%s/call?token=%s"; + String formatted = AgentMcpUrl.formatted(agentMcpUrl, encryptToken); + return list; } + /** + * 获取URI对象 + * @param url 路径 + * @return URI对象 + */ + private static URI getURI(String url) { + try { + return new URI(url); + } catch (URISyntaxException e) { + log.error("路径格式不正确路径:{},\n错误信息:{}", url, e.getMessage()); + throw new RuntimeException("mcp的地址存在错误,请进入参数管理修改mcp接入点地址"); + } + } + /** * 获取密钥 + * * @param uri mcp地址 * @return 密钥 */ @@ -70,26 +93,26 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic /** * 获取智能体mcp接入点url + * * @param uri mcp地址 * @return 智能体mcp接入点url */ private String getAgentMcpUrl(URI uri) { // 获取协议 - String wsScheme = (uri.getScheme().equals("https")) ? "wss" : "ws" ; + String wsScheme = (uri.getScheme().equals("https")) ? "wss" : "ws"; // 获取主机,端口,路径 String path = uri.getSchemeSpecificPart(); // 获取到最后一个/前的path - path = path.substring(0,path.lastIndexOf("/")); - return wsScheme+":"+path; + path = path.substring(0, path.lastIndexOf("/")); + return wsScheme + ":" + path; } - - /** * 获取对智能体id加密的token + * * @param agentId 智能体id - * @param key 加密密钥 + * @param key 加密密钥 * @return 加密后token */ private static String encryptToken(String agentId, String key) { From 2348d9ceb3b9b7c5c1fe1bc46b0bf7e8a2bf61cd Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Wed, 25 Jun 2025 18:27:08 +0800 Subject: [PATCH 61/91] =?UTF-8?q?update:=E7=BB=9F=E4=B8=80=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=B3=A8=E5=86=8C=E5=8F=8A=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 160 ++----- .../core/handle/functionHandler.py | 103 ----- .../xiaozhi-server/core/handle/helloHandle.py | 2 +- .../core/handle/intentHandler.py | 45 +- main/xiaozhi-server/core/handle/iotHandle.py | 427 ------------------ main/xiaozhi-server/core/handle/textHandle.py | 4 +- main/xiaozhi-server/core/mcp/MCPClient.py | 164 ------- main/xiaozhi-server/core/mcp/manager.py | 184 -------- main/xiaozhi-server/core/tools/__init__.py | 1 + .../core/tools/base/__init__.py | 6 + .../core/tools/base/tool_executor.py | 26 ++ .../core/tools/base/tool_types.py | 45 ++ .../core/tools/device_iot/__init__.py | 12 + .../core/tools/device_iot/iot_descriptor.py | 46 ++ .../core/tools/device_iot/iot_executor.py | 243 ++++++++++ .../core/tools/device_iot/iot_handler.py | 86 ++++ .../core/tools/device_mcp/__init__.py | 21 + .../core/tools/device_mcp/mcp_client.py | 93 ++++ .../core/tools/device_mcp/mcp_executor.py | 72 +++ .../device_mcp/mcp_handler.py} | 60 ++- .../core/tools/server_mcp/__init__.py | 6 + .../core/tools/server_mcp/mcp_executor.py | 82 ++++ .../core/tools/server_mcp/mcp_manager.py | 100 ++++ .../core/tools/server_plugins/__init__.py | 5 + .../tools/server_plugins/plugin_executor.py | 102 +++++ .../core/tools/unified_tool_handler.py | 188 ++++++++ .../core/tools/unified_tool_manager.py | 128 ++++++ 27 files changed, 1356 insertions(+), 1055 deletions(-) delete mode 100644 main/xiaozhi-server/core/handle/functionHandler.py delete mode 100644 main/xiaozhi-server/core/handle/iotHandle.py delete mode 100644 main/xiaozhi-server/core/mcp/MCPClient.py delete mode 100644 main/xiaozhi-server/core/mcp/manager.py create mode 100644 main/xiaozhi-server/core/tools/__init__.py create mode 100644 main/xiaozhi-server/core/tools/base/__init__.py create mode 100644 main/xiaozhi-server/core/tools/base/tool_executor.py create mode 100644 main/xiaozhi-server/core/tools/base/tool_types.py create mode 100644 main/xiaozhi-server/core/tools/device_iot/__init__.py create mode 100644 main/xiaozhi-server/core/tools/device_iot/iot_descriptor.py create mode 100644 main/xiaozhi-server/core/tools/device_iot/iot_executor.py create mode 100644 main/xiaozhi-server/core/tools/device_iot/iot_handler.py create mode 100644 main/xiaozhi-server/core/tools/device_mcp/__init__.py create mode 100644 main/xiaozhi-server/core/tools/device_mcp/mcp_client.py create mode 100644 main/xiaozhi-server/core/tools/device_mcp/mcp_executor.py rename main/xiaozhi-server/core/{handle/mcpHandle.py => tools/device_mcp/mcp_handler.py} (87%) create mode 100644 main/xiaozhi-server/core/tools/server_mcp/__init__.py create mode 100644 main/xiaozhi-server/core/tools/server_mcp/mcp_executor.py create mode 100644 main/xiaozhi-server/core/tools/server_mcp/mcp_manager.py create mode 100644 main/xiaozhi-server/core/tools/server_plugins/__init__.py create mode 100644 main/xiaozhi-server/core/tools/server_plugins/plugin_executor.py create mode 100644 main/xiaozhi-server/core/tools/unified_tool_handler.py create mode 100644 main/xiaozhi-server/core/tools/unified_tool_manager.py diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 3ad94592..88e115d9 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -10,7 +10,6 @@ import threading import traceback import subprocess import websockets -from core.handle.mcpHandle import call_mcp_tool from core.utils.util import ( extract_json_from_string, check_vad_update, @@ -18,7 +17,7 @@ from core.utils.util import ( filter_sensitive_info, ) from typing import Dict, Any -from core.mcp.manager import MCPManager +from core.tools.base import ToolAction from core.utils.modules_initialize import ( initialize_modules, initialize_tts, @@ -30,7 +29,7 @@ from concurrent.futures import ThreadPoolExecutor from core.utils.dialogue import Message, Dialogue from core.providers.asr.dto.dto import InterfaceType from core.handle.textHandle import handleTextMessage -from core.handle.functionHandler import FunctionHandler +from core.tools.unified_tool_handler import UnifiedToolHandler from plugins_func.loadplugins import auto_import_modules from plugins_func.register import Action, ActionResponse from core.auth import AuthMiddleware, AuthenticationError @@ -586,14 +585,12 @@ class ConnectionHandler: self.intent.set_llm(self.llm) self.logger.bind(tag=TAG).info("使用主LLM作为意图识别模型") - """加载插件""" - self.func_handler = FunctionHandler(self) - self.mcp_manager = MCPManager(self) + """加载统一工具处理器""" + self.func_handler = UnifiedToolHandler(self) - """加载MCP工具""" - asyncio.run_coroutine_threadsafe( - self.mcp_manager.initialize_servers(), self.loop - ) + # 异步初始化工具处理器 + if hasattr(self, "loop") and self.loop: + asyncio.run_coroutine_threadsafe(self.func_handler._initialize(), self.loop) def change_system_prompt(self, prompt): self.prompt = prompt @@ -611,12 +608,6 @@ class ConnectionHandler: functions = None if self.intent_type == "function_call" and hasattr(self, "func_handler"): functions = self.func_handler.get_functions() - if hasattr(self, "mcp_client"): - mcp_tools = self.mcp_client.get_available_tools() - if mcp_tools is not None and len(mcp_tools) > 0: - if functions is None: - functions = [] - functions.extend(mcp_tools) response_message = [] try: @@ -630,7 +621,6 @@ class ConnectionHandler: self.sentence_id = str(uuid.uuid4().hex) - if self.intent_type == "function_call" and functions is not None: # 使用支持functions的streaming接口 llm_responses = self.llm.response_with_functions( @@ -734,59 +724,16 @@ class ConnectionHandler: "arguments": function_arguments, } - # 处理Server端MCP工具调用 - if self.mcp_manager.is_mcp_tool(function_name): - result = self._handle_mcp_tool_call(function_call_data) - elif hasattr(self, "mcp_client") and self.mcp_client.has_tool( - function_name - ): - # 如果是小智端MCP工具调用 - self.logger.bind(tag=TAG).debug( - f"调用小智端MCP工具: {function_name}, 参数: {function_arguments}" - ) - try: - result = asyncio.run_coroutine_threadsafe( - call_mcp_tool( - self, self.mcp_client, function_name, function_arguments - ), - self.loop, - ).result() - self.logger.bind(tag=TAG).debug(f"MCP工具调用结果: {result}") - - resultJson = None - if isinstance(result, str): - try: - resultJson = json.loads(result) - except Exception as e: - self.logger.bind(tag=TAG).error( - f"解析MCP工具返回结果失败: {e}" - ) - - # 视觉大模型不经过二次LLM处理 - if ( - resultJson is not None - and isinstance(resultJson, dict) - and "action" in resultJson - ): - result = ActionResponse( - action=Action[resultJson["action"]], - result=None, - response=resultJson.get("response", ""), - ) - else: - result = ActionResponse( - action=Action.REQLLM, result=result, response="" - ) - except Exception as e: - self.logger.bind(tag=TAG).error(f"MCP工具调用失败: {e}") - result = ActionResponse( - action=Action.REQLLM, result="MCP工具调用失败", response="" - ) - else: - # 处理系统函数 - result = self.func_handler.handle_llm_function_call( + # 使用统一工具处理器处理所有工具调用 + tool_result = asyncio.run_coroutine_threadsafe( + self.func_handler.handle_llm_function_call( self, function_call_data - ) + ), + self.loop, + ).result() + + # 转换ToolResult为ActionResponse + result = self._convert_tool_result_to_action_response(tool_result) self._handle_function_result(result, function_call_data) # 存储对话内容 @@ -809,47 +756,38 @@ class ConnectionHandler: return True - def _handle_mcp_tool_call(self, function_call_data): - function_arguments = function_call_data["arguments"] - function_name = function_call_data["name"] - try: - args_dict = function_arguments - if isinstance(function_arguments, str): - try: - args_dict = json.loads(function_arguments) - except json.JSONDecodeError: - self.logger.bind(tag=TAG).error( - f"无法解析 function_arguments: {function_arguments}" - ) - return ActionResponse( - action=Action.REQLLM, result="参数解析失败", response="" - ) - - tool_result = asyncio.run_coroutine_threadsafe( - self.mcp_manager.execute_tool(function_name, args_dict), self.loop - ).result() - # meta=None content=[TextContent(type='text', text='北京当前天气:\n温度: 21°C\n天气: 晴\n湿度: 6%\n风向: 西北 风\n风力等级: 5级', annotations=None)] isError=False - content_text = "" - if tool_result is not None and tool_result.content is not None: - for content in tool_result.content: - content_type = content.type - if content_type == "text": - content_text = content.text - elif content_type == "image": - pass - - if len(content_text) > 0: - return ActionResponse( - action=Action.REQLLM, result=content_text, response="" - ) - - except Exception as e: - self.logger.bind(tag=TAG).error(f"MCP工具调用错误: {e}") + def _convert_tool_result_to_action_response(self, tool_result): + """转换ToolResult为ActionResponse""" + if tool_result.action == ToolAction.ERROR: return ActionResponse( - action=Action.REQLLM, result="工具调用出错", response="" + action=Action.ERROR, + result=tool_result.error or tool_result.content, + response=tool_result.response or tool_result.content, + ) + elif tool_result.action == ToolAction.NOT_FOUND: + return ActionResponse( + action=Action.NOTFOUND, + result=tool_result.content, + response=tool_result.response or tool_result.content, + ) + elif tool_result.action == ToolAction.RESPONSE: + return ActionResponse( + action=Action.RESPONSE, + result=tool_result.content, + response=tool_result.response or tool_result.content, + ) + elif tool_result.action == ToolAction.REQUEST_LLM: + return ActionResponse( + action=Action.REQLLM, + result=tool_result.content, + response=tool_result.response or "", + ) + else: + return ActionResponse( + action=Action.NONE, + result=tool_result.content, + response=tool_result.response or "", ) - - return ActionResponse(action=Action.REQLLM, result="工具调用出错", response="") def _handle_function_result(self, result, function_call_data): if result.action == Action.RESPONSE: # 直接回复前端 @@ -945,9 +883,9 @@ class ConnectionHandler: self.timeout_task.cancel() self.timeout_task = None - # 清理MCP资源 - if hasattr(self, "mcp_manager") and self.mcp_manager: - await self.mcp_manager.cleanup_all() + # 清理工具处理器资源 + if hasattr(self, "func_handler") and self.func_handler: + await self.func_handler.cleanup() # 触发停止事件 if self.stop_event: diff --git a/main/xiaozhi-server/core/handle/functionHandler.py b/main/xiaozhi-server/core/handle/functionHandler.py deleted file mode 100644 index e1292553..00000000 --- a/main/xiaozhi-server/core/handle/functionHandler.py +++ /dev/null @@ -1,103 +0,0 @@ -from config.logger import setup_logging -import json -from plugins_func.register import ( - FunctionRegistry, - ActionResponse, - Action, - ToolType, - DeviceTypeRegistry, -) -from plugins_func.functions.hass_init import append_devices_to_prompt - -TAG = __name__ - - -class FunctionHandler: - def __init__(self, conn): - self.conn = conn - self.config = conn.config - self.device_type_registry = DeviceTypeRegistry() - self.function_registry = FunctionRegistry() - self.register_nessary_functions() - self.register_config_functions() - self.functions_desc = self.function_registry.get_all_function_desc() - self.finish_init = True - - def upload_functions_desc(self): - self.functions_desc = self.function_registry.get_all_function_desc() - - - def current_support_functions(self): - func_names = [] - for func in self.functions_desc: - func_names.append(func["function"]["name"]) - # 打印当前支持的函数列表 - self.conn.logger.bind(tag=TAG, session_id=self.conn.session_id).info( - f"当前支持的函数列表: {func_names}" - ) - return func_names - - def get_functions(self): - """获取功能调用配置""" - return self.functions_desc - - def register_nessary_functions(self): - """注册必要的函数""" - self.function_registry.register_function("handle_exit_intent") - self.function_registry.register_function("get_time") - self.function_registry.register_function("get_lunar") - - def register_config_functions(self): - """注册配置中的函数,可以不同客户端使用不同的配置""" - for func in self.config["Intent"][self.config["selected_module"]["Intent"]].get( - "functions", [] - ): - self.function_registry.register_function(func) - - """home assistant需要初始化提示词""" - append_devices_to_prompt(self.conn) - - def get_function(self, name): - return self.function_registry.get_function(name) - - def handle_llm_function_call(self, conn, function_call_data): - # 多函数调用处理 - if "function_calls" in function_call_data: - responses = [] - for call in function_call_data["function_calls"]: - func = self.get_function(call["name"]) - if func: - # 执行函数并收集响应 - response = func(conn, **call.get("arguments", {})) - responses.append(response) - return self._combine_responses(responses) # 合并响应 - try: - function_name = function_call_data["name"] - funcItem = self.get_function(function_name) - if not funcItem: - return ActionResponse( - action=Action.NOTFOUND, result="没有找到对应的函数", response="" - ) - func = funcItem.func - arguments = function_call_data["arguments"] - arguments = json.loads(arguments) if arguments else {} - self.conn.logger.bind(tag=TAG).debug( - f"调用函数: {function_name}, 参数: {arguments}" - ) - if ( - funcItem.type == ToolType.SYSTEM_CTL - or funcItem.type == ToolType.IOT_CTL - ): - return func(conn, **arguments) - elif funcItem.type == ToolType.WAIT: - return func(**arguments) - elif funcItem.type == ToolType.CHANGE_SYS_PROMPT: - return func(conn, **arguments) - else: - return ActionResponse( - action=Action.NOTFOUND, result="没有找到对应的函数", response="" - ) - except Exception as e: - self.conn.logger.bind(tag=TAG).error(f"处理function call错误: {e}") - - return None diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py index f5a3b0cd..cb025bc1 100644 --- a/main/xiaozhi-server/core/handle/helloHandle.py +++ b/main/xiaozhi-server/core/handle/helloHandle.py @@ -7,7 +7,7 @@ from core.utils.util import audio_to_data from core.handle.sendAudioHandle import sendAudioMessage, send_stt_message from core.utils.util import remove_punctuation_and_length, opus_datas_to_wav_bytes from core.providers.tts.dto.dto import ContentType, SentenceType -from core.handle.mcpHandle import ( +from core.tools.device_mcp import ( MCPClient, send_mcp_initialize_message, send_mcp_tools_list_request, diff --git a/main/xiaozhi-server/core/handle/intentHandler.py b/main/xiaozhi-server/core/handle/intentHandler.py index de4c36c1..4a298e6e 100644 --- a/main/xiaozhi-server/core/handle/intentHandler.py +++ b/main/xiaozhi-server/core/handle/intentHandler.py @@ -6,7 +6,7 @@ from core.handle.helloHandle import checkWakeupWords from core.utils.util import remove_punctuation_and_length from core.providers.tts.dto.dto import ContentType from core.utils.dialogue import Message -from core.handle.mcpHandle import call_mcp_tool +from core.tools.device_mcp import call_mcp_tool from plugins_func.register import Action, ActionResponse from loguru import logger @@ -106,36 +106,19 @@ async def process_intent_result(conn, intent_result, original_text): def process_function_call(): conn.dialogue.put(Message(role="user", content=original_text)) - # 处理Server端MCP工具调用 - if conn.mcp_manager.is_mcp_tool(function_name): - result = conn._handle_mcp_tool_call(function_call_data) - elif hasattr(conn, "mcp_client") and conn.mcp_client.has_tool( - function_name - ): - # 如果是小智端MCP工具调用 - conn.logger.bind(tag=TAG).debug( - f"调用小智端MCP工具: {function_name}, 参数: {function_args}" - ) - try: - result = asyncio.run_coroutine_threadsafe( - call_mcp_tool( - conn, conn.mcp_client, function_name, function_args - ), - conn.loop, - ).result() - conn.logger.bind(tag=TAG).debug(f"MCP工具调用结果: {result}") - result = ActionResponse( - action=Action.REQLLM, result=result, response="" - ) - except Exception as e: - conn.logger.bind(tag=TAG).error(f"MCP工具调用失败: {e}") - result = ActionResponse( - action=Action.REQLLM, result="MCP工具调用失败", response="" - ) - else: - # 处理系统函数 - result = conn.func_handler.handle_llm_function_call( - conn, function_call_data + # 使用统一工具处理器处理所有工具调用 + try: + tool_result = asyncio.run_coroutine_threadsafe( + conn.func_handler.handle_llm_function_call(conn, function_call_data), + conn.loop, + ).result() + + # 转换ToolResult为ActionResponse + result = conn._convert_tool_result_to_action_response(tool_result) + except Exception as e: + conn.logger.bind(tag=TAG).error(f"工具调用失败: {e}") + result = ActionResponse( + action=Action.ERROR, result=str(e), response=str(e) ) if result: diff --git a/main/xiaozhi-server/core/handle/iotHandle.py b/main/xiaozhi-server/core/handle/iotHandle.py deleted file mode 100644 index bebb44fc..00000000 --- a/main/xiaozhi-server/core/handle/iotHandle.py +++ /dev/null @@ -1,427 +0,0 @@ -import json -import asyncio -from plugins_func.register import ( - FunctionItem, - register_device_function, - ActionResponse, - Action, - ToolType, -) - -TAG = __name__ - - -def wrap_async_function(async_func): - """包装异步函数为同步函数""" - - def wrapper(*args, **kwargs): - try: - # 获取连接对象(第一个参数) - conn = args[0] - if not hasattr(conn, "loop"): - conn.logger.bind(tag=TAG).error("Connection对象没有loop属性") - return ActionResponse( - Action.ERROR, - "Connection对象没有loop属性", - "执行操作时出错: Connection对象没有loop属性", - ) - - # 使用conn对象中的事件循环 - loop = conn.loop - # 在conn的事件循环中运行异步函数 - future = asyncio.run_coroutine_threadsafe(async_func(*args, **kwargs), loop) - # 等待结果返回 - return future.result() - except Exception as e: - conn.logger.bind(tag=TAG).error(f"运行异步函数时出错: {e}") - return ActionResponse(Action.ERROR, str(e), f"执行操作时出错: {e}") - - return wrapper - - -def create_iot_function(device_name, method_name, method_info): - """ - 根据IOT设备描述生成通用的控制函数 - """ - - async def iot_control_function( - conn, response_success=None, response_failure=None, **params - ): - try: - # 设置默认响应消息 - if not response_success: - response_success = "操作成功" - if not response_failure: - response_failure = "操作失败" - - # 打印响应参数 - conn.logger.bind(tag=TAG).debug( - f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'" - ) - - # 发送控制命令 - await send_iot_conn(conn, device_name, method_name, params) - # 等待一小段时间让状态更新 - await asyncio.sleep(0.1) - - # 生成结果信息 - result = f"{device_name}的{method_name}操作执行成功" - - # 处理响应中可能的占位符 - response = response_success - # 替换{value}占位符 - for param_name, param_value in params.items(): - # 先尝试直接替换参数值 - if "{" + param_name + "}" in response: - response = response.replace( - "{" + param_name + "}", str(param_value) - ) - - # 如果有{value}占位符,用相关参数替换 - if "{value}" in response: - response = response.replace("{value}", str(param_value)) - break - - return ActionResponse( - Action.REQLLM, - result=f"{device_name}操作执行成功,请继续处理剩余指令", - response=response_success # 保留成功提示 - ) - except Exception as e: - conn.logger.bind(tag=TAG).error( - f"执行{device_name}的{method_name}操作失败: {e}" - ) - - # 操作失败时使用大模型提供的失败响应 - response = response_failure - - return ActionResponse(Action.ERROR, str(e), response) - - return wrap_async_function(iot_control_function) - - -def create_iot_query_function(device_name, prop_name, prop_info): - """ - 根据IOT设备属性创建查询函数 - """ - - async def iot_query_function(conn, response_success=None, response_failure=None): - try: - # 打印响应参数 - conn.logger.bind(tag=TAG).info( - f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'" - ) - - value = await get_iot_status(conn, device_name, prop_name) - - # 查询成功,生成结果 - if value is not None: - # 使用大模型提供的成功响应,并替换其中的占位符 - response = response_success.replace("{value}", str(value)) - - return ActionResponse(Action.RESPONSE, str(value), response) - else: - # 查询失败,使用大模型提供的失败响应 - response = response_failure - - return ActionResponse(Action.ERROR, f"属性{prop_name}不存在", response) - except Exception as e: - conn.logger.bind(tag=TAG).error( - f"查询{device_name}的{prop_name}时出错: {e}" - ) - - # 查询出错时使用大模型提供的失败响应 - response = response_failure - - return ActionResponse(Action.ERROR, str(e), response) - - return wrap_async_function(iot_query_function) - - -class IotDescriptor: - """ - A class to represent an IoT descriptor. - """ - - def __init__(self, name, description, properties, methods): - self.name = name - self.description = description - self.properties = [] - self.methods = [] - - # 根据描述创建属性 - if properties is not None: - for key, value in properties.items(): - property_item = {} - property_item["name"] = key - property_item["description"] = value["description"] - if value["type"] == "number": - property_item["value"] = 0 - elif value["type"] == "boolean": - property_item["value"] = False - else: - property_item["value"] = "" - self.properties.append(property_item) - - # 根据描述创建方法 - if methods is not None: - for key, value in methods.items(): - method = {} - method["description"] = value["description"] - method["name"] = key - # 检查方法是否有参数 - if "parameters" in value: - method["parameters"] = {} - for k, v in value["parameters"].items(): - method["parameters"][k] = { - "description": v["description"], - "type": v["type"], - } - self.methods.append(method) - - -def register_device_type(descriptor, device_type_registry): - """注册设备类型及其功能""" - device_name = descriptor["name"] - type_id = device_type_registry.generate_device_type_id(descriptor) - - # 如果该类型已注册,直接返回类型ID - if type_id in device_type_registry.type_functions: - return type_id - - functions = {} - - # 为每个属性创建查询函数 - for prop_name, prop_info in descriptor["properties"].items(): - func_name = f"get_{device_name.lower()}_{prop_name.lower()}" - func_desc = { - "type": "function", - "function": { - "name": func_name, - "description": f"查询{descriptor['description']}的{prop_info['description']}", - "parameters": { - "type": "object", - "properties": { - "response_success": { - "type": "string", - "description": f"查询成功时的友好回复,必须使用{{value}}作为占位符表示查询到的值", - }, - "response_failure": { - "type": "string", - "description": f"查询失败时的友好回复,例如:'无法获取{device_name}的{prop_info['description']}'", - }, - }, - "required": ["response_success", "response_failure"], - }, - }, - } - query_func = create_iot_query_function(device_name, prop_name, prop_info) - decorated_func = register_device_function( - func_name, func_desc, ToolType.IOT_CTL - )(query_func) - functions[func_name] = FunctionItem( - func_name, func_desc, decorated_func, ToolType.IOT_CTL - ) - - # 为每个方法创建控制函数 - for method_name, method_info in descriptor["methods"].items(): - func_name = f"{device_name.lower()}_{method_name.lower()}" - - # 创建参数字典,添加原有参数 - parameters = {} - required_params = [] - - # 如果方法有参数,则添加参数信息 - if "parameters" in method_info: - parameters = { - param_name: { - "type": param_info["type"], - "description": param_info["description"], - } - for param_name, param_info in method_info["parameters"].items() - } - required_params = list(method_info["parameters"].keys()) - - # 添加响应参数 - parameters.update( - { - "response_success": { - "type": "string", - "description": "操作成功时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称", - }, - "response_failure": { - "type": "string", - "description": "操作失败时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称", - }, - } - ) - - # 构建必须参数列表(原有参数 + 响应参数) - required_params.extend(["response_success", "response_failure"]) - - func_desc = { - "type": "function", - "function": { - "name": func_name, - "description": f"{descriptor['description']} - {method_info['description']}", - "parameters": { - "type": "object", - "properties": parameters, - "required": required_params, - }, - }, - } - control_func = create_iot_function(device_name, method_name, method_info) - decorated_func = register_device_function( - func_name, func_desc, ToolType.IOT_CTL - )(control_func) - functions[func_name] = FunctionItem( - func_name, func_desc, decorated_func, ToolType.IOT_CTL - ) - - device_type_registry.register_device_type(type_id, functions) - return type_id - - -# 用于接受前端设备推送的搜索iot描述 -async def handleIotDescriptors(conn, descriptors): - wait_max_time = 5 - while conn.func_handler is None or not conn.func_handler.finish_init: - await asyncio.sleep(1) - wait_max_time -= 1 - if wait_max_time <= 0: - conn.logger.bind(tag=TAG).debug("连接对象没有func_handler") - return - """处理物联网描述""" - functions_changed = False - - for descriptor in descriptors: - # 如果descriptor没有properties和methods,则直接跳过 - if "properties" not in descriptor and "methods" not in descriptor: - continue - - # 处理缺失properties的情况 - if "properties" not in descriptor: - descriptor["properties"] = {} - # 从methods中提取所有参数作为properties - if "methods" in descriptor: - for method_name, method_info in descriptor["methods"].items(): - if "parameters" in method_info: - for param_name, param_info in method_info["parameters"].items(): - # 将参数信息转换为属性信息 - descriptor["properties"][param_name] = { - "description": param_info["description"], - "type": param_info["type"], - } - - # 创建IOT设备描述符 - iot_descriptor = IotDescriptor( - descriptor["name"], - descriptor["description"], - descriptor["properties"], - descriptor["methods"], - ) - conn.iot_descriptors[descriptor["name"]] = iot_descriptor - - if conn.load_function_plugin: - # 注册或获取设备类型 - device_type_registry = conn.func_handler.device_type_registry - type_id = register_device_type(descriptor, device_type_registry) - device_functions = device_type_registry.get_device_functions(type_id) - - # 在连接级注册设备函数 - if hasattr(conn, "func_handler"): - for func_name, func_item in device_functions.items(): - conn.func_handler.function_registry.register_function( - func_name, func_item - ) - conn.logger.bind(tag=TAG).info( - f"注册IOT函数到function handler: {func_name}" - ) - functions_changed = True - - # 如果注册了新函数,更新function描述列表 - if functions_changed and hasattr(conn, "func_handler"): - conn.func_handler.upload_functions_desc() - - func_names = conn.func_handler.current_support_functions() - conn.logger.bind(tag=TAG).info(f"设备类型: {type_id}") - conn.logger.bind(tag=TAG).info( - f"更新function描述列表完成,当前支持的函数: {func_names}" - ) - - -async def handleIotStatus(conn, states): - """处理物联网状态""" - for state in states: - for key, value in conn.iot_descriptors.items(): - if key == state["name"]: - for property_item in value.properties: - for k, v in state["state"].items(): - if property_item["name"] == k: - if type(v) != type(property_item["value"]): - conn.logger.bind(tag=TAG).error( - f"属性{property_item['name']}的值类型不匹配" - ) - break - else: - property_item["value"] = v - conn.logger.bind(tag=TAG).info( - f"物联网状态更新: {key} , {property_item['name']} = {v}" - ) - break - break - - -async def get_iot_status(conn, name, property_name): - """获取物联网状态""" - for key, value in conn.iot_descriptors.items(): - if key == name: - for property_item in value.properties: - if property_item["name"] == property_name: - return property_item["value"] - conn.logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}") - return None - - -async def set_iot_status(conn, name, property_name, value): - """设置物联网状态""" - for key, iot_descriptor in conn.iot_descriptors.items(): - if key == name: - for property_item in iot_descriptor.properties: - if property_item["name"] == property_name: - if type(value) != type(property_item["value"]): - conn.logger.bind(tag=TAG).error( - f"属性{property_item['name']}的值类型不匹配" - ) - return - property_item["value"] = value - conn.logger.bind(tag=TAG).info( - f"物联网状态更新: {name} , {property_name} = {value}" - ) - return - conn.logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}") - - -async def send_iot_conn(conn, name, method_name, parameters): - """发送物联网指令""" - for key, value in conn.iot_descriptors.items(): - if key == name: - # 找到了设备 - for method in value.methods: - # 找到了方法 - if method["name"] == method_name: - # 构建命令对象 - command = { - "name": name, - "method": method_name, - } - - # 只有当参数不为空时才添加parameters字段 - if parameters: - command["parameters"] = parameters - send_message = json.dumps({"type": "iot", "commands": [command]}) - await conn.websocket.send(send_message) - conn.logger.bind(tag=TAG).info(f"发送物联网指令: {send_message}") - return - conn.logger.bind(tag=TAG).error(f"未找到方法{method_name}") diff --git a/main/xiaozhi-server/core/handle/textHandle.py b/main/xiaozhi-server/core/handle/textHandle.py index 676e33ab..3b3526d7 100644 --- a/main/xiaozhi-server/core/handle/textHandle.py +++ b/main/xiaozhi-server/core/handle/textHandle.py @@ -1,11 +1,11 @@ import json from core.handle.abortHandle import handleAbortMessage from core.handle.helloHandle import handleHelloMessage -from core.handle.mcpHandle import handle_mcp_message +from core.tools.device_mcp import handle_mcp_message from core.utils.util import remove_punctuation_and_length, filter_sensitive_info from core.handle.receiveAudioHandle import startToChat, handleAudioMessage from core.handle.sendAudioHandle import send_stt_message, send_tts_message -from core.handle.iotHandle import handleIotDescriptors, handleIotStatus +from core.tools.device_iot import handleIotDescriptors, handleIotStatus from core.handle.reportHandle import enqueue_asr_report import asyncio diff --git a/main/xiaozhi-server/core/mcp/MCPClient.py b/main/xiaozhi-server/core/mcp/MCPClient.py deleted file mode 100644 index 5b7bab55..00000000 --- a/main/xiaozhi-server/core/mcp/MCPClient.py +++ /dev/null @@ -1,164 +0,0 @@ -from __future__ import annotations - -from datetime import timedelta -import asyncio, os, shutil, concurrent.futures -from contextlib import AsyncExitStack -from typing import Optional, List, Dict, Any - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.client.sse import sse_client -from config.logger import setup_logging -from core.utils.util import sanitize_tool_name - -TAG = __name__ - - -class MCPClient: - def __init__(self, config: Dict[str, Any]): - self.logger = setup_logging() - self.config = config - - self._worker_task: Optional[asyncio.Task] = None - self._ready_evt = asyncio.Event() - self._shutdown_evt = asyncio.Event() - - self.session: Optional[ClientSession] = None - self.tools: List = [] # original tool objects - self.tools_dict: Dict[str, Any] = {} - self.name_mapping: Dict[str, str] = {} - - async def initialize(self): - if self._worker_task: - return - self._worker_task = asyncio.create_task(self._worker(), name="MCPClientWorker") - await self._ready_evt.wait() - - self.logger.bind(tag=TAG).info( - f"Connected, tools = {[name for name in self.name_mapping.values()]}" - ) - - async def cleanup(self): - if not self._worker_task: - return - - self._shutdown_evt.set() - try: - await asyncio.wait_for(self._worker_task, timeout=20) - except (asyncio.TimeoutError, Exception) as e: - self.logger.bind(tag=TAG).error(f"worker shutdown err: {e}") - finally: - self._worker_task = None - - def has_tool(self, name: str) -> bool: - return name in self.tools_dict - - def get_available_tools(self): - return [ - { - "type": "function", - "function": { - "name": name, - "description": tool.description, - "parameters": tool.inputSchema, - }, - } - for name, tool in self.tools_dict.items() - ] - - async def call_tool(self, name: str, args: dict): - if not self.session: - raise RuntimeError("MCPClient not initialized") - - real_name = self.name_mapping.get(name, name) - loop = self._worker_task.get_loop() - coro = self.session.call_tool(real_name, args) - - if loop is asyncio.get_running_loop(): - return await coro - - fut: concurrent.futures.Future = asyncio.run_coroutine_threadsafe(coro, loop) - return await asyncio.wrap_future(fut) - - def is_connected(self) -> bool: - """检查MCP客户端是否连接正常 - - Returns: - bool: 如果客户端已连接并正常工作,返回True,否则返回False - """ - # 检查工作任务是否存在 - if self._worker_task is None: - return False - - # 检查工作任务是否已经完成或取消 - if self._worker_task.done(): - return False - - # 检查会话是否存在 - if self.session is None: - return False - - # 所有检查都通过,连接正常 - return True - - async def _worker(self): - async with AsyncExitStack() as stack: - try: - # 建立 StdioClient - if "command" in self.config: - cmd = ( - shutil.which("npx") - if self.config["command"] == "npx" - else self.config["command"] - ) - env = {**os.environ, **self.config.get("env", {})} - params = StdioServerParameters( - command=cmd, - args=self.config.get("args", []), - env=env, - ) - stdio_r, stdio_w = await stack.enter_async_context( - stdio_client(params) - ) - read_stream, write_stream = stdio_r, stdio_w - # 建立SSEClient - elif "url" in self.config: - if "API_ACCESS_TOKEN" in self.config: - headers = { - "Authorization": f"Bearer {self.config['API_ACCESS_TOKEN']}" - } - else: - headers = {} - sse_r, sse_w = await stack.enter_async_context( - sse_client(self.config["url"], headers=headers) - ) - read_stream, write_stream = sse_r, sse_w - - else: - raise ValueError("MCPClient config must include 'command' or 'url'") - - self.session = await stack.enter_async_context( - ClientSession( - read_stream=read_stream, - write_stream=write_stream, - read_timeout_seconds=timedelta(seconds=15), - ) - ) - await self.session.initialize() - - # 获取工具 - self.tools = (await self.session.list_tools()).tools - for t in self.tools: - sanitized = sanitize_tool_name(t.name) - self.tools_dict[sanitized] = t - self.name_mapping[sanitized] = t.name - - self._ready_evt.set() - - # 挂起等待关闭 - await self._shutdown_evt.wait() - - except Exception as e: - self.logger.bind(tag=TAG).error(f"worker error: {e}") - self._ready_evt.set() - raise diff --git a/main/xiaozhi-server/core/mcp/manager.py b/main/xiaozhi-server/core/mcp/manager.py deleted file mode 100644 index 1c677e0e..00000000 --- a/main/xiaozhi-server/core/mcp/manager.py +++ /dev/null @@ -1,184 +0,0 @@ -"""MCP服务管理器""" - -import asyncio -import os, json -from typing import Dict, Any, List -from .MCPClient import MCPClient -from plugins_func.register import register_function, ToolType -from config.config_loader import get_project_dir - -TAG = __name__ - - -class MCPManager: - """管理多个MCP服务的集中管理器""" - - def __init__(self, conn) -> None: - """ - 初始化MCP管理器 - """ - self.conn = conn - self.config_path = get_project_dir() + "data/.mcp_server_settings.json" - if os.path.exists(self.config_path) == False: - self.config_path = "" - self.conn.logger.bind(tag=TAG).warning( - f"请检查mcp服务配置文件:data/.mcp_server_settings.json" - ) - self.client: Dict[str, MCPClient] = {} - self.tools = [] - - def load_config(self) -> Dict[str, Any]: - """加载MCP服务配置 - Returns: - Dict[str, Any]: 服务配置字典 - """ - if len(self.config_path) == 0: - return {} - - try: - with open(self.config_path, "r", encoding="utf-8") as f: - config = json.load(f) - return config.get("mcpServers", {}) - except Exception as e: - self.conn.logger.bind(tag=TAG).error( - f"Error loading MCP config from {self.config_path}: {e}" - ) - return {} - - async def initialize_servers(self) -> None: - """初始化所有MCP服务""" - config = self.load_config() - for name, srv_config in config.items(): - if not srv_config.get("command") and not srv_config.get("url"): - self.conn.logger.bind(tag=TAG).warning( - f"Skipping server {name}: neither command nor url specified" - ) - continue - - try: - client = MCPClient(srv_config) - await client.initialize() - self.client[name] = client - self.conn.logger.bind(tag=TAG).info(f"Initialized MCP client: {name}") - client_tools = client.get_available_tools() - self.tools.extend(client_tools) - for tool in client_tools: - func_name = "mcp_" + tool["function"]["name"] - register_function(func_name, tool, ToolType.MCP_CLIENT)( - self.execute_tool - ) - self.conn.func_handler.function_registry.register_function( - func_name - ) - - except Exception as e: - self.conn.logger.bind(tag=TAG).error( - f"Failed to initialize MCP server {name}: {e}" - ) - self.conn.func_handler.upload_functions_desc() - - def get_all_tools(self) -> List[Dict[str, Any]]: - """获取所有服务的工具function定义 - Returns: - List[Dict[str, Any]]: 所有工具的function定义列表 - """ - return self.tools - - def is_mcp_tool(self, tool_name: str) -> bool: - """检查是否是MCP工具 - Args: - tool_name: 工具名称 - Returns: - bool: 是否是MCP工具 - """ - for tool in self.tools: - if ( - tool.get("function") != None - and tool["function"].get("name") == tool_name - ): - return True - return False - - async def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any: - """执行工具调用,失败时会尝试重新连接 - Args: - tool_name: 工具名称 - arguments: 工具参数 - Returns: - Any: 工具执行结果 - Raises: - ValueError: 工具未找到时抛出 - """ - self.conn.logger.bind(tag=TAG).info( - f"Executing tool {tool_name} with arguments: {arguments}" - ) - - max_retries = 3 # 最大重试次数 - retry_interval = 2 # 重试间隔(秒) - - # 找到对应的客户端 - client_name = None - target_client = None - for name, client in self.client.items(): - if client.has_tool(tool_name): - client_name = name - target_client = client - break - - if not target_client: - raise ValueError(f"Tool {tool_name} not found in any MCP server") - - # 带重试机制的工具调用 - for attempt in range(max_retries): - try: - return await target_client.call_tool(tool_name, arguments) - except Exception as e: - # 最后一次尝试失败时直接抛出异常 - if attempt == max_retries - 1: - raise - - self.conn.logger.bind(tag=TAG).warning( - f"执行工具 {tool_name} 失败 (尝试 {attempt+1}/{max_retries}): {e}" - ) - - # 尝试重新连接 - self.conn.logger.bind(tag=TAG).info( - f"重试前尝试重新连接 MCP 客户端 {client_name}" - ) - try: - # 关闭旧的连接 - await target_client.cleanup() - - # 重新初始化客户端 - config = self.load_config() - if client_name in config: - client = MCPClient(config[client_name]) - await client.initialize() - self.client[client_name] = client - target_client = client - self.conn.logger.bind(tag=TAG).info( - f"成功重新连接 MCP 客户端: {client_name}" - ) - else: - self.conn.logger.bind(tag=TAG).error( - f"Cannot reconnect MCP client {client_name}: config not found" - ) - except Exception as reconnect_error: - self.conn.logger.bind(tag=TAG).error( - f"Failed to reconnect MCP client {client_name}: {reconnect_error}" - ) - - # 等待一段时间再重试 - await asyncio.sleep(retry_interval) - - async def cleanup_all(self) -> None: - """依次关闭所有 MCPClient,不让异常阻断整体流程。""" - for name, client in list(self.client.items()): - try: - await asyncio.wait_for(client.cleanup(), timeout=20) - self.conn.logger.bind(tag=TAG).info(f"MCP client closed: {name}") - except (asyncio.TimeoutError, Exception) as e: - self.conn.logger.bind(tag=TAG).error( - f"Error closing MCP client {name}: {e}" - ) - self.client.clear() diff --git a/main/xiaozhi-server/core/tools/__init__.py b/main/xiaozhi-server/core/tools/__init__.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/main/xiaozhi-server/core/tools/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/main/xiaozhi-server/core/tools/base/__init__.py b/main/xiaozhi-server/core/tools/base/__init__.py new file mode 100644 index 00000000..1bc6dc32 --- /dev/null +++ b/main/xiaozhi-server/core/tools/base/__init__.py @@ -0,0 +1,6 @@ +"""基础工具定义模块""" + +from .tool_types import ToolType, ToolAction, ToolResult, ToolDefinition +from .tool_executor import ToolExecutor + +__all__ = ["ToolType", "ToolAction", "ToolResult", "ToolDefinition", "ToolExecutor"] diff --git a/main/xiaozhi-server/core/tools/base/tool_executor.py b/main/xiaozhi-server/core/tools/base/tool_executor.py new file mode 100644 index 00000000..47cdf718 --- /dev/null +++ b/main/xiaozhi-server/core/tools/base/tool_executor.py @@ -0,0 +1,26 @@ +"""工具执行器基类定义""" + +from abc import ABC, abstractmethod +from typing import Dict, Any +from .tool_types import ToolDefinition, ToolResult + + +class ToolExecutor(ABC): + """工具执行器抽象基类""" + + @abstractmethod + async def execute( + self, conn, tool_name: str, arguments: Dict[str, Any] + ) -> ToolResult: + """执行工具调用""" + pass + + @abstractmethod + def get_tools(self) -> Dict[str, ToolDefinition]: + """获取该执行器管理的所有工具""" + pass + + @abstractmethod + def has_tool(self, tool_name: str) -> bool: + """检查是否有指定工具""" + pass diff --git a/main/xiaozhi-server/core/tools/base/tool_types.py b/main/xiaozhi-server/core/tools/base/tool_types.py new file mode 100644 index 00000000..ba93c3a7 --- /dev/null +++ b/main/xiaozhi-server/core/tools/base/tool_types.py @@ -0,0 +1,45 @@ +"""工具系统的类型定义""" + +from enum import Enum +from dataclasses import dataclass +from typing import Any, Dict, Optional, Callable, Awaitable +from abc import ABC, abstractmethod + + +class ToolType(Enum): + """工具类型枚举""" + + SERVER_PLUGIN = "server_plugin" # 服务端插件 + SERVER_MCP = "server_mcp" # 服务端MCP + DEVICE_IOT = "device_iot" # 设备端IoT + DEVICE_MCP = "device_mcp" # 设备端MCP + + +class ToolAction(Enum): + """工具执行后的动作类型""" + + ERROR = "error" # 错误 + NOT_FOUND = "not_found" # 工具未找到 + RESPONSE = "response" # 直接回复 + REQUEST_LLM = "request_llm" # 需要LLM处理 + NONE = "none" # 无需特殊处理 + + +@dataclass +class ToolResult: + """工具执行结果""" + + action: ToolAction + content: str # 结果内容 + response: Optional[str] = None # 直接回复内容 + error: Optional[str] = None # 错误信息 + + +@dataclass +class ToolDefinition: + """工具定义""" + + name: str # 工具名称 + description: Dict[str, Any] # 工具描述(OpenAI函数调用格式) + tool_type: ToolType # 工具类型 + parameters: Optional[Dict[str, Any]] = None # 额外参数 diff --git a/main/xiaozhi-server/core/tools/device_iot/__init__.py b/main/xiaozhi-server/core/tools/device_iot/__init__.py new file mode 100644 index 00000000..844c5c28 --- /dev/null +++ b/main/xiaozhi-server/core/tools/device_iot/__init__.py @@ -0,0 +1,12 @@ +"""设备端IoT工具模块""" + +from .iot_descriptor import IotDescriptor +from .iot_handler import handleIotDescriptors, handleIotStatus +from .iot_executor import DeviceIoTExecutor + +__all__ = [ + "IotDescriptor", + "handleIotDescriptors", + "handleIotStatus", + "DeviceIoTExecutor", +] diff --git a/main/xiaozhi-server/core/tools/device_iot/iot_descriptor.py b/main/xiaozhi-server/core/tools/device_iot/iot_descriptor.py new file mode 100644 index 00000000..81df02ad --- /dev/null +++ b/main/xiaozhi-server/core/tools/device_iot/iot_descriptor.py @@ -0,0 +1,46 @@ +"""IoT设备描述符定义""" + +from config.logger import setup_logging + +TAG = __name__ +logger = setup_logging() + + +class IotDescriptor: + """IoT设备描述符""" + + def __init__(self, name, description, properties, methods): + self.name = name + self.description = description + self.properties = [] + self.methods = [] + + # 根据描述创建属性 + if properties is not None: + for key, value in properties.items(): + property_item = {} + property_item["name"] = key + property_item["description"] = value["description"] + if value["type"] == "number": + property_item["value"] = 0 + elif value["type"] == "boolean": + property_item["value"] = False + else: + property_item["value"] = "" + self.properties.append(property_item) + + # 根据描述创建方法 + if methods is not None: + for key, value in methods.items(): + method = {} + method["description"] = value["description"] + method["name"] = key + # 检查方法是否有参数 + if "parameters" in value: + method["parameters"] = {} + for k, v in value["parameters"].items(): + method["parameters"][k] = { + "description": v["description"], + "type": v["type"], + } + self.methods.append(method) diff --git a/main/xiaozhi-server/core/tools/device_iot/iot_executor.py b/main/xiaozhi-server/core/tools/device_iot/iot_executor.py new file mode 100644 index 00000000..e2801127 --- /dev/null +++ b/main/xiaozhi-server/core/tools/device_iot/iot_executor.py @@ -0,0 +1,243 @@ +"""设备端IoT工具执行器""" + +import json +import asyncio +from typing import Dict, Any +from ..base import ToolType, ToolDefinition, ToolResult, ToolExecutor, ToolAction + + +class DeviceIoTExecutor(ToolExecutor): + """设备端IoT工具执行器""" + + def __init__(self, conn): + self.conn = conn + self.iot_tools: Dict[str, ToolDefinition] = {} + + async def execute( + self, conn, tool_name: str, arguments: Dict[str, Any] + ) -> ToolResult: + """执行设备端IoT工具""" + if not self.has_tool(tool_name): + return ToolResult( + action=ToolAction.NOT_FOUND, content=f"IoT工具 {tool_name} 不存在" + ) + + try: + # 解析工具名称,获取设备名和操作类型 + if tool_name.startswith("get_"): + # 查询操作:get_devicename_property + parts = tool_name.split("_", 2) + if len(parts) >= 3: + device_name = parts[1] + property_name = parts[2] + + value = await self._get_iot_status(device_name, property_name) + if value is not None: + # 处理响应模板 + response_success = arguments.get( + "response_success", "查询成功:{value}" + ) + response = response_success.replace("{value}", str(value)) + + return ToolResult( + action=ToolAction.RESPONSE, + content=str(value), + response=response, + ) + else: + response_failure = arguments.get( + "response_failure", f"无法获取{device_name}的状态" + ) + return ToolResult( + action=ToolAction.ERROR, + content=f"属性{property_name}不存在", + error=response_failure, + ) + else: + # 控制操作:devicename_method + parts = tool_name.split("_", 1) + if len(parts) >= 2: + device_name = parts[0] + method_name = parts[1] + + # 提取控制参数(排除响应参数) + control_params = { + k: v + for k, v in arguments.items() + if k not in ["response_success", "response_failure"] + } + + # 发送IoT控制命令 + await self._send_iot_command( + device_name, method_name, control_params + ) + + # 等待状态更新 + await asyncio.sleep(0.1) + + response_success = arguments.get("response_success", "操作成功") + + # 处理响应中的占位符 + for param_name, param_value in control_params.items(): + placeholder = "{" + param_name + "}" + if placeholder in response_success: + response_success = response_success.replace( + placeholder, str(param_value) + ) + if "{value}" in response_success: + response_success = response_success.replace( + "{value}", str(param_value) + ) + break + + return ToolResult( + action=ToolAction.REQUEST_LLM, + content=f"{device_name}操作执行成功,请继续处理剩余指令", + response=response_success, + ) + + return ToolResult(action=ToolAction.ERROR, content="无法解析IoT工具名称") + + except Exception as e: + response_failure = arguments.get("response_failure", "操作失败") + return ToolResult( + action=ToolAction.ERROR, content=str(e), error=response_failure + ) + + async def _get_iot_status(self, device_name: str, property_name: str): + """获取IoT设备状态""" + for key, value in self.conn.iot_descriptors.items(): + if key == device_name: + for property_item in value.properties: + if property_item["name"] == property_name: + return property_item["value"] + return None + + async def _send_iot_command( + self, device_name: str, method_name: str, parameters: Dict[str, Any] + ): + """发送IoT控制命令""" + for key, value in self.conn.iot_descriptors.items(): + if key == device_name: + for method in value.methods: + if method["name"] == method_name: + command = { + "name": device_name, + "method": method_name, + } + + if parameters: + command["parameters"] = parameters + + send_message = json.dumps( + {"type": "iot", "commands": [command]} + ) + await self.conn.websocket.send(send_message) + return + + raise Exception(f"未找到设备{device_name}的方法{method_name}") + + def register_iot_tools(self, descriptors: list): + """注册IoT工具""" + for descriptor in descriptors: + device_name = descriptor["name"] + device_desc = descriptor["description"] + + # 注册查询工具 + if "properties" in descriptor: + for prop_name, prop_info in descriptor["properties"].items(): + tool_name = f"get_{device_name.lower()}_{prop_name.lower()}" + + tool_desc = { + "type": "function", + "function": { + "name": tool_name, + "description": f"查询{device_desc}的{prop_info['description']}", + "parameters": { + "type": "object", + "properties": { + "response_success": { + "type": "string", + "description": f"查询成功时的友好回复,必须使用{{value}}作为占位符表示查询到的值", + }, + "response_failure": { + "type": "string", + "description": f"查询失败时的友好回复", + }, + }, + "required": ["response_success", "response_failure"], + }, + }, + } + + self.iot_tools[tool_name] = ToolDefinition( + name=tool_name, + description=tool_desc, + tool_type=ToolType.DEVICE_IOT, + ) + + # 注册控制工具 + if "methods" in descriptor: + for method_name, method_info in descriptor["methods"].items(): + tool_name = f"{device_name.lower()}_{method_name.lower()}" + + # 构建参数 + parameters = {} + required_params = [] + + # 添加方法的原始参数 + if "parameters" in method_info: + parameters.update( + { + param_name: { + "type": param_info["type"], + "description": param_info["description"], + } + for param_name, param_info in method_info[ + "parameters" + ].items() + } + ) + required_params.extend(method_info["parameters"].keys()) + + # 添加响应参数 + parameters.update( + { + "response_success": { + "type": "string", + "description": "操作成功时的友好回复", + }, + "response_failure": { + "type": "string", + "description": "操作失败时的友好回复", + }, + } + ) + required_params.extend(["response_success", "response_failure"]) + + tool_desc = { + "type": "function", + "function": { + "name": tool_name, + "description": f"{device_desc} - {method_info['description']}", + "parameters": { + "type": "object", + "properties": parameters, + "required": required_params, + }, + }, + } + + self.iot_tools[tool_name] = ToolDefinition( + name=tool_name, + description=tool_desc, + tool_type=ToolType.DEVICE_IOT, + ) + + def get_tools(self) -> Dict[str, ToolDefinition]: + """获取所有设备端IoT工具""" + return self.iot_tools.copy() + + def has_tool(self, tool_name: str) -> bool: + """检查是否有指定的设备端IoT工具""" + return tool_name in self.iot_tools diff --git a/main/xiaozhi-server/core/tools/device_iot/iot_handler.py b/main/xiaozhi-server/core/tools/device_iot/iot_handler.py new file mode 100644 index 00000000..ffea57a4 --- /dev/null +++ b/main/xiaozhi-server/core/tools/device_iot/iot_handler.py @@ -0,0 +1,86 @@ +"""IoT设备支持模块,提供IoT设备描述符和状态处理""" + +import asyncio +from config.logger import setup_logging +from .iot_descriptor import IotDescriptor + +TAG = __name__ +logger = setup_logging() + + +async def handleIotDescriptors(conn, descriptors): + """处理物联网描述""" + wait_max_time = 5 + while ( + not hasattr(conn, "func_handler") + or conn.func_handler is None + or not conn.func_handler.finish_init + ): + await asyncio.sleep(1) + wait_max_time -= 1 + if wait_max_time <= 0: + logger.bind(tag=TAG).debug("连接对象没有func_handler") + return + + functions_changed = False + + for descriptor in descriptors: + # 如果descriptor没有properties和methods,则直接跳过 + if "properties" not in descriptor and "methods" not in descriptor: + continue + + # 处理缺失properties的情况 + if "properties" not in descriptor: + descriptor["properties"] = {} + # 从methods中提取所有参数作为properties + if "methods" in descriptor: + for method_name, method_info in descriptor["methods"].items(): + if "parameters" in method_info: + for param_name, param_info in method_info["parameters"].items(): + # 将参数信息转换为属性信息 + descriptor["properties"][param_name] = { + "description": param_info["description"], + "type": param_info["type"], + } + + # 创建IOT设备描述符 + iot_descriptor = IotDescriptor( + descriptor["name"], + descriptor["description"], + descriptor["properties"], + descriptor["methods"], + ) + conn.iot_descriptors[descriptor["name"]] = iot_descriptor + functions_changed = True + + # 如果注册了新函数,更新function描述列表 + if functions_changed and hasattr(conn, "func_handler"): + # 注册IoT工具到统一工具处理器 + await conn.func_handler.register_iot_tools(descriptors) + + func_names = conn.func_handler.current_support_functions() + logger.bind(tag=TAG).info( + f"更新function描述列表完成,当前支持的函数: {func_names}" + ) + + +async def handleIotStatus(conn, states): + """处理物联网状态""" + for state in states: + for key, value in conn.iot_descriptors.items(): + if key == state["name"]: + for property_item in value.properties: + for k, v in state["state"].items(): + if property_item["name"] == k: + if type(v) != type(property_item["value"]): + logger.bind(tag=TAG).error( + f"属性{property_item['name']}的值类型不匹配" + ) + break + else: + property_item["value"] = v + logger.bind(tag=TAG).info( + f"物联网状态更新: {key} , {property_item['name']} = {v}" + ) + break + break diff --git a/main/xiaozhi-server/core/tools/device_mcp/__init__.py b/main/xiaozhi-server/core/tools/device_mcp/__init__.py new file mode 100644 index 00000000..266d60c8 --- /dev/null +++ b/main/xiaozhi-server/core/tools/device_mcp/__init__.py @@ -0,0 +1,21 @@ +"""设备端MCP工具模块""" + +from .mcp_client import MCPClient +from .mcp_handler import ( + send_mcp_message, + handle_mcp_message, + send_mcp_initialize_message, + send_mcp_tools_list_request, + call_mcp_tool, +) +from .mcp_executor import DeviceMCPExecutor + +__all__ = [ + "MCPClient", + "send_mcp_message", + "handle_mcp_message", + "send_mcp_initialize_message", + "send_mcp_tools_list_request", + "call_mcp_tool", + "DeviceMCPExecutor", +] diff --git a/main/xiaozhi-server/core/tools/device_mcp/mcp_client.py b/main/xiaozhi-server/core/tools/device_mcp/mcp_client.py new file mode 100644 index 00000000..75aa22b5 --- /dev/null +++ b/main/xiaozhi-server/core/tools/device_mcp/mcp_client.py @@ -0,0 +1,93 @@ +"""设备端MCP客户端定义""" + +import asyncio +from concurrent.futures import Future +from core.utils.util import sanitize_tool_name +from config.logger import setup_logging + +TAG = __name__ +logger = setup_logging() + + +class MCPClient: + """设备端MCP客户端,用于管理MCP状态和工具""" + + def __init__(self): + self.tools = {} # sanitized_name -> tool_data + self.name_mapping = {} + self.ready = False + self.call_results = {} # To store Futures for tool call responses + self.next_id = 1 + self.lock = asyncio.Lock() + self._cached_available_tools = None # Cache for get_available_tools + + def has_tool(self, name: str) -> bool: + return name in self.tools + + def get_available_tools(self) -> list: + # Check if the cache is valid + if self._cached_available_tools is not None: + return self._cached_available_tools + + # If cache is not valid, regenerate the list + result = [] + for tool_name, tool_data in self.tools.items(): + function_def = { + "name": tool_name, + "description": tool_data["description"], + "parameters": { + "type": tool_data["inputSchema"].get("type", "object"), + "properties": tool_data["inputSchema"].get("properties", {}), + "required": tool_data["inputSchema"].get("required", []), + }, + } + result.append({"type": "function", "function": function_def}) + + self._cached_available_tools = result # Store the generated list in cache + return result + + async def is_ready(self) -> bool: + async with self.lock: + return self.ready + + async def set_ready(self, status: bool): + async with self.lock: + self.ready = status + + async def add_tool(self, tool_data: dict): + async with self.lock: + sanitized_name = sanitize_tool_name(tool_data["name"]) + self.tools[sanitized_name] = tool_data + self.name_mapping[sanitized_name] = tool_data["name"] + self._cached_available_tools = ( + None # Invalidate the cache when a tool is added + ) + + async def get_next_id(self) -> int: + async with self.lock: + current_id = self.next_id + self.next_id += 1 + return current_id + + async def register_call_result_future(self, id: int, future: Future): + async with self.lock: + self.call_results[id] = future + + async def resolve_call_result(self, id: int, result: any): + async with self.lock: + if id in self.call_results: + future = self.call_results.pop(id) + if not future.done(): + future.set_result(result) + + async def reject_call_result(self, id: int, exception: Exception): + async with self.lock: + if id in self.call_results: + future = self.call_results.pop(id) + if not future.done(): + future.set_exception(exception) + + async def cleanup_call_result(self, id: int): + async with self.lock: + if id in self.call_results: + self.call_results.pop(id) diff --git a/main/xiaozhi-server/core/tools/device_mcp/mcp_executor.py b/main/xiaozhi-server/core/tools/device_mcp/mcp_executor.py new file mode 100644 index 00000000..97d37f9b --- /dev/null +++ b/main/xiaozhi-server/core/tools/device_mcp/mcp_executor.py @@ -0,0 +1,72 @@ +"""设备端MCP工具执行器""" + +from typing import Dict, Any +from ..base import ToolType, ToolDefinition, ToolResult, ToolExecutor, ToolAction +from .mcp_handler import call_mcp_tool + + +class DeviceMCPExecutor(ToolExecutor): + """设备端MCP工具执行器""" + + def __init__(self, conn): + self.conn = conn + + async def execute( + self, conn, tool_name: str, arguments: Dict[str, Any] + ) -> ToolResult: + """执行设备端MCP工具""" + if not hasattr(conn, "mcp_client") or not conn.mcp_client: + return ToolResult( + action=ToolAction.ERROR, + content="设备端MCP客户端未初始化", + error="设备端MCP客户端未初始化", + ) + + if not await conn.mcp_client.is_ready(): + return ToolResult( + action=ToolAction.ERROR, + content="设备端MCP客户端未准备就绪", + error="设备端MCP客户端未准备就绪", + ) + + try: + # 转换参数为JSON字符串 + import json + + args_str = json.dumps(arguments) if arguments else "{}" + + # 调用设备端MCP工具 + result = await call_mcp_tool(conn, conn.mcp_client, tool_name, args_str) + + return ToolResult(action=ToolAction.RESPONSE, content=str(result)) + + except ValueError as e: + return ToolResult(action=ToolAction.NOT_FOUND, content=str(e), error=str(e)) + except Exception as e: + return ToolResult(action=ToolAction.ERROR, content=str(e), error=str(e)) + + def get_tools(self) -> Dict[str, ToolDefinition]: + """获取所有设备端MCP工具""" + if not hasattr(self.conn, "mcp_client") or not self.conn.mcp_client: + return {} + + tools = {} + mcp_tools = self.conn.mcp_client.get_available_tools() + + for tool in mcp_tools: + func_def = tool.get("function", {}) + tool_name = func_def.get("name", "") + + if tool_name: + tools[tool_name] = ToolDefinition( + name=tool_name, description=tool, tool_type=ToolType.DEVICE_MCP + ) + + return tools + + def has_tool(self, tool_name: str) -> bool: + """检查是否有指定的设备端MCP工具""" + if not hasattr(self.conn, "mcp_client") or not self.conn.mcp_client: + return False + + return self.conn.mcp_client.has_tool(tool_name) diff --git a/main/xiaozhi-server/core/handle/mcpHandle.py b/main/xiaozhi-server/core/tools/device_mcp/mcp_handler.py similarity index 87% rename from main/xiaozhi-server/core/handle/mcpHandle.py rename to main/xiaozhi-server/core/tools/device_mcp/mcp_handler.py index 3f3216ac..42a69c0f 100644 --- a/main/xiaozhi-server/core/handle/mcpHandle.py +++ b/main/xiaozhi-server/core/tools/device_mcp/mcp_handler.py @@ -1,14 +1,19 @@ +"""设备端MCP客户端支持模块""" + import json import asyncio +import re from concurrent.futures import Future from core.utils.util import get_vision_url, sanitize_tool_name from core.utils.auth import AuthToken +from config.logger import setup_logging TAG = __name__ +logger = setup_logging() class MCPClient: - """MCPClient,用于管理MCP状态和工具""" + """设备端MCP客户端,用于管理MCP状态和工具""" def __init__(self): self.tools = {} # sanitized_name -> tool_data @@ -94,24 +99,24 @@ class MCPClient: async def send_mcp_message(conn, payload: dict): """Helper to send MCP messages, encapsulating common logic.""" if not conn.features.get("mcp"): - conn.logger.bind(tag=TAG).warning("客户端不支持MCP,无法发送MCP消息") + logger.bind(tag=TAG).warning("客户端不支持MCP,无法发送MCP消息") return message = json.dumps({"type": "mcp", "payload": payload}) try: await conn.websocket.send(message) - conn.logger.bind(tag=TAG).info(f"成功发送MCP消息: {message}") + logger.bind(tag=TAG).info(f"成功发送MCP消息: {message}") except Exception as e: - conn.logger.bind(tag=TAG).error(f"发送MCP消息失败: {e}") + logger.bind(tag=TAG).error(f"发送MCP消息失败: {e}") async def handle_mcp_message(conn, mcp_client: MCPClient, payload: dict): """处理MCP消息,包括初始化、工具列表和工具调用响应等""" - conn.logger.bind(tag=TAG).info(f"处理MCP消息: {payload}") + logger.bind(tag=TAG).info(f"处理MCP消息: {payload}") if not isinstance(payload, dict): - conn.logger.bind(tag=TAG).error("MCP消息缺少payload字段或格式错误") + logger.bind(tag=TAG).error("MCP消息缺少payload字段或格式错误") return # Handle result @@ -121,32 +126,32 @@ async def handle_mcp_message(conn, mcp_client: MCPClient, payload: dict): # Check for tool call response first if msg_id in mcp_client.call_results: - conn.logger.bind(tag=TAG).debug( + logger.bind(tag=TAG).debug( f"收到工具调用响应,ID: {msg_id}, 结果: {result}" ) await mcp_client.resolve_call_result(msg_id, result) return if msg_id == 1: # mcpInitializeID - conn.logger.bind(tag=TAG).debug("收到MCP初始化响应") + logger.bind(tag=TAG).debug("收到MCP初始化响应") server_info = result.get("serverInfo") if isinstance(server_info, dict): name = server_info.get("name") version = server_info.get("version") - conn.logger.bind(tag=TAG).info( + logger.bind(tag=TAG).info( f"客户端MCP服务器信息: name={name}, version={version}" ) return elif msg_id == 2: # mcpToolsListID - conn.logger.bind(tag=TAG).debug("收到MCP工具列表响应") + logger.bind(tag=TAG).debug("收到MCP工具列表响应") if isinstance(result, dict) and "tools" in result: tools_data = result["tools"] if not isinstance(tools_data, list): - conn.logger.bind(tag=TAG).error("工具列表格式错误") + logger.bind(tag=TAG).error("工具列表格式错误") return - conn.logger.bind(tag=TAG).info( + logger.bind(tag=TAG).info( f"客户端设备支持的工具数量: {len(tools_data)}" ) @@ -172,7 +177,7 @@ async def handle_mcp_message(conn, mcp_client: MCPClient, payload: dict): "inputSchema": input_schema, } await mcp_client.add_tool(new_tool) - conn.logger.bind(tag=TAG).debug(f"客户端工具 #{i+1}: {name}") + logger.bind(tag=TAG).debug(f"客户端工具 #{i+1}: {name}") # 替换所有工具描述中的工具名称 for tool_data in mcp_client.tools.values(): @@ -190,24 +195,22 @@ async def handle_mcp_message(conn, mcp_client: MCPClient, payload: dict): next_cursor = result.get("nextCursor", "") if next_cursor: - conn.logger.bind(tag=TAG).info( - f"有更多工具,nextCursor: {next_cursor}" - ) + logger.bind(tag=TAG).info(f"有更多工具,nextCursor: {next_cursor}") await send_mcp_tools_list_continue_request(conn, next_cursor) else: await mcp_client.set_ready(True) - conn.logger.bind(tag=TAG).info("所有工具已获取,MCP客户端准备就绪") + logger.bind(tag=TAG).info("所有工具已获取,MCP客户端准备就绪") return # Handle method calls (requests from the client) elif "method" in payload: method = payload["method"] - conn.logger.bind(tag=TAG).info(f"收到MCP客户端请求: {method}") + logger.bind(tag=TAG).info(f"收到MCP客户端请求: {method}") elif "error" in payload: error_data = payload["error"] error_msg = error_data.get("message", "未知错误") - conn.logger.bind(tag=TAG).error(f"收到MCP错误响应: {error_msg}") + logger.bind(tag=TAG).error(f"收到MCP错误响应: {error_msg}") msg_id = int(payload.get("id", 0)) if msg_id in mcp_client.call_results: @@ -216,9 +219,6 @@ async def handle_mcp_message(conn, mcp_client: MCPClient, payload: dict): ) -# --- Outgoing MCP Messages --- - - async def send_mcp_initialize_message(conn): """发送MCP初始化消息""" @@ -250,7 +250,7 @@ async def send_mcp_initialize_message(conn): }, }, } - conn.logger.bind(tag=TAG).info("发送MCP初始化消息") + logger.bind(tag=TAG).info("发送MCP初始化消息") await send_mcp_message(conn, payload) @@ -261,7 +261,7 @@ async def send_mcp_tools_list_request(conn): "id": 2, # mcpToolsListID "method": "tools/list", } - conn.logger.bind(tag=TAG).debug("发送MCP工具列表请求") + logger.bind(tag=TAG).debug("发送MCP工具列表请求") await send_mcp_message(conn, payload) @@ -273,7 +273,7 @@ async def send_mcp_tools_list_continue_request(conn, cursor: str): "method": "tools/list", "params": {"cursor": cursor}, } - conn.logger.bind(tag=TAG).info(f"发送带cursor的MCP工具列表请求: {cursor}") + logger.bind(tag=TAG).info(f"发送带cursor的MCP工具列表请求: {cursor}") await send_mcp_message(conn, payload) @@ -307,8 +307,6 @@ async def call_mcp_tool( # 如果解析失败,尝试合并多个JSON对象 try: # 使用正则表达式匹配所有JSON对象 - import re - json_objects = re.findall(r"\{[^{}]*\}", args) if len(json_objects) > 1: # 合并所有JSON对象 @@ -327,7 +325,7 @@ async def call_mcp_tool( else: raise ValueError(f"参数JSON解析失败: {args}") except Exception as e: - conn.logger.bind(tag=TAG).error( + logger.bind(tag=TAG).error( f"参数JSON解析失败: {str(e)}, 原始参数: {args}" ) raise ValueError(f"参数JSON解析失败: {str(e)}") @@ -353,15 +351,13 @@ async def call_mcp_tool( "params": {"name": actual_name, "arguments": arguments}, } - conn.logger.bind(tag=TAG).info( - f"发送客户端mcp工具调用请求: {actual_name},参数: {args}" - ) + logger.bind(tag=TAG).info(f"发送客户端mcp工具调用请求: {actual_name},参数: {args}") await send_mcp_message(conn, payload) try: # Wait for response or timeout raw_result = await asyncio.wait_for(result_future, timeout=timeout) - conn.logger.bind(tag=TAG).info( + logger.bind(tag=TAG).info( f"客户端mcp工具调用 {actual_name} 成功,原始结果: {raw_result}" ) diff --git a/main/xiaozhi-server/core/tools/server_mcp/__init__.py b/main/xiaozhi-server/core/tools/server_mcp/__init__.py new file mode 100644 index 00000000..ba73b522 --- /dev/null +++ b/main/xiaozhi-server/core/tools/server_mcp/__init__.py @@ -0,0 +1,6 @@ +"""服务端MCP工具模块""" + +from .mcp_manager import ServerMCPManager +from .mcp_executor import ServerMCPExecutor + +__all__ = ["ServerMCPManager", "ServerMCPExecutor"] diff --git a/main/xiaozhi-server/core/tools/server_mcp/mcp_executor.py b/main/xiaozhi-server/core/tools/server_mcp/mcp_executor.py new file mode 100644 index 00000000..3902b6b4 --- /dev/null +++ b/main/xiaozhi-server/core/tools/server_mcp/mcp_executor.py @@ -0,0 +1,82 @@ +"""服务端MCP工具执行器""" + +from typing import Dict, Any, Optional +from ..base import ToolType, ToolDefinition, ToolResult, ToolExecutor, ToolAction +from .mcp_manager import ServerMCPManager + + +class ServerMCPExecutor(ToolExecutor): + """服务端MCP工具执行器""" + + def __init__(self, conn): + self.conn = conn + self.mcp_manager: Optional[ServerMCPManager] = None + self._initialized = False + + async def initialize(self): + """初始化MCP管理器""" + if not self._initialized: + self.mcp_manager = ServerMCPManager(self.conn) + await self.mcp_manager.initialize_servers() + self._initialized = True + + async def execute( + self, conn, tool_name: str, arguments: Dict[str, Any] + ) -> ToolResult: + """执行服务端MCP工具""" + if not self._initialized or not self.mcp_manager: + return ToolResult( + action=ToolAction.ERROR, + content="MCP管理器未初始化", + error="MCP管理器未初始化", + ) + + try: + # 移除mcp_前缀(如果有) + actual_tool_name = tool_name + if tool_name.startswith("mcp_"): + actual_tool_name = tool_name[4:] + + result = await self.mcp_manager.execute_tool(actual_tool_name, arguments) + + return ToolResult(action=ToolAction.RESPONSE, content=str(result)) + + except ValueError as e: + return ToolResult(action=ToolAction.NOT_FOUND, content=str(e), error=str(e)) + except Exception as e: + return ToolResult(action=ToolAction.ERROR, content=str(e), error=str(e)) + + def get_tools(self) -> Dict[str, ToolDefinition]: + """获取所有服务端MCP工具""" + if not self._initialized or not self.mcp_manager: + return {} + + tools = {} + mcp_tools = self.mcp_manager.get_all_tools() + + for tool in mcp_tools: + func_def = tool.get("function", {}) + tool_name = f"mcp_{func_def.get('name', '')}" + + tools[tool_name] = ToolDefinition( + name=tool_name, description=tool, tool_type=ToolType.SERVER_MCP + ) + + return tools + + def has_tool(self, tool_name: str) -> bool: + """检查是否有指定的服务端MCP工具""" + if not self._initialized or not self.mcp_manager: + return False + + # 移除mcp_前缀(如果有) + actual_tool_name = tool_name + if tool_name.startswith("mcp_"): + actual_tool_name = tool_name[4:] + + return self.mcp_manager.is_mcp_tool(actual_tool_name) + + async def cleanup(self): + """清理MCP连接""" + if self.mcp_manager: + await self.mcp_manager.cleanup_all() diff --git a/main/xiaozhi-server/core/tools/server_mcp/mcp_manager.py b/main/xiaozhi-server/core/tools/server_mcp/mcp_manager.py new file mode 100644 index 00000000..92e9d8cd --- /dev/null +++ b/main/xiaozhi-server/core/tools/server_mcp/mcp_manager.py @@ -0,0 +1,100 @@ +"""服务端MCP管理器""" + +import asyncio +import os +import json +from typing import Dict, Any, List +from config.config_loader import get_project_dir +from config.logger import setup_logging + +TAG = __name__ +logger = setup_logging() + + +class ServerMCPManager: + """管理多个服务端MCP服务的集中管理器""" + + def __init__(self, conn) -> None: + """初始化MCP管理器""" + self.conn = conn + self.config_path = get_project_dir() + "data/.mcp_server_settings.json" + if not os.path.exists(self.config_path): + self.config_path = "" + logger.bind(tag=TAG).warning( + f"请检查mcp服务配置文件:data/.mcp_server_settings.json" + ) + self.clients: Dict[str, Any] = {} + self.tools = [] + + def load_config(self) -> Dict[str, Any]: + """加载MCP服务配置""" + if len(self.config_path) == 0: + return {} + + try: + with open(self.config_path, "r", encoding="utf-8") as f: + config = json.load(f) + return config.get("mcpServers", {}) + except Exception as e: + logger.bind(tag=TAG).error( + f"Error loading MCP config from {self.config_path}: {e}" + ) + return {} + + async def initialize_servers(self) -> None: + """初始化所有MCP服务""" + config = self.load_config() + for name, srv_config in config.items(): + if not srv_config.get("command") and not srv_config.get("url"): + logger.bind(tag=TAG).warning( + f"Skipping server {name}: neither command nor url specified" + ) + continue + + try: + # 这里可以添加真正的MCP客户端初始化逻辑 + # 暂时使用简化版本 + logger.bind(tag=TAG).info(f"初始化服务端MCP客户端: {name}") + # client = MCPClient(srv_config) + # await client.initialize() + # self.clients[name] = client + # client_tools = client.get_available_tools() + # self.tools.extend(client_tools) + + except Exception as e: + logger.bind(tag=TAG).error( + f"Failed to initialize MCP server {name}: {e}" + ) + + def get_all_tools(self) -> List[Dict[str, Any]]: + """获取所有服务的工具function定义""" + return self.tools + + def is_mcp_tool(self, tool_name: str) -> bool: + """检查是否是MCP工具""" + for tool in self.tools: + if ( + tool.get("function") is not None + and tool["function"].get("name") == tool_name + ): + return True + return False + + async def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any: + """执行工具调用""" + logger.bind(tag=TAG).info(f"执行服务端MCP工具 {tool_name},参数: {arguments}") + + # 这里可以添加真正的工具执行逻辑 + # 暂时返回模拟结果 + return f"服务端MCP工具 {tool_name} 执行结果" + + async def cleanup_all(self) -> None: + """关闭所有 MCP客户端""" + for name, client in list(self.clients.items()): + try: + if hasattr(client, "cleanup"): + await asyncio.wait_for(client.cleanup(), timeout=20) + logger.bind(tag=TAG).info(f"服务端MCP客户端已关闭: {name}") + except (asyncio.TimeoutError, Exception) as e: + logger.bind(tag=TAG).error(f"关闭服务端MCP客户端 {name} 时出错: {e}") + self.clients.clear() diff --git a/main/xiaozhi-server/core/tools/server_plugins/__init__.py b/main/xiaozhi-server/core/tools/server_plugins/__init__.py new file mode 100644 index 00000000..232b50d2 --- /dev/null +++ b/main/xiaozhi-server/core/tools/server_plugins/__init__.py @@ -0,0 +1,5 @@ +"""服务端插件工具模块""" + +from .plugin_executor import ServerPluginExecutor + +__all__ = ["ServerPluginExecutor"] diff --git a/main/xiaozhi-server/core/tools/server_plugins/plugin_executor.py b/main/xiaozhi-server/core/tools/server_plugins/plugin_executor.py new file mode 100644 index 00000000..0d74aa8e --- /dev/null +++ b/main/xiaozhi-server/core/tools/server_plugins/plugin_executor.py @@ -0,0 +1,102 @@ +"""服务端插件工具执行器""" + +from typing import Dict, Any +from ..base import ToolType, ToolDefinition, ToolResult, ToolExecutor, ToolAction +from plugins_func.register import all_function_registry, Action, ActionResponse + + +class ServerPluginExecutor(ToolExecutor): + """服务端插件工具执行器""" + + def __init__(self, conn): + self.conn = conn + self.config = conn.config + + async def execute( + self, conn, tool_name: str, arguments: Dict[str, Any] + ) -> ToolResult: + """执行服务端插件工具""" + func_item = all_function_registry.get(tool_name) + if not func_item: + return ToolResult( + action=ToolAction.NOT_FOUND, content=f"插件函数 {tool_name} 不存在" + ) + + try: + # 根据工具类型决定如何调用 + if hasattr(func_item, "type"): + func_type = func_item.type + if func_type.code in [4, 5]: # SYSTEM_CTL, IOT_CTL (需要conn参数) + result = func_item.func(conn, **arguments) + elif func_type.code == 2: # WAIT + result = func_item.func(**arguments) + elif func_type.code == 3: # CHANGE_SYS_PROMPT + result = func_item.func(conn, **arguments) + else: + result = func_item.func(**arguments) + else: + # 默认不传conn参数 + result = func_item.func(**arguments) + + # 转换ActionResponse到ToolResult + if isinstance(result, ActionResponse): + if result.action == Action.ERROR: + return ToolResult( + action=ToolAction.ERROR, + content=result.result, + error=result.response, + ) + elif result.action == Action.RESPONSE: + return ToolResult( + action=ToolAction.RESPONSE, + content=result.result, + response=result.response, + ) + elif result.action == Action.REQLLM: + return ToolResult( + action=ToolAction.REQUEST_LLM, + content=result.result, + response=result.response, + ) + else: + return ToolResult( + action=ToolAction.NONE, + content=result.result, + response=result.response, + ) + else: + # 直接返回结果 + return ToolResult(action=ToolAction.RESPONSE, content=str(result)) + + except Exception as e: + return ToolResult(action=ToolAction.ERROR, content=str(e), error=str(e)) + + def get_tools(self) -> Dict[str, ToolDefinition]: + """获取所有注册的服务端插件工具""" + tools = {} + + # 获取必要的函数 + necessary_functions = ["handle_exit_intent", "get_time", "get_lunar"] + + # 获取配置中的函数 + config_functions = self.config["Intent"][ + self.config["selected_module"]["Intent"] + ].get("functions", []) + + # 合并所有需要的函数 + all_required_functions = list(set(necessary_functions + config_functions)) + + for func_name in all_required_functions: + func_item = all_function_registry.get(func_name) + if func_item: + tools[func_name] = ToolDefinition( + name=func_name, + description=func_item.description, + tool_type=ToolType.SERVER_PLUGIN, + ) + + return tools + + def has_tool(self, tool_name: str) -> bool: + """检查是否有指定的服务端插件工具""" + return tool_name in all_function_registry diff --git a/main/xiaozhi-server/core/tools/unified_tool_handler.py b/main/xiaozhi-server/core/tools/unified_tool_handler.py new file mode 100644 index 00000000..91608b48 --- /dev/null +++ b/main/xiaozhi-server/core/tools/unified_tool_handler.py @@ -0,0 +1,188 @@ +"""统一工具处理器,替换原有的FunctionHandler""" + +import json +from typing import Dict, List, Any, Optional +from config.logger import setup_logging +from plugins_func.loadplugins import auto_import_modules + +from .base import ToolType, ToolResult, ToolAction +from .unified_tool_manager import ToolManager +from .server_plugins import ServerPluginExecutor +from .server_mcp import ServerMCPExecutor +from .device_iot import DeviceIoTExecutor +from .device_mcp import DeviceMCPExecutor + + +class UnifiedToolHandler: + """统一工具处理器""" + + def __init__(self, conn): + self.conn = conn + self.config = conn.config + self.logger = setup_logging() + + # 创建工具管理器 + self.tool_manager = ToolManager(conn) + + # 创建各类执行器 + self.server_plugin_executor = ServerPluginExecutor(conn) + self.server_mcp_executor = ServerMCPExecutor(conn) + self.device_iot_executor = DeviceIoTExecutor(conn) + self.device_mcp_executor = DeviceMCPExecutor(conn) + + # 注册执行器 + self.tool_manager.register_executor( + ToolType.SERVER_PLUGIN, self.server_plugin_executor + ) + self.tool_manager.register_executor( + ToolType.SERVER_MCP, self.server_mcp_executor + ) + self.tool_manager.register_executor( + ToolType.DEVICE_IOT, self.device_iot_executor + ) + self.tool_manager.register_executor( + ToolType.DEVICE_MCP, self.device_mcp_executor + ) + + # 初始化标志 + self.finish_init = False + + async def _initialize(self): + """异步初始化""" + try: + # 自动导入插件模块 + auto_import_modules("plugins_func.functions") + + # 初始化服务端MCP + await self.server_mcp_executor.initialize() + + # 初始化Home Assistant(如果需要) + self._initialize_home_assistant() + + self.finish_init = True + self.logger.info("统一工具处理器初始化完成") + + except Exception as e: + self.logger.error(f"统一工具处理器初始化失败: {e}") + + def _initialize_home_assistant(self): + """初始化Home Assistant提示词""" + try: + from plugins_func.functions.hass_init import append_devices_to_prompt + + append_devices_to_prompt(self.conn) + except ImportError: + pass # 忽略导入错误 + except Exception as e: + self.logger.error(f"初始化Home Assistant失败: {e}") + + def get_functions(self) -> List[Dict[str, Any]]: + """获取所有工具的函数描述""" + return self.tool_manager.get_function_descriptions() + + def current_support_functions(self) -> List[str]: + """获取当前支持的函数名称列表""" + func_names = self.tool_manager.get_supported_tool_names() + self.logger.info(f"当前支持的函数列表: {func_names}") + return func_names + + def upload_functions_desc(self): + """刷新函数描述列表""" + self.tool_manager.refresh_tools() + self.logger.info("函数描述列表已刷新") + + def has_tool(self, tool_name: str) -> bool: + """检查是否有指定工具""" + return self.tool_manager.has_tool(tool_name) + + async def handle_llm_function_call( + self, conn, function_call_data: Dict[str, Any] + ) -> Optional[ToolResult]: + """处理LLM函数调用""" + try: + # 处理多函数调用 + if "function_calls" in function_call_data: + responses = [] + for call in function_call_data["function_calls"]: + result = await self.tool_manager.execute_tool( + call["name"], call.get("arguments", {}) + ) + responses.append(result) + return self._combine_responses(responses) + + # 处理单函数调用 + function_name = function_call_data["name"] + arguments = function_call_data.get("arguments", {}) + + # 如果arguments是字符串,尝试解析为JSON + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) if arguments else {} + except json.JSONDecodeError: + self.logger.error(f"无法解析函数参数: {arguments}") + return ToolResult( + action=ToolAction.ERROR, + content="无法解析函数参数", + error="无法解析函数参数", + ) + + self.logger.debug(f"调用函数: {function_name}, 参数: {arguments}") + + # 执行工具调用 + result = await self.tool_manager.execute_tool(function_name, arguments) + return result + + except Exception as e: + self.logger.error(f"处理function call错误: {e}") + return ToolResult(action=ToolAction.ERROR, content=str(e), error=str(e)) + + def _combine_responses(self, responses: List[ToolResult]) -> ToolResult: + """合并多个函数调用的响应""" + if not responses: + return ToolResult(action=ToolAction.NONE, content="无响应") + + # 如果有任何错误,返回第一个错误 + for response in responses: + if response.action == ToolAction.ERROR: + return response + + # 合并所有成功的响应 + contents = [] + responses_text = [] + + for response in responses: + if response.content: + contents.append(response.content) + if response.response: + responses_text.append(response.response) + + # 确定最终的动作类型 + final_action = ToolAction.RESPONSE + for response in responses: + if response.action == ToolAction.REQUEST_LLM: + final_action = ToolAction.REQUEST_LLM + break + + return ToolResult( + action=final_action, + content="; ".join(contents), + response="; ".join(responses_text) if responses_text else None, + ) + + async def register_iot_tools(self, descriptors: List[Dict[str, Any]]): + """注册IoT设备工具""" + self.device_iot_executor.register_iot_tools(descriptors) + self.tool_manager.refresh_tools() + self.logger.info(f"注册了{len(descriptors)}个IoT设备的工具") + + def get_tool_statistics(self) -> Dict[str, int]: + """获取工具统计信息""" + return self.tool_manager.get_tool_statistics() + + async def cleanup(self): + """清理资源""" + try: + await self.server_mcp_executor.cleanup() + self.logger.info("工具处理器清理完成") + except Exception as e: + self.logger.error(f"工具处理器清理失败: {e}") diff --git a/main/xiaozhi-server/core/tools/unified_tool_manager.py b/main/xiaozhi-server/core/tools/unified_tool_manager.py new file mode 100644 index 00000000..c4187b7c --- /dev/null +++ b/main/xiaozhi-server/core/tools/unified_tool_manager.py @@ -0,0 +1,128 @@ +"""统一工具管理器""" + +import asyncio +from typing import Dict, List, Optional, Any +from config.logger import setup_logging +from .base import ToolType, ToolDefinition, ToolResult, ToolExecutor, ToolAction + + +class ToolManager: + """统一工具管理器,管理所有类型的工具""" + + def __init__(self, conn): + self.conn = conn + self.logger = setup_logging() + self.executors: Dict[ToolType, ToolExecutor] = {} + self._cached_tools: Optional[Dict[str, ToolDefinition]] = None + self._cached_function_descriptions: Optional[List[Dict[str, Any]]] = None + + def register_executor(self, tool_type: ToolType, executor: ToolExecutor): + """注册工具执行器""" + self.executors[tool_type] = executor + self._invalidate_cache() + self.logger.info(f"注册工具执行器: {tool_type.value}") + + def _invalidate_cache(self): + """使缓存失效""" + self._cached_tools = None + self._cached_function_descriptions = None + + def get_all_tools(self) -> Dict[str, ToolDefinition]: + """获取所有工具定义""" + if self._cached_tools is not None: + return self._cached_tools + + all_tools = {} + for tool_type, executor in self.executors.items(): + try: + tools = executor.get_tools() + for name, definition in tools.items(): + if name in all_tools: + self.logger.warning(f"工具名称冲突: {name}") + all_tools[name] = definition + except Exception as e: + self.logger.error(f"获取{tool_type.value}工具时出错: {e}") + + self._cached_tools = all_tools + return all_tools + + def get_function_descriptions(self) -> List[Dict[str, Any]]: + """获取所有工具的函数描述(OpenAI格式)""" + if self._cached_function_descriptions is not None: + return self._cached_function_descriptions + + descriptions = [] + tools = self.get_all_tools() + for tool_definition in tools.values(): + descriptions.append(tool_definition.description) + + self._cached_function_descriptions = descriptions + return descriptions + + def has_tool(self, tool_name: str) -> bool: + """检查是否存在指定工具""" + tools = self.get_all_tools() + return tool_name in tools + + def get_tool_type(self, tool_name: str) -> Optional[ToolType]: + """获取工具类型""" + tools = self.get_all_tools() + tool_def = tools.get(tool_name) + return tool_def.tool_type if tool_def else None + + async def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> ToolResult: + """执行工具调用""" + try: + # 查找工具类型 + tool_type = self.get_tool_type(tool_name) + if not tool_type: + return ToolResult( + action=ToolAction.NOT_FOUND, + content=f"工具 {tool_name} 不存在", + error=f"工具 {tool_name} 不存在" + ) + + # 获取对应的执行器 + executor = self.executors.get(tool_type) + if not executor: + return ToolResult( + action=ToolAction.ERROR, + content=f"工具类型 {tool_type.value} 的执行器未注册", + error=f"工具类型 {tool_type.value} 的执行器未注册" + ) + + # 执行工具 + self.logger.info(f"执行工具: {tool_name},参数: {arguments}") + result = await executor.execute(self.conn, tool_name, arguments) + self.logger.debug(f"工具执行结果: {result}") + return result + + except Exception as e: + self.logger.error(f"执行工具 {tool_name} 时出错: {e}") + return ToolResult( + action=ToolAction.ERROR, + content=str(e), + error=str(e) + ) + + def get_supported_tool_names(self) -> List[str]: + """获取所有支持的工具名称""" + tools = self.get_all_tools() + return list(tools.keys()) + + def refresh_tools(self): + """刷新工具缓存""" + self._invalidate_cache() + self.logger.info("工具缓存已刷新") + + def get_tool_statistics(self) -> Dict[str, int]: + """获取工具统计信息""" + stats = {} + for tool_type, executor in self.executors.items(): + try: + tools = executor.get_tools() + stats[tool_type.value] = len(tools) + except Exception as e: + self.logger.error(f"获取{tool_type.value}工具统计时出错: {e}") + stats[tool_type.value] = 0 + return stats \ No newline at end of file From 9412a26bfcc6278e3af7a8b97fdcd411d1334f09 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Thu, 26 Jun 2025 09:28:29 +0800 Subject: [PATCH 62/91] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=9B=AE=E5=BD=95=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 4 ++-- main/xiaozhi-server/core/handle/helloHandle.py | 2 +- main/xiaozhi-server/core/handle/intentHandler.py | 8 +++++--- main/xiaozhi-server/core/handle/textHandle.py | 4 ++-- .../xiaozhi-server/core/{ => providers}/tools/__init__.py | 0 .../core/{ => providers}/tools/base/__init__.py | 0 .../core/{ => providers}/tools/base/tool_executor.py | 0 .../core/{ => providers}/tools/base/tool_types.py | 0 .../core/{ => providers}/tools/device_iot/__init__.py | 0 .../{ => providers}/tools/device_iot/iot_descriptor.py | 0 .../core/{ => providers}/tools/device_iot/iot_executor.py | 8 ++++---- .../core/{ => providers}/tools/device_iot/iot_handler.py | 0 .../core/{ => providers}/tools/device_mcp/__init__.py | 0 .../core/{ => providers}/tools/device_mcp/mcp_client.py | 0 .../core/{ => providers}/tools/device_mcp/mcp_executor.py | 0 .../core/{ => providers}/tools/device_mcp/mcp_handler.py | 0 .../core/{ => providers}/tools/server_mcp/__init__.py | 0 .../core/{ => providers}/tools/server_mcp/mcp_executor.py | 0 .../core/{ => providers}/tools/server_mcp/mcp_manager.py | 0 .../core/{ => providers}/tools/server_plugins/__init__.py | 0 .../tools/server_plugins/plugin_executor.py | 0 .../core/{ => providers}/tools/unified_tool_handler.py | 0 .../core/{ => providers}/tools/unified_tool_manager.py | 0 23 files changed, 14 insertions(+), 12 deletions(-) rename main/xiaozhi-server/core/{ => providers}/tools/__init__.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/base/__init__.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/base/tool_executor.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/base/tool_types.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/device_iot/__init__.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/device_iot/iot_descriptor.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/device_iot/iot_executor.py (97%) rename main/xiaozhi-server/core/{ => providers}/tools/device_iot/iot_handler.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/device_mcp/__init__.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/device_mcp/mcp_client.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/device_mcp/mcp_executor.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/device_mcp/mcp_handler.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/server_mcp/__init__.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/server_mcp/mcp_executor.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/server_mcp/mcp_manager.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/server_plugins/__init__.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/server_plugins/plugin_executor.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/unified_tool_handler.py (100%) rename main/xiaozhi-server/core/{ => providers}/tools/unified_tool_manager.py (100%) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 88e115d9..eaae4e19 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -17,7 +17,7 @@ from core.utils.util import ( filter_sensitive_info, ) from typing import Dict, Any -from core.tools.base import ToolAction +from core.providers.tools.base import ToolAction from core.utils.modules_initialize import ( initialize_modules, initialize_tts, @@ -29,7 +29,7 @@ from concurrent.futures import ThreadPoolExecutor from core.utils.dialogue import Message, Dialogue from core.providers.asr.dto.dto import InterfaceType from core.handle.textHandle import handleTextMessage -from core.tools.unified_tool_handler import UnifiedToolHandler +from core.providers.tools.unified_tool_handler import UnifiedToolHandler from plugins_func.loadplugins import auto_import_modules from plugins_func.register import Action, ActionResponse from core.auth import AuthMiddleware, AuthenticationError diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py index cb025bc1..e4e836b4 100644 --- a/main/xiaozhi-server/core/handle/helloHandle.py +++ b/main/xiaozhi-server/core/handle/helloHandle.py @@ -7,7 +7,7 @@ from core.utils.util import audio_to_data from core.handle.sendAudioHandle import sendAudioMessage, send_stt_message from core.utils.util import remove_punctuation_and_length, opus_datas_to_wav_bytes from core.providers.tts.dto.dto import ContentType, SentenceType -from core.tools.device_mcp import ( +from core.providers.tools.device_mcp import ( MCPClient, send_mcp_initialize_message, send_mcp_tools_list_request, diff --git a/main/xiaozhi-server/core/handle/intentHandler.py b/main/xiaozhi-server/core/handle/intentHandler.py index 4a298e6e..b80c43fe 100644 --- a/main/xiaozhi-server/core/handle/intentHandler.py +++ b/main/xiaozhi-server/core/handle/intentHandler.py @@ -6,7 +6,7 @@ from core.handle.helloHandle import checkWakeupWords from core.utils.util import remove_punctuation_and_length from core.providers.tts.dto.dto import ContentType from core.utils.dialogue import Message -from core.tools.device_mcp import call_mcp_tool +from core.providers.tools.device_mcp import call_mcp_tool from plugins_func.register import Action, ActionResponse from loguru import logger @@ -109,10 +109,12 @@ async def process_intent_result(conn, intent_result, original_text): # 使用统一工具处理器处理所有工具调用 try: tool_result = asyncio.run_coroutine_threadsafe( - conn.func_handler.handle_llm_function_call(conn, function_call_data), + conn.func_handler.handle_llm_function_call( + conn, function_call_data + ), conn.loop, ).result() - + # 转换ToolResult为ActionResponse result = conn._convert_tool_result_to_action_response(tool_result) except Exception as e: diff --git a/main/xiaozhi-server/core/handle/textHandle.py b/main/xiaozhi-server/core/handle/textHandle.py index 3b3526d7..9bce2ca4 100644 --- a/main/xiaozhi-server/core/handle/textHandle.py +++ b/main/xiaozhi-server/core/handle/textHandle.py @@ -1,11 +1,11 @@ import json from core.handle.abortHandle import handleAbortMessage from core.handle.helloHandle import handleHelloMessage -from core.tools.device_mcp import handle_mcp_message +from core.providers.tools.device_mcp import handle_mcp_message from core.utils.util import remove_punctuation_and_length, filter_sensitive_info from core.handle.receiveAudioHandle import startToChat, handleAudioMessage from core.handle.sendAudioHandle import send_stt_message, send_tts_message -from core.tools.device_iot import handleIotDescriptors, handleIotStatus +from core.providers.tools.device_iot import handleIotDescriptors, handleIotStatus from core.handle.reportHandle import enqueue_asr_report import asyncio diff --git a/main/xiaozhi-server/core/tools/__init__.py b/main/xiaozhi-server/core/providers/tools/__init__.py similarity index 100% rename from main/xiaozhi-server/core/tools/__init__.py rename to main/xiaozhi-server/core/providers/tools/__init__.py diff --git a/main/xiaozhi-server/core/tools/base/__init__.py b/main/xiaozhi-server/core/providers/tools/base/__init__.py similarity index 100% rename from main/xiaozhi-server/core/tools/base/__init__.py rename to main/xiaozhi-server/core/providers/tools/base/__init__.py diff --git a/main/xiaozhi-server/core/tools/base/tool_executor.py b/main/xiaozhi-server/core/providers/tools/base/tool_executor.py similarity index 100% rename from main/xiaozhi-server/core/tools/base/tool_executor.py rename to main/xiaozhi-server/core/providers/tools/base/tool_executor.py diff --git a/main/xiaozhi-server/core/tools/base/tool_types.py b/main/xiaozhi-server/core/providers/tools/base/tool_types.py similarity index 100% rename from main/xiaozhi-server/core/tools/base/tool_types.py rename to main/xiaozhi-server/core/providers/tools/base/tool_types.py diff --git a/main/xiaozhi-server/core/tools/device_iot/__init__.py b/main/xiaozhi-server/core/providers/tools/device_iot/__init__.py similarity index 100% rename from main/xiaozhi-server/core/tools/device_iot/__init__.py rename to main/xiaozhi-server/core/providers/tools/device_iot/__init__.py diff --git a/main/xiaozhi-server/core/tools/device_iot/iot_descriptor.py b/main/xiaozhi-server/core/providers/tools/device_iot/iot_descriptor.py similarity index 100% rename from main/xiaozhi-server/core/tools/device_iot/iot_descriptor.py rename to main/xiaozhi-server/core/providers/tools/device_iot/iot_descriptor.py diff --git a/main/xiaozhi-server/core/tools/device_iot/iot_executor.py b/main/xiaozhi-server/core/providers/tools/device_iot/iot_executor.py similarity index 97% rename from main/xiaozhi-server/core/tools/device_iot/iot_executor.py rename to main/xiaozhi-server/core/providers/tools/device_iot/iot_executor.py index e2801127..abf3d937 100644 --- a/main/xiaozhi-server/core/tools/device_iot/iot_executor.py +++ b/main/xiaozhi-server/core/providers/tools/device_iot/iot_executor.py @@ -118,12 +118,12 @@ class DeviceIoTExecutor(ToolExecutor): ): """发送IoT控制命令""" for key, value in self.conn.iot_descriptors.items(): - if key == device_name: + if key.lower() == device_name.lower(): for method in value.methods: - if method["name"] == method_name: + if method["name"].lower() == method_name.lower(): command = { - "name": device_name, - "method": method_name, + "name": key, + "method": method["name"], } if parameters: diff --git a/main/xiaozhi-server/core/tools/device_iot/iot_handler.py b/main/xiaozhi-server/core/providers/tools/device_iot/iot_handler.py similarity index 100% rename from main/xiaozhi-server/core/tools/device_iot/iot_handler.py rename to main/xiaozhi-server/core/providers/tools/device_iot/iot_handler.py diff --git a/main/xiaozhi-server/core/tools/device_mcp/__init__.py b/main/xiaozhi-server/core/providers/tools/device_mcp/__init__.py similarity index 100% rename from main/xiaozhi-server/core/tools/device_mcp/__init__.py rename to main/xiaozhi-server/core/providers/tools/device_mcp/__init__.py diff --git a/main/xiaozhi-server/core/tools/device_mcp/mcp_client.py b/main/xiaozhi-server/core/providers/tools/device_mcp/mcp_client.py similarity index 100% rename from main/xiaozhi-server/core/tools/device_mcp/mcp_client.py rename to main/xiaozhi-server/core/providers/tools/device_mcp/mcp_client.py diff --git a/main/xiaozhi-server/core/tools/device_mcp/mcp_executor.py b/main/xiaozhi-server/core/providers/tools/device_mcp/mcp_executor.py similarity index 100% rename from main/xiaozhi-server/core/tools/device_mcp/mcp_executor.py rename to main/xiaozhi-server/core/providers/tools/device_mcp/mcp_executor.py diff --git a/main/xiaozhi-server/core/tools/device_mcp/mcp_handler.py b/main/xiaozhi-server/core/providers/tools/device_mcp/mcp_handler.py similarity index 100% rename from main/xiaozhi-server/core/tools/device_mcp/mcp_handler.py rename to main/xiaozhi-server/core/providers/tools/device_mcp/mcp_handler.py diff --git a/main/xiaozhi-server/core/tools/server_mcp/__init__.py b/main/xiaozhi-server/core/providers/tools/server_mcp/__init__.py similarity index 100% rename from main/xiaozhi-server/core/tools/server_mcp/__init__.py rename to main/xiaozhi-server/core/providers/tools/server_mcp/__init__.py diff --git a/main/xiaozhi-server/core/tools/server_mcp/mcp_executor.py b/main/xiaozhi-server/core/providers/tools/server_mcp/mcp_executor.py similarity index 100% rename from main/xiaozhi-server/core/tools/server_mcp/mcp_executor.py rename to main/xiaozhi-server/core/providers/tools/server_mcp/mcp_executor.py diff --git a/main/xiaozhi-server/core/tools/server_mcp/mcp_manager.py b/main/xiaozhi-server/core/providers/tools/server_mcp/mcp_manager.py similarity index 100% rename from main/xiaozhi-server/core/tools/server_mcp/mcp_manager.py rename to main/xiaozhi-server/core/providers/tools/server_mcp/mcp_manager.py diff --git a/main/xiaozhi-server/core/tools/server_plugins/__init__.py b/main/xiaozhi-server/core/providers/tools/server_plugins/__init__.py similarity index 100% rename from main/xiaozhi-server/core/tools/server_plugins/__init__.py rename to main/xiaozhi-server/core/providers/tools/server_plugins/__init__.py diff --git a/main/xiaozhi-server/core/tools/server_plugins/plugin_executor.py b/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py similarity index 100% rename from main/xiaozhi-server/core/tools/server_plugins/plugin_executor.py rename to main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py diff --git a/main/xiaozhi-server/core/tools/unified_tool_handler.py b/main/xiaozhi-server/core/providers/tools/unified_tool_handler.py similarity index 100% rename from main/xiaozhi-server/core/tools/unified_tool_handler.py rename to main/xiaozhi-server/core/providers/tools/unified_tool_handler.py diff --git a/main/xiaozhi-server/core/tools/unified_tool_manager.py b/main/xiaozhi-server/core/providers/tools/unified_tool_manager.py similarity index 100% rename from main/xiaozhi-server/core/tools/unified_tool_manager.py rename to main/xiaozhi-server/core/providers/tools/unified_tool_manager.py From 2f78acaf4df1b4dc065ee5ba1651dba00c3d0797 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Thu, 26 Jun 2025 09:34:00 +0800 Subject: [PATCH 63/91] =?UTF-8?q?update:=E4=BC=98=E5=8C=96iot=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/providers/tools/device_iot/iot_executor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/xiaozhi-server/core/providers/tools/device_iot/iot_executor.py b/main/xiaozhi-server/core/providers/tools/device_iot/iot_executor.py index abf3d937..cf9ced88 100644 --- a/main/xiaozhi-server/core/providers/tools/device_iot/iot_executor.py +++ b/main/xiaozhi-server/core/providers/tools/device_iot/iot_executor.py @@ -107,9 +107,9 @@ class DeviceIoTExecutor(ToolExecutor): async def _get_iot_status(self, device_name: str, property_name: str): """获取IoT设备状态""" for key, value in self.conn.iot_descriptors.items(): - if key == device_name: + if key.lower() == device_name.lower(): for property_item in value.properties: - if property_item["name"] == property_name: + if property_item["name"].lower() == property_name.lower(): return property_item["value"] return None From 6ec7a1fe09ce6efcd6cfa48cd2f9a46766aa0384 Mon Sep 17 00:00:00 2001 From: Tink Date: Thu, 26 Jun 2025 11:09:17 +0800 Subject: [PATCH 64/91] manual-add-device --- main/manager-web/src/apis/api.js | 2 +- main/manager-web/src/apis/module/device.js | 5 + .../src/components/ManualAddDeviceDialog.vue | 158 ++++++++++++++++++ .../src/views/DeviceManagement.vue | 18 +- 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 main/manager-web/src/components/ManualAddDeviceDialog.vue diff --git a/main/manager-web/src/apis/api.js b/main/manager-web/src/apis/api.js index 0a3e8d18..4d1d7d7d 100755 --- a/main/manager-web/src/apis/api.js +++ b/main/manager-web/src/apis/api.js @@ -7,6 +7,7 @@ import model from './module/model.js' import ota from './module/ota.js' import timbre from "./module/timbre.js" import user from './module/user.js' + /** * 接口地址 * 开发时自动读取使用.env.development文件 @@ -22,7 +23,6 @@ export function getServiceUrl() { return DEV_API_SERVICE } - /** request服务封装 */ export default { getServiceUrl, diff --git a/main/manager-web/src/apis/module/device.js b/main/manager-web/src/apis/module/device.js index e6d61353..87f0c7b1 100644 --- a/main/manager-web/src/apis/module/device.js +++ b/main/manager-web/src/apis/module/device.js @@ -1,5 +1,6 @@ import { getServiceUrl } from '../api'; import RequestService from '../httpRequest'; +import request from '../request' export default { // 已绑设备 @@ -68,4 +69,8 @@ export default { }) }).send() }, + // 手动添加设备 + manualAddDevice: (params, callback) => { + return request.post('/device/manual-add', params, callback); + }, } \ No newline at end of file diff --git a/main/manager-web/src/components/ManualAddDeviceDialog.vue b/main/manager-web/src/components/ManualAddDeviceDialog.vue new file mode 100644 index 00000000..b6060ff5 --- /dev/null +++ b/main/manager-web/src/components/ManualAddDeviceDialog.vue @@ -0,0 +1,158 @@ + + + + + \ No newline at end of file diff --git a/main/manager-web/src/views/DeviceManagement.vue b/main/manager-web/src/views/DeviceManagement.vue index 25cd5a68..35238371 100644 --- a/main/manager-web/src/views/DeviceManagement.vue +++ b/main/manager-web/src/views/DeviceManagement.vue @@ -77,7 +77,10 @@ {{ isAllSelected ? '取消全选' : '全选' }} - 新增 + 验证码绑定 + + + 手动添加 解绑 @@ -103,6 +106,8 @@ + @@ -110,13 +115,19 @@