Compare commits

...
10 Commits
Author SHA1 Message Date
3030332422 a21a2e34d0 refactor: 合并 HA 设备控制插件为 hass_state 模块 2026-07-29 09:58:26 +08:00
3030332422 7a22b61969 fix:修复与函数同名时遗漏其他函数的问题 2026-07-28 17:05:18 +08:00
CGDandGitHub 477f85b97c Merge pull request #3304 from MsThink/main
fix(plugins): 展开模块级别插件名为具体函数名
2026-07-28 15:22:58 +08:00
CGDandGitHub de45f73efd Merge pull request #3303 from chentyke/fix/frontend-contract-consistency
fix(manager): align frontend and backend field contracts
2026-07-28 09:37:39 +08:00
CGDandGitHub 1dc46e30d7 Merge pull request #3302 from xinnan-tech/refactor/manager-api-tool-dependencies
refactor(manager-api): 升级常用工具依赖并收敛冗余实现
2026-07-28 09:28:00 +08:00
Sakura-RanChenandGitHub 5e0d853256 Merge pull request #3309 from xinnan-tech/py-fix-reportHandle
refactor(reportHandle): 重构上报处理逻辑,适配多格式音频转换
2026-07-27 15:24:15 +08:00
wengzh 2a9f809700 refactor(reportHandle): 重构上报处理逻辑,适配多格式音频转换
1.  重命名参数与变量,明确上报类型与音频格式
2.  拆分PCM和Opus转WAV逻辑,分别处理不同输入格式
3.  移动校验逻辑到上报队列函数开头,提前拦截无效上报
4.  完善Opus转WAV的解码与资源释放流程
2026-07-27 09:59:32 +08:00
colorful 523915f9fe fix(plugins): 展开模块级别插件名为具体函数名
在一个 function 文件中可能注册多个 @register_function,当配置下发或
本地配置使用模块名(文件名)时,需要自动展开为具体的函数名列表。

改动内容:
- register.py: 新增 module_func_map 全局映射,在 register_function
  装饰器中自动记录模块名 → 函数名列表的映射关系
- connection.py: 处理服务端下发的插件配置时,将模块级的 plugins 配置
  复制到各具体函数名 key 下,并将模块名展开写入 Intent functions 列表
- plugin_executor.py: 新增 _expand_plugin_names() 兜底展开逻辑和
  _get_plugin_description() 双层查找方法,确保本地配置场景也能正确展开
2026-07-24 10:47:00 +08:00
Tyke Chen 18e5c47a06 fix(manager): align frontend and backend field contracts 2026-07-24 10:21:05 +08:00
Tyke Chen 4a2c90ec87 refactor(manager-api): 升级常用工具依赖并收敛冗余实现 2026-07-24 09:06:15 +08:00
49 changed files with 785 additions and 412 deletions
+4 -4
View File
@@ -21,14 +21,14 @@
<junit.version>5.10.1</junit.version>
<druid.version>1.2.20</druid.version>
<mybatisplus.version>3.5.17</mybatisplus.version>
<hutool.version>5.8.24</hutool.version>
<jsoup.version>1.19.1</jsoup.version>
<hutool.version>5.8.46</hutool.version>
<jsoup.version>1.22.2</jsoup.version>
<knife4j.version>4.6.0</knife4j.version>
<springdoc.version>2.8.8</springdoc.version>
<commons-lang3.version>3.18.0</commons-lang3.version>
<commons-lang3.version>3.20.0</commons-lang3.version>
<shiro.version>2.0.2</shiro.version>
<captcha.version>1.6.2</captcha.version>
<guava.version>33.0.0-jre</guava.version>
<guava.version>33.6.0-jre</guava.version>
<liquibase-core.version>4.20.0</liquibase-core.version>
<aliyun-sms-version>4.1.0</aliyun-sms-version>
<okio-version>3.4.0</okio-version>
@@ -1,52 +0,0 @@
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();
}
}
@@ -1,89 +0,0 @@
package xiaozhi.common.utils;
import cn.hutool.core.util.ReUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 通用工具类
*/
public class ToolUtil {
private static final Logger logger = LoggerFactory.getLogger(ToolUtil.class);
/**
* 对象是否不为空(新增)
*/
public static boolean isNotEmpty(Object o) {
return !isEmpty(o);
}
/**
* 对象是否为空
*/
public static boolean isEmpty(Object o) {
if (o == null) {
return true;
}
if (o instanceof String) {
if (o.toString().trim().equals("")) {
return true;
}
} else if (o instanceof List) {
if (((List) o).size() == 0) {
return true;
}
} else if (o instanceof Map) {
if (((Map) o).size() == 0) {
return true;
}
} else if (o instanceof Set) {
if (((Set) o).size() == 0) {
return true;
}
} else if (o instanceof Object[]) {
if (((Object[]) o).length == 0) {
return true;
}
} else if (o instanceof int[]) {
if (((int[]) o).length == 0) {
return true;
}
} else if (o instanceof long[]) {
if (((long[]) o).length == 0) {
return true;
}
}
return false;
}
/**
* 对象组中是否存在空对象
*/
public static boolean isOneEmpty(Object... os) {
for (Object o : os) {
if (isEmpty(o)) {
return true;
}
}
return false;
}
/**
* 对象组中是否全是空对象
*/
public static boolean isAllEmpty(Object... os) {
for (Object o : os) {
if (!isEmpty(o)) {
return false;
}
}
return true;
}
}
@@ -5,6 +5,7 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.ListUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -20,7 +21,6 @@ import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.agent.Enums.AgentChatHistoryType;
import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
@@ -107,7 +107,7 @@ public class AgentChatHistoryServiceImpl extends CrudRepository<AiAgentChatHisto
if (deleteAudio) {
// 分批删除音频,避免超时
List<String> audioIds = baseMapper.getAudioIdsByAgentId(agentId);
if (ToolUtil.isNotEmpty(audioIds)) {
if (CollUtil.isNotEmpty(audioIds)) {
// 每批删除1000条
List<List<String>> batch = ListUtil.split(audioIds, 1000);
batch.forEach(dataList -> {
@@ -12,11 +12,11 @@ import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import cn.hutool.crypto.digest.DigestUtil;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.utils.AESUtils;
import xiaozhi.common.utils.HashEncryptionUtil;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.Enums.XiaoZhiMcpJsonRpcJson;
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
@@ -226,7 +226,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
*/
private static String encryptToken(String agentId, String key) {
// 使用md5对智能体id进行加密
String md5 = HashEncryptionUtil.Md5hexDigest(agentId);
String md5 = DigestUtil.md5Hex(agentId);
// aes需要加密文本
String json = "{\"agentId\": \"%s\"}".formatted(md5);
// 加密后成token值
@@ -18,6 +18,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import cn.hutool.core.collection.CollUtil;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
@@ -29,7 +30,6 @@ import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.dao.AgentTagDao;
import xiaozhi.modules.agent.dto.AgentCreateDTO;
@@ -243,13 +243,13 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
.map(DeviceEntity::getAgentId)
.distinct()
.collect(Collectors.toList());
if (ToolUtil.isNotEmpty(agentIds)) {
if (CollUtil.isNotEmpty(agentIds)) {
w.or().in("id", agentIds);
}
// 按标签名搜索
List<String> tagAgentIds = agentTagService.getAgentIdsByTagName(keyword);
if (ToolUtil.isNotEmpty(tagAgentIds)) {
if (CollUtil.isNotEmpty(tagAgentIds)) {
w.or().in("id", tagAgentIds);
}
});
@@ -291,7 +291,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
// 获取标签列表
List<AgentTagEntity> tags = agentTagDao.selectByAgentId(agent.getId());
if (ToolUtil.isNotEmpty(tags)) {
if (CollUtil.isNotEmpty(tags)) {
dto.setTags(tags.stream().map(this::convertTagToDTO).collect(Collectors.toList()));
}
@@ -5,8 +5,6 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -31,6 +29,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import cn.hutool.crypto.digest.DigestUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
@@ -288,7 +287,7 @@ public class OTAMagController {
// 返回文件路径
return new Result<String>().ok(filePath.toString());
} catch (IOException | NoSuchAlgorithmException e) {
} catch (IOException e) {
return new Result<String>().error("文件上传失败:" + e.getMessage());
}
}
@@ -329,13 +328,7 @@ public class OTAMagController {
return result;
}
private String calculateMD5(MultipartFile file) throws IOException, NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(file.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
private String calculateMD5(MultipartFile file) throws IOException {
return DigestUtil.md5Hex(file.getBytes());
}
}
@@ -31,6 +31,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
@@ -51,7 +52,6 @@ import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.DateUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.device.dao.DeviceDao;
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
import xiaozhi.modules.device.dto.DevicePageUserDTO;
@@ -103,15 +103,15 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
throw new RenException(ErrorCode.ACTIVATION_CODE_EMPTY);
}
String deviceKey = RedisKeys.getOtaActivationCode(activationCode);
Object cacheDeviceId = redisUtils.get(deviceKey);
if (ToolUtil.isEmpty(cacheDeviceId)) {
String cacheDeviceId = (String) redisUtils.get(deviceKey);
if (StringUtils.isBlank(cacheDeviceId)) {
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
}
String deviceId = (String) cacheDeviceId;
String deviceId = cacheDeviceId;
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String cacheDeviceKey = RedisKeys.getOtaDeviceActivationInfo(safeDeviceId);
Map<String, Object> cacheMap = JsonUtils.toStringObjectMap(redisUtils.get(cacheDeviceKey));
if (ToolUtil.isEmpty(cacheMap)) {
if (MapUtil.isEmpty(cacheMap)) {
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
}
String cachedCode = (String) cacheMap.get("activation_code");
@@ -181,7 +181,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.builder(new HashMap<String, Set<String>>())
.put("clientIds", deviceIds).build();
if (ToolUtil.isNotEmpty(deviceIds)) {
if (CollUtil.isNotEmpty(deviceIds)) {
return postToMqttGateway(url, params);
}
// 返回响应
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import cn.hutool.core.collection.CollUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -23,7 +24,6 @@ import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.Result;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.knowledge.dto.KnowledgeBaseDTO;
import xiaozhi.modules.knowledge.service.KnowledgeBaseService;
import xiaozhi.modules.knowledge.service.KnowledgeManagerService;
@@ -140,7 +140,7 @@ public class KnowledgeBaseController {
List<String> idList = Arrays.asList(ids.split(","));
List<KnowledgeBaseDTO> knowledgeBaseDTOs = Optional.ofNullable(knowledgeBaseService.getByDatasetIdList(idList))
.orElseGet(ArrayList::new);
if (ToolUtil.isNotEmpty(knowledgeBaseDTOs)) {
if (CollUtil.isNotEmpty(knowledgeBaseDTOs)) {
knowledgeBaseDTOs.forEach(item -> {
// 检查权限:用户只能删除自己创建的知识库
if (item.getCreator() == null || !item.getCreator().equals(currentUserId)) {
@@ -1,8 +1,7 @@
package xiaozhi.modules.security.service.impl;
import java.io.IOException;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.time.Duration;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
@@ -13,6 +12,7 @@ import com.google.common.cache.CacheBuilder;
import com.wf.captcha.SpecCaptcha;
import com.wf.captcha.base.Captcha;
import cn.hutool.core.util.RandomUtil;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import xiaozhi.common.constant.Constant;
@@ -41,7 +41,7 @@ public class CaptchaServiceImpl implements CaptchaService {
* Local Cache 5分钟过期
*/
Cache<String, String> localCache = CacheBuilder.newBuilder().maximumSize(1000)
.expireAfterAccess(5, TimeUnit.MINUTES).build();
.expireAfterAccess(Duration.ofMinutes(5)).build();
@Override
public void create(HttpServletResponse response, String uuid) throws IOException {
@@ -113,7 +113,7 @@ public class CaptchaServiceImpl implements CaptchaService {
}
String key = RedisKeys.getSMSValidateCodeKey(phone);
String validateCodes = generateValidateCode(6);
String validateCodes = RandomUtil.randomNumbers(6);
// 设置验证码
setCache(key, validateCodes);
@@ -135,22 +135,6 @@ public class CaptchaServiceImpl implements CaptchaService {
return validate(key, code, delete);
}
/**
* 生成指定数量的随机数验证码
*
* @param length 数量
* @return 随机码
*/
private String generateValidateCode(Integer length) {
String chars = "0123456789"; // 字符范围可以自定义:数字
Random random = new Random();
StringBuilder code = new StringBuilder();
for (int i = 0; i < length; i++) {
code.append(chars.charAt(random.nextInt(chars.length())));
}
return code.toString();
}
private void setCache(String key, String value) {
if (open) {
key = RedisKeys.getCaptchaKey(key);
@@ -12,6 +12,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.hutool.core.collection.CollUtil;
import lombok.AllArgsConstructor;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.exception.ErrorCode;
@@ -21,7 +22,6 @@ import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.sys.dao.SysDictDataDao;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.dto.SysDictDataDTO;
@@ -104,13 +104,13 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
@Transactional(rollbackFor = Exception.class)
public void delete(Long[] ids) {
List<Long> idList = Arrays.asList(ids);
if (ToolUtil.isNotEmpty(idList)) {
if (CollUtil.isNotEmpty(idList)) {
//批量删除redis字典
List<String> redisKeyList = new ArrayList<>();
//批量获取字典类型
List<String> dictTypeList = Optional.ofNullable(baseDao.getDictTypesByIdList(idList)).orElseGet(ArrayList::new);
dictTypeList.forEach(dictType -> redisKeyList.add(RedisKeys.getDictDataByTypeKey(dictType)));
if (ToolUtil.isNotEmpty(redisKeyList)) {
if (CollUtil.isNotEmpty(redisKeyList)) {
//清除缓存
redisUtils.delete(redisKeyList);
}
@@ -34,7 +34,7 @@ public class TimbreDataDTO {
@Schema(description = "排序")
@Min(value = 0, message = "{sort.number}")
private long sort;
private Long sort;
@Schema(description = "对应 TTS 模型主键")
@NotBlank(message = "{timbre.ttsModelId.require}")
@@ -3,6 +3,7 @@ package xiaozhi.modules.timbre.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -41,7 +42,8 @@ public class TimbreEntity {
private String referenceText;
@Schema(description = "排序")
private long sort;
@TableField(updateStrategy = FieldStrategy.NOT_NULL)
private Long sort;
@Schema(description = "对应 TTS 模型主键")
private String ttsModelId;
@@ -99,6 +99,9 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
@Transactional(rollbackFor = Exception.class)
public void save(TimbreDataDTO dto) {
isTtsModelId(dto.getTtsModelId());
if (dto.getSort() == null) {
dto.setSort(0L);
}
TimbreEntity timbreEntity = ConvertUtils.sourceToTarget(dto, TimbreEntity.class);
baseDao.insert(timbreEntity);
}
@@ -32,7 +32,7 @@ public class TimbreDetailsVO implements Serializable {
private String referenceText;
@Schema(description = "排序")
private long sort;
private Long sort;
@Schema(description = "对应 TTS 模型主键")
private String ttsModelId;
@@ -17,6 +17,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import cn.hutool.core.collection.CollUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
@@ -26,7 +27,6 @@ import xiaozhi.common.page.PageData;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.DateUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.model.entity.ModelConfigEntity;
import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.modules.sys.dao.SysUserDao;
@@ -120,7 +120,7 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
entity.setTrainStatus(0); // 默认训练中
batchInsertList.add(entity);
}
if (ToolUtil.isNotEmpty(batchInsertList)) {
if (CollUtil.isNotEmpty(batchInsertList)) {
insertBatch(batchInsertList);
}
}
@@ -0,0 +1,46 @@
-- liquibase formatted sql
-- 插入新的 hass_state 插件记录(合并 get_state 的配置字段)
INSERT INTO ai_model_provider (id, model_type, provider_code, name, fields,
sort, creator, create_date, updater, update_date)
VALUES ('SYSTEM_PLUGIN_HA_STATE',
'Plugin',
'hass_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')
)
),
50, 0, NOW(), 0, NOW());
-- 迁移已配置的 agent:把指向 HA_GET_STATE 的改成 HA_STATE(保留参数)
UPDATE ai_agent_plugin_mapping
SET plugin_id = 'SYSTEM_PLUGIN_HA_STATE'
WHERE plugin_id = 'SYSTEM_PLUGIN_HA_GET_STATE';
-- HA_SET_STATE 本来就没有配置字段(fields=[]),直接删除其 mapping
DELETE FROM ai_agent_plugin_mapping
WHERE plugin_id = 'SYSTEM_PLUGIN_HA_SET_STATE';
-- 删除旧的插件定义
DELETE FROM ai_model_provider
WHERE id IN ('SYSTEM_PLUGIN_HA_GET_STATE', 'SYSTEM_PLUGIN_HA_SET_STATE');
@@ -711,3 +711,10 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607101200.sql
- changeSet:
id: 202607290930
author: cgd
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607290930.sql
@@ -2,16 +2,20 @@ package xiaozhi.modules.timbre.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.timbre.dao.TimbreDao;
import xiaozhi.modules.timbre.dto.TimbreDataDTO;
import xiaozhi.modules.timbre.entity.TimbreEntity;
import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
@@ -54,4 +58,74 @@ class TimbreServiceImplTest {
assertNull(service.getDefaultLanguageById("voice-id"));
}
@Test
void updateLeavesSortOutOfTheUpdateWhenRequestOmitsIt() {
TimbreDao timbreDao = mock(TimbreDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, mock(VoiceCloneDao.class), redisUtils);
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
TimbreDataDTO dto = validTimbreData();
service.update("voice-id", dto);
verify(timbreDao, never()).selectById("voice-id");
verify(timbreDao).updateById(argThat((TimbreEntity entity) ->
"voice-id".equals(entity.getId()) && entity.getSort() == null));
verify(redisUtils).delete("timbre:details:voice-id");
}
@Test
void updateUsesExplicitSortWithoutLoadingExistingTimbre() {
TimbreDao timbreDao = mock(TimbreDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(
timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class));
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
TimbreDataDTO dto = validTimbreData();
dto.setSort(0L);
service.update("voice-id", dto);
verify(timbreDao, never()).selectById("voice-id");
verify(timbreDao).updateById(argThat((TimbreEntity entity) -> entity.getSort() == 0L));
}
@Test
void saveDefaultsOmittedSortToZero() {
TimbreDao timbreDao = mock(TimbreDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(
timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class));
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
service.save(validTimbreData());
verify(timbreDao).insert(argThat((TimbreEntity entity) ->
"测试音色".equals(entity.getName()) && entity.getSort() == 0L));
}
@Test
void getSupportsLegacyRowsWithNullSort() {
TimbreDao timbreDao = mock(TimbreDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
TimbreServiceImpl service = new TimbreServiceImpl(
timbreDao, mock(VoiceCloneDao.class), redisUtils);
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
TimbreEntity entity = new TimbreEntity();
entity.setId("voice-id");
entity.setSort(null);
when(timbreDao.selectById("voice-id")).thenReturn(entity);
TimbreDetailsVO details = service.get("voice-id");
assertNull(details.getSort());
}
private TimbreDataDTO validTimbreData() {
TimbreDataDTO dto = new TimbreDataDTO();
dto.setLanguages("中文");
dto.setName("测试音色");
dto.setTtsModelId("TTS_Test");
dto.setTtsVoice("test-voice");
return dto;
}
}
+1 -1
View File
@@ -69,7 +69,7 @@
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"type-check": "vue-tsc --noEmit",
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs",
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs src/pages/agent/components/voicePreviewUtils.test.mjs src/pages/device/deviceTimeUtils.test.mjs",
"openapi-ts-request": "openapi-ts",
"prepare": "git init && husky",
"lint": "eslint",
+16 -2
View File
@@ -8,6 +8,7 @@ import type {
ModelOption,
PageData,
RoleTemplate,
TtsVoice,
} from './types'
import { http } from '@/http/request/alova'
@@ -89,7 +90,7 @@ export function deleteAgent(id: string) {
// 获取TTS音色列表
export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
return http.Get<{ id: string, name: string }[]>(`/models/${ttsModelId}/voices`, {
return http.Get<TtsVoice[]>(`/models/${ttsModelId}/voices`, {
params: {
voiceName,
},
@@ -214,7 +215,7 @@ export function updateAgentTags(agentId: string, data) {
// 获取所有语言
export function getAllLanguage(modelId: string) {
return http.Get<{ id: string, name: string, languages: string }[]>(`/models/${modelId}/voices`, {
return http.Get<TtsVoice[]>(`/models/${modelId}/voices`, {
meta: {
ignoreAuth: false,
toast: false,
@@ -225,6 +226,19 @@ export function getAllLanguage(modelId: string) {
})
}
/**
* 获取克隆音色的临时播放ID
* @param cloneId 克隆音色记录ID
*/
export function getVoiceCloneAudioId(cloneId: string) {
return http.Post<string>(`/voiceClone/audio/${cloneId}`, {}, {
meta: {
ignoreAuth: false,
toast: false,
},
})
}
// 获取智能体历史版本列表
export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) {
return http.Get<PageData<AgentSnapshot>>(`/agent/${agentId}/snapshots`, {
@@ -114,6 +114,14 @@ export interface CorrectWordFile {
wordCount?: number
}
export interface TtsVoice {
id: string
name: string
voiceDemo?: string | null
languages?: string | null
isClone?: boolean | null
}
// 角色模板数据类型
export interface RoleTemplate {
id: string
+1 -1
View File
@@ -7,7 +7,7 @@ export interface Device {
id: string
userId: string
macAddress: string
lastConnectedAt: string
lastConnectedAtTimestamp: string | null
autoUpdate: number
board: string
alias?: string
@@ -0,0 +1,42 @@
/** @param {Record<string, any>} voice */
export function hasVoicePreview(voice) {
return Boolean(voice?.isClone || voice?.voiceDemo || voice?.voice_demo)
}
export function createVoicePreviewRequestGate() {
let sequence = 0
return {
begin() {
sequence += 1
return sequence
},
invalidate() {
sequence += 1
},
isCurrent(requestId) {
return requestId === sequence
},
}
}
/**
* @param {{ id: string, isClone?: boolean, voiceDemo?: string | null }} voice
* @param {(cloneId: string) => Promise<string>} getCloneAudioId
* @param {string} baseUrl
*/
export async function resolveVoicePreviewUrl(voice, getCloneAudioId, baseUrl) {
if (!voice?.isClone) {
return typeof voice?.voiceDemo === 'string' ? voice.voiceDemo : ''
}
if (!voice.id) {
return ''
}
const uuid = await getCloneAudioId(voice.id)
if (!uuid) {
return ''
}
return `${baseUrl.replace(/\/+$/, '')}/voiceClone/play/${encodeURIComponent(uuid)}`
}
@@ -0,0 +1,50 @@
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
import assert from 'node:assert/strict'
import test from 'node:test'
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './voicePreviewUtils.mjs'
test('keeps normal voice previews on their direct URL', async () => {
let cloneRequestCount = 0
const url = await resolveVoicePreviewUrl({
id: 'normal-voice',
isClone: false,
voiceDemo: 'https://cdn.example.test/normal.wav',
}, async () => {
cloneRequestCount += 1
return 'unused'
}, 'https://api.example.test')
assert.equal(url, 'https://cdn.example.test/normal.wav')
assert.equal(cloneRequestCount, 0)
})
test('uses the clone record id to obtain and construct a temporary play URL', async () => {
let requestedCloneId = ''
const url = await resolveVoicePreviewUrl({
id: 'clone-record-id',
isClone: true,
voiceDemo: 'provider-speaker-id-must-not-be-played',
}, async (cloneId) => {
requestedCloneId = cloneId
return 'temporary uuid'
}, 'https://api.example.test/')
assert.equal(requestedCloneId, 'clone-record-id')
assert.equal(url, 'https://api.example.test/voiceClone/play/temporary%20uuid')
})
test('shows a preview control for cloned voices even without voiceDemo', () => {
assert.equal(hasVoicePreview({ id: 'clone-record-id', isClone: true }), true)
assert.equal(hasVoicePreview({ id: 'normal-voice', isClone: false, voiceDemo: '' }), false)
})
test('invalidates an older request when the same voice is cancelled and retried', () => {
const gate = createVoicePreviewRequestGate()
const firstRequest = gate.begin()
gate.invalidate()
const retryRequest = gate.begin()
assert.equal(gate.isCurrent(firstRequest), false)
assert.equal(gate.isCurrent(retryRequest), true)
})
+74 -20
View File
@@ -1,12 +1,14 @@
<script lang="ts" setup>
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate, TtsVoice } from '@/api/agent/types'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, updateAgent } from '@/api/agent/agent'
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, getVoiceCloneAudioId, updateAgent } from '@/api/agent/agent'
import { t } from '@/i18n'
import { usePluginStore, useProvider, useSpeedPitch } from '@/store'
import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue'
import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs'
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './components/voicePreviewUtils.mjs'
defineOptions({
name: 'AgentEdit',
@@ -84,10 +86,20 @@ const modelOptions = ref<{
TTS: [],
})
interface VoiceOption {
id?: string
value: string
name: string
voiceDemo?: string | null
voice_demo?: string | null
isClone: boolean
train_status?: number
}
// 音色选项数据
const voiceOptions = ref<any[]>([])
const voiceOptions = ref<VoiceOption[]>([])
// 保存完整的音色信息
const voiceDetails = ref<Record<string, any>>({})
const voiceDetails = ref<Record<string, TtsVoice>>({})
// 上报模式选项数据
const reportOptions = [
@@ -139,6 +151,7 @@ interface SnapshotRestoreContext {
// 音频播放相关
const audioRef = ref<UniApp.InnerAudioContext | null>(null)
const playingVoiceId = ref<string>('')
const voicePreviewRequestGate = createVoicePreviewRequestGate()
// 使用插件store
const pluginStore = usePluginStore()
@@ -513,8 +526,8 @@ interface TtsSelectionState {
languageTouched: boolean
voiceTouched: boolean
optionsModelId: string
voiceOptions: any[]
voiceDetails: Record<string, any>
voiceOptions: VoiceOption[]
voiceDetails: Record<string, TtsVoice>
languageOptions: any[]
displayNames: {
tts: string
@@ -565,7 +578,7 @@ function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) {
return
}
const allVoices = Object.values(voiceDetails.value) as any[]
const allVoices = Object.values(voiceDetails.value)
// 根据选中的语言筛选音色
const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
@@ -624,7 +637,7 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption
throw new Error('No TTS voice metadata is available')
}
// 保存完整的音色信息
voiceDetails.value = res.reduce((acc, voice) => {
voiceDetails.value = res.reduce<Record<string, TtsVoice>>((acc, voice) => {
acc[voice.id] = voice
return acc
}, {})
@@ -863,44 +876,85 @@ function onPickerCancel(type: string) {
}
// 播放音频
function playAudio(voiceDemo: string, voiceId: string, event: Event) {
async function playAudio(voice: VoiceOption, event: Event) {
event.stopPropagation() // 阻止事件冒泡,防止关闭下拉框
if (!voiceDemo) {
if (!hasVoicePreview(voice)) {
return
}
// 如果正在播放同一个音频,则停止
if (playingVoiceId.value === voiceId) {
if (playingVoiceId.value === voice.value) {
stopAudio()
return
}
// 停止之前的音频
stopAudio()
const requestId = voicePreviewRequestGate.begin()
playingVoiceId.value = voice.value
try {
const audioUrl = await resolveVoicePreviewUrl({
id: voice.value,
isClone: voice.isClone,
voiceDemo: voice.voiceDemo || voice.voice_demo,
}, getVoiceCloneAudioId, getEnvBaseUrl())
// 用户可能在等待克隆音色临时地址时取消或切换了音色。
if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
return
}
if (!audioUrl) {
toast.error(t('voiceprint.getAudioFailed'))
playingVoiceId.value = ''
return
}
// 创建新的音频实例
audioRef.value = uni.createInnerAudioContext()
audioRef.value.src = voiceDemo
playingVoiceId.value = voiceId
const audio = uni.createInnerAudioContext()
audioRef.value = audio
audio.src = audioUrl
// 监听播放结束
audioRef.value.onEnded(() => {
audio.onEnded(() => {
if (
voicePreviewRequestGate.isCurrent(requestId)
&& audioRef.value === audio
&& playingVoiceId.value === voice.value
) {
playingVoiceId.value = ''
}
})
// 监听播放错误
audioRef.value.onError(() => {
toast.error('音频播放失败')
audio.onError(() => {
if (
voicePreviewRequestGate.isCurrent(requestId)
&& audioRef.value === audio
&& playingVoiceId.value === voice.value
) {
toast.error(t('voiceprint.audioPlayFailed'))
playingVoiceId.value = ''
}
})
// 播放音频
audioRef.value.play()
audio.play()
}
catch (error) {
if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
return
}
console.error('获取克隆音色试听地址失败:', error)
toast.error(t('voiceprint.getAudioFailed'))
playingVoiceId.value = ''
}
}
// 停止音频
function stopAudio() {
voicePreviewRequestGate.invalidate()
if (audioRef.value) {
audioRef.value.stop()
audioRef.value.destroy()
@@ -1592,10 +1646,10 @@ onMounted(async () => {
class="flex items-center justify-between border-b border-[#f5f5f5] p-[32rpx] transition-all active:bg-[#f5f7fb]"
@click="onPickerConfirm('voiceprint', voice.value, voice.name)"
>
<text :class="`flex-1 text-[28rpx] text-[#232338] ${(voice.voiceDemo || voice.voice_demo) ? '' : 'text-center'}`">
<text :class="`flex-1 text-[28rpx] text-[#232338] ${hasVoicePreview(voice) ? '' : 'text-center'}`">
{{ voice.name }}
</text>
<view v-if="voice.voiceDemo || voice.voice_demo" class="ml-[20rpx]" @click.stop="playAudio(voice.voiceDemo || voice.voice_demo, voice.value, $event)">
<view v-if="hasVoicePreview(voice)" class="ml-[20rpx]" @click.stop="playAudio(voice, $event)">
<wd-icon
:name="playingVoiceId === voice.value ? 'pause-circle' : 'play-circle'"
size="24px"
@@ -0,0 +1,14 @@
/** @param {unknown} timestamp */
export function parseDeviceLastConnectedAtTimestamp(timestamp) {
if (typeof timestamp !== 'string' || !timestamp.trim()) {
return null
}
const milliseconds = Number(timestamp)
if (!Number.isFinite(milliseconds)) {
return null
}
const date = new Date(milliseconds)
return Number.isNaN(date.getTime()) ? null : date
}
@@ -0,0 +1,15 @@
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
import assert from 'node:assert/strict'
import test from 'node:test'
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
test('parses the backend Long timestamp serialized as a string', () => {
const timestamp = '1783689702000'
assert.equal(parseDeviceLastConnectedAtTimestamp(timestamp)?.getTime(), Number(timestamp))
})
test('rejects missing and malformed device timestamps', () => {
assert.equal(parseDeviceLastConnectedAtTimestamp(null), null)
assert.equal(parseDeviceLastConnectedAtTimestamp(''), null)
assert.equal(parseDeviceLastConnectedAtTimestamp('not-a-timestamp'), null)
})
@@ -5,6 +5,7 @@ import { useMessage } from 'wot-design-uni/components/wd-message-box'
import { bindDevice, bindDeviceManual, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
import { t } from '@/i18n'
import { toast } from '@/utils/toast'
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
defineOptions({
name: 'DeviceManage',
@@ -131,10 +132,10 @@ function getDeviceTypeName(boardKey: string): string {
}
// 格式化时间
function formatTime(timeStr: string) {
if (!timeStr)
function formatTime(timestamp: string | null) {
const date = parseDeviceLastConnectedAtTimestamp(timestamp)
if (!date)
return t('device.neverConnected')
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
@@ -410,7 +411,7 @@ defineExpose({
{{ t('device.firmwareVersion') }}{{ device.appVersion }}
</text>
<text class="block text-[28rpx] text-[#65686f] leading-[1.4]">
{{ t('device.lastConnection') }}{{ formatTime(device.lastConnectedAt) }}
{{ t('device.lastConnection') }}{{ formatTime(device.lastConnectedAtTimestamp) }}
</text>
</view>
@@ -27,7 +27,7 @@ export default {
getFileList(params, callback) {
const queryParams = new URLSearchParams({
page: params.page,
pageSize: params.pageSize
limit: params.pageSize
}).toString();
RequestService.sendRequest()
@@ -79,6 +79,7 @@ export default {
remark: params.remark,
referenceAudio: params.referenceAudio,
referenceText: params.referenceText,
sort: params.sort,
ttsModelId: params.ttsModelId,
ttsVoice: params.voiceCode,
voiceDemo: params.voiceDemo || ''
@@ -497,8 +497,7 @@ const FALLBACK_PLUGIN_NAME_KEYS = {
SYSTEM_PLUGIN_MUSIC: "agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC",
SYSTEM_PLUGIN_NEWS_CHINANEWS: "agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS",
SYSTEM_PLUGIN_NEWS_NEWSNOW: "agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW",
SYSTEM_PLUGIN_HA_GET_STATE: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE",
SYSTEM_PLUGIN_HA_SET_STATE: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE",
SYSTEM_PLUGIN_HA_STATE: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_STATE",
SYSTEM_PLUGIN_HA_PLAY_MUSIC: "agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC",
SYSTEM_PLUGIN_WEB_SEARCH: "agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH",
SYSTEM_PLUGIN_CALL_DEVICE: "agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE"
+1 -2
View File
@@ -903,8 +903,7 @@ export default {
'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': 'Server-Musikplayer',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': 'China News',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'NewsNow-Aggregation',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'HomeAssistant-Statusabfrage',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'HomeAssistant-Statusaktualisierung',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_STATE': 'HomeAssistant-Gerätesteuerung',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'HomeAssistant-Musikplayer',
'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': 'Websuche',
'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': 'Geräteanruf',
+1 -2
View File
@@ -953,8 +953,7 @@ export default {
'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': 'Server music player',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': 'China News',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'NewsNow aggregation',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'HomeAssistant state query',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'HomeAssistant state update',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_STATE': 'HomeAssistant device control',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'HomeAssistant music player',
'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': 'Web search',
'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': 'Device-to-device call',
+1 -2
View File
@@ -903,8 +903,7 @@ export default {
'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': 'Reprodutor de música do servidor',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': 'China News',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'Agregação NewsNow',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'Consulta de estado do HomeAssistant',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'Atualização de estado do HomeAssistant',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_STATE': 'Controle de dispositivo do HomeAssistant',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'Reprodutor de música do HomeAssistant',
'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': 'Pesquisa na web',
'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': 'Chamada entre dispositivos',
+1 -2
View File
@@ -903,8 +903,7 @@ export default {
'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': 'Trình phát nhạc máy chủ',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': 'Tin tức China News',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'Tổng hợp NewsNow',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'Truy vấn trạng thái HomeAssistant',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'Cập nhật trạng thái HomeAssistant',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_STATE': 'Điều khiển thiết bị HomeAssistant',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'Trình phát nhạc HomeAssistant',
'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': 'Tìm kiếm web',
'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': 'Gọi thiết bị',
+1 -2
View File
@@ -953,8 +953,7 @@ export default {
'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': '服务器音乐播放',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': '中新网新闻',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'newsnow新闻聚合',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'HomeAssistant设备状态查询',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'HomeAssistant设备状态修改',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_STATE': 'HomeAssistant设备控制',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'HomeAssistant音乐播放',
'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': '联网搜索',
'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': '设备呼叫设备',
+1 -2
View File
@@ -903,8 +903,7 @@ export default {
'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': '伺服器音樂播放',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': '中新網新聞',
'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'newsnow新聞聚合',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'HomeAssistant設備狀態查詢',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'HomeAssistant設備狀態修改',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_STATE': 'HomeAssistant設備控制',
'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'HomeAssistant音樂播放',
'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': '聯網搜尋',
'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': '設備呼叫設備',
@@ -141,23 +141,24 @@
<p class="section-desc">{{ $t('addressBookManagement.setPermissionDesc', { count: selectedPermissions.length }) }}</p>
</div>
<div class="section-actions">
<CustomButton size="small" @click="handleCancel">{{ $t('common.cancel') }}</CustomButton>
<CustomButton size="small" @click="handleToggleSelectAll">{{ isAllSelected ? $t('addressBookManagement.deselectAll') : $t('addressBookManagement.selectAll') }}</CustomButton>
<CustomButton type="confirm" size="small" @click="handleSavePermissions">{{ $t('addressBookManagement.save') }}</CustomButton>
<CustomButton size="small" :disabled="permissionsLoading" @click="handleCancel">{{ $t('common.cancel') }}</CustomButton>
<CustomButton size="small" :disabled="permissionsLoading" @click="handleToggleSelectAll">{{ isAllSelected ? $t('addressBookManagement.deselectAll') : $t('addressBookManagement.selectAll') }}</CustomButton>
<CustomButton type="confirm" size="small" :disabled="permissionsLoading" @click="handleSavePermissions">{{ $t('addressBookManagement.save') }}</CustomButton>
</div>
</div>
<div class="permission-grid">
<div v-loading="permissionsLoading" class="permission-grid">
<div
v-for="device in allDevices"
:key="device.id"
class="permission-item"
:class="{ active: selectedPermissions.includes(device.id) }"
:class="{ active: selectedPermissions.includes(device.deviceId) }"
>
<el-checkbox
class="permission-checkbox"
:value="selectedPermissions.includes(device.id)"
@change="(checked) => handlePermissionToggle(device.id, checked)"
:disabled="permissionsLoading"
:value="selectedPermissions.includes(device.deviceId)"
@change="(checked) => handlePermissionToggle(device.deviceId, checked)"
></el-checkbox>
<div class="permission-avatar">
<img :src="getDeviceAvatar(device.id)" alt="avatar" />
@@ -229,7 +230,9 @@ export default {
editAgentNameValue: '',
editingDeviceId: null,
editingDeviceName: '',
mqttServiceAvailable: false
mqttServiceAvailable: false,
permissionRequestSequence: 0,
permissionsLoading: false
};
},
created() {
@@ -363,18 +366,45 @@ export default {
this.loadAddressBookPermissions(device.deviceId);
},
loadAddressBookPermissions(macAddress) {
const requestId = ++this.permissionRequestSequence;
this.permissionsLoading = true;
this.selectedPermissions = [];
this.originalPermissions = [];
this.editingDeviceId = null;
this.editingDeviceName = '';
this.allDevices.forEach(device => {
device.addressBookAlias = '';
});
AddressBookApi.getAddressBookList(macAddress, (res) => {
if (
requestId !== this.permissionRequestSequence ||
this.selectedDevice?.deviceId !== macAddress
) {
return;
}
this.permissionsLoading = false;
if (res.data?.code === 0) {
const permissions = res.data.data || [];
const permissionsByTargetMac = new Map(
permissions.map(permission => [
(permission.targetMac || '').toLowerCase(),
permission
])
);
// 设置已选择的权限
this.selectedPermissions = permissions
const permittedTargetMacs = new Set(
permissions
.filter(p => p.hasPermission)
.map(p => p.targetMac);
.map(p => (p.targetMac || '').toLowerCase())
);
this.selectedPermissions = this.allDevices
.filter(device => permittedTargetMacs.has((device.deviceId || '').toLowerCase()))
.map(device => device.deviceId);
// 保存初始权限状态(用于对比变更)
this.originalPermissions = [...this.selectedPermissions];
// 更新设备的通讯录别名
this.allDevices.forEach(device => {
const addrBook = permissions.find(p => p.targetMac === device.deviceId);
const addrBook = permissionsByTargetMac.get((device.deviceId || '').toLowerCase());
if (addrBook) {
device.addressBookAlias = addrBook.alias || '';
} else {
@@ -385,6 +415,7 @@ export default {
});
},
handleStartEditPermission(device) {
if (this.permissionsLoading) return;
this.editingDeviceId = device.id;
this.editingDeviceName = device.addressBookAlias || device.name;
this.$nextTick(() => {
@@ -412,13 +443,14 @@ export default {
this.editingDeviceId = null;
this.editingDeviceName = '';
},
handlePermissionToggle(deviceId, checked) {
handlePermissionToggle(targetMac, checked) {
if (this.permissionsLoading) return;
if (checked) {
if (!this.selectedPermissions.includes(deviceId)) {
this.selectedPermissions.push(deviceId);
if (!this.selectedPermissions.includes(targetMac)) {
this.selectedPermissions.push(targetMac);
}
} else {
const index = this.selectedPermissions.indexOf(deviceId);
const index = this.selectedPermissions.indexOf(targetMac);
if (index > -1) {
this.selectedPermissions.splice(index, 1);
}
@@ -428,21 +460,22 @@ export default {
if (this.isAllSelected) {
this.selectedPermissions = [];
} else {
this.selectedPermissions = this.allDevices.map(d => d.id);
this.selectedPermissions = this.allDevices.map(d => d.deviceId);
}
},
handleCancel() {
this.selectedPermissions = [];
},
handleSavePermissions() {
if (this.permissionsLoading) return;
const promises = this.allDevices
.filter(device => {
const isNowSelected = this.selectedPermissions.includes(device.id);
const wasOriginallySelected = this.originalPermissions.includes(device.id);
const isNowSelected = this.selectedPermissions.includes(device.deviceId);
const wasOriginallySelected = this.originalPermissions.includes(device.deviceId);
return isNowSelected !== wasOriginallySelected;
})
.map(device => {
const hasPermission = this.selectedPermissions.includes(device.id);
const hasPermission = this.selectedPermissions.includes(device.deviceId);
return new Promise((resolve) => {
AddressBookApi.updatePermission({
macAddress: this.selectedDevice.deviceId,
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const addressBookSource = await readFile(
new URL('../src/views/AddressBookManagement.vue', import.meta.url),
'utf8',
);
const correctWordApiSource = await readFile(
new URL('../src/apis/module/correctWord.js', import.meta.url),
'utf8',
);
test('address-book permission state consistently uses the target device MAC', () => {
assert.match(
addressBookSource,
/:value="selectedPermissions\.includes\(device\.deviceId\)"/,
);
assert.match(
addressBookSource,
/@change="\(checked\) => handlePermissionToggle\(device\.deviceId, checked\)"/,
);
assert.match(
addressBookSource,
/this\.selectedPermissions = this\.allDevices\.map\(d => d\.deviceId\)/,
);
assert.match(
addressBookSource,
/this\.originalPermissions\.includes\(device\.deviceId\)/,
);
assert.doesNotMatch(
addressBookSource,
/selectedPermissions\.includes\(device\.id\)/,
);
assert.doesNotMatch(
addressBookSource,
/originalPermissions\.includes\(device\.id\)/,
);
assert.match(
addressBookSource,
/requestId !== this\.permissionRequestSequence/,
);
assert.match(
addressBookSource,
/this\.selectedDevice\?\.deviceId !== macAddress/,
);
assert.match(
addressBookSource,
/this\.permissionsLoading = true;\s*this\.selectedPermissions = \[\];\s*this\.originalPermissions = \[\];/,
);
assert.match(
addressBookSource,
/handleSavePermissions\(\) \{\s*if \(this\.permissionsLoading\) return;/,
);
});
test('correct-word pagination maps the UI page size to the backend limit query', () => {
assert.match(
correctWordApiSource,
/new URLSearchParams\(\{\s*page: params\.page,\s*limit: params\.pageSize\s*\}\)/,
);
assert.doesNotMatch(correctWordApiSource, /pageSize: params\.pageSize/);
});
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const timbreApiSource = await readFile(
new URL('../src/apis/module/timbre.js', import.meta.url),
'utf8',
);
test('timbre update sends the current sort value to the backend', () => {
const updateStart = timbreApiSource.indexOf('updateVoice(params, callback)');
assert.notEqual(updateStart, -1);
const updateSource = timbreApiSource.slice(updateStart);
assert.match(updateSource, /\.method\('PUT'\)/);
assert.match(updateSource, /sort:\s*params\.sort/);
});
+1 -2
View File
@@ -286,8 +286,7 @@ Intent:
# play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放
# 如果用了hass_play_music,就不要开启play_music,两者只留一个
- play_music
#- hass_get_state
#- hass_set_state
#- hass_state
#- hass_play_music
Memory:
+19 -2
View File
@@ -35,7 +35,7 @@ from core.providers.asr.dto.dto import InterfaceType
from core.handle.textHandle import handleTextMessage
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 plugins_func.register import Action, ActionResponse, all_function_registry, module_func_map
from core.auth import AuthenticationError
from config.config_loader import get_private_config_from_api
from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType
@@ -878,10 +878,27 @@ class ConnectionHandler:
plugin_from_server = private_config.get("plugins", {})
for plugin, config_str in plugin_from_server.items():
plugin_from_server[plugin] = json.loads(config_str)
# 将模块级别的插件配置复制到各具体函数名,
# 方便后续 per-function 查找描述、news_sources 等配置
for module_name, func_names in module_func_map.items():
if module_name in plugin_from_server:
module_config = plugin_from_server[module_name]
for func_name in func_names:
if func_name not in plugin_from_server:
plugin_from_server[func_name] = module_config
self.config["plugins"] = plugin_from_server
# 将模块级别的插件名展开为具体函数名
expanded_functions = []
for plugin_key in plugin_from_server.keys():
if plugin_key in all_function_registry:
expanded_functions.append(plugin_key)
elif plugin_key in module_func_map:
expanded_functions.extend(module_func_map[plugin_key])
else:
expanded_functions.append(plugin_key)
self.config["Intent"][self.config["selected_module"]["Intent"]][
"functions"
] = plugin_from_server.keys()
] = expanded_functions
if private_config.get("prompt", None) is not None:
self.config["prompt"] = private_config["prompt"]
# 获取声纹信息
+79 -18
View File
@@ -22,39 +22,45 @@ from config.manage_api_client import report as manage_report
TAG = __name__
async def report(conn: "ConnectionHandler", type, text, opus_data, report_time):
async def report(conn: "ConnectionHandler", chat_type, text, audio_data, report_time):
"""执行聊天记录上报操作
Args:
conn: 连接对象
type: 上报类型,1为用户2为智能体3为工具调用
chat_type: 上报类型,1为用户(ASR/PCM)2为智能体(TTS/Opus)3为工具调用
text: 合成文本
opus_data: opus音频数据
audio_data: 音频数据chat_type=1时为PCM格式,chat_type=2时为Opus格式)
report_time: 上报时间
"""
try:
if opus_data:
audio_data = opus_to_wav(conn, opus_data)
if audio_data:
if chat_type == 1:
wav_data = pcm_to_wav(conn, audio_data)
elif chat_type == 2:
wav_data = opus_to_wav(conn, audio_data)
else:
audio_data = None
wav_data = None
else:
wav_data = None
# 执行异步上报
await manage_report(
mac_address=conn.device_id,
session_id=conn.session_id,
chat_type=type,
chat_type=chat_type,
content=text,
audio=audio_data,
audio=wav_data,
report_time=report_time,
)
except Exception as e:
conn.logger.bind(tag=TAG).error(f"聊天记录上报失败: {e}")
def opus_to_wav(conn: "ConnectionHandler", pcm_data):
def pcm_to_wav(conn: "ConnectionHandler", pcm_data):
"""将PCM数据转换为WAV格式的字节流
Args:
output_dir: 输出目录(保留参数以保持接口兼容)
conn: 连接对象
pcm_data: PCM音频数据(可能是列表或bytes)
Returns:
@@ -96,11 +102,62 @@ def opus_to_wav(conn: "ConnectionHandler", pcm_data):
raise
def opus_to_wav(conn: "ConnectionHandler", opus_data):
"""将Opus数据转换为WAV格式的字节流
Args:
conn: 连接对象
opus_data: Opus音频数据(可能是列表或bytes)
Returns:
bytes: WAV格式的音频数据
"""
decoder = None
try:
decoder = opuslib_next.Decoder(16000, 1)
pcm_data = []
if isinstance(opus_data, list):
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960)
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
conn.logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
elif isinstance(opus_data, bytes):
pcm_frame = decoder.decode(opus_data, 960)
pcm_data.append(pcm_frame)
if not pcm_data:
raise ValueError("没有有效的音频数据")
pcm_data_bytes = b"".join(pcm_data)
wav_header = bytearray()
wav_header.extend(b"RIFF")
wav_header.extend((36 + len(pcm_data_bytes)).to_bytes(4, "little"))
wav_header.extend(b"WAVE")
wav_header.extend(b"fmt ")
wav_header.extend((16).to_bytes(4, "little"))
wav_header.extend((1).to_bytes(2, "little"))
wav_header.extend((1).to_bytes(2, "little"))
wav_header.extend((16000).to_bytes(4, "little"))
wav_header.extend((32000).to_bytes(4, "little"))
wav_header.extend((2).to_bytes(2, "little"))
wav_header.extend((16).to_bytes(2, "little"))
wav_header.extend(b"data")
wav_header.extend(len(pcm_data_bytes).to_bytes(4, "little"))
return bytes(wav_header) + pcm_data_bytes
finally:
if decoder is not None:
try:
del decoder
except Exception as e:
conn.logger.bind(tag=TAG).debug(f"释放decoder资源时出错: {e}")
def enqueue_tts_report(conn: "ConnectionHandler", text, opus_data):
if not conn.read_config_from_api or conn.need_bind or not conn.report_tts_enable:
return
if conn.chat_history_conf == 0:
return
"""将TTS数据加入上报队列
Args:
@@ -108,6 +165,10 @@ def enqueue_tts_report(conn: "ConnectionHandler", text, opus_data):
text: 合成文本
opus_data: opus音频数据
"""
if not conn.read_config_from_api or conn.need_bind or not conn.report_tts_enable:
return
if conn.chat_history_conf == 0:
return
try:
# 使用连接对象的队列,传入文本和二进制数据而非文件路径
if conn.chat_history_conf == 2:
@@ -164,10 +225,6 @@ def enqueue_tool_report(conn: "ConnectionHandler", tool_name: str, tool_input: d
def enqueue_asr_report(conn: "ConnectionHandler", text, opus_data):
if not conn.read_config_from_api or conn.need_bind or not conn.report_asr_enable:
return
if conn.chat_history_conf == 0:
return
"""将ASR数据加入上报队列
Args:
@@ -175,6 +232,10 @@ def enqueue_asr_report(conn: "ConnectionHandler", text, opus_data):
text: 合成文本
opus_data: opus音频数据
"""
if not conn.read_config_from_api or conn.need_bind or not conn.report_asr_enable:
return
if conn.chat_history_conf == 0:
return
try:
# 使用连接对象的队列,传入文本和二进制数据而非文件路径
if conn.chat_history_conf == 2:
@@ -6,7 +6,7 @@ from typing import Dict, Any, TYPE_CHECKING
if TYPE_CHECKING:
from core.connection import ConnectionHandler
from ..base import ToolType, ToolDefinition, ToolExecutor
from plugins_func.register import all_function_registry, Action, ActionResponse
from plugins_func.register import all_function_registry, module_func_map, Action, ActionResponse
class ServerPluginExecutor(ToolExecutor):
@@ -54,6 +54,43 @@ class ServerPluginExecutor(ToolExecutor):
response=str(e),
)
def _expand_plugin_names(self, config_functions):
"""将模块级别的插件名展开为具体函数名。
在一个 function 文件中可能注册多个 @register_function
配置中如果使用的是模块名(文件名),需要展开为具体的函数名列表。
"""
if not isinstance(config_functions, list):
try:
config_functions = list(config_functions)
except TypeError:
return []
expanded = []
for name in config_functions:
if name in module_func_map:
# 模块名,展开为该模块下所有注册函数名
expanded.extend(module_func_map[name])
elif name in all_function_registry:
# 精确匹配函数名,直接保留
expanded.append(name)
else:
# 未知名称,保留原值(可能是 MCP 或其他工具)
expanded.append(name)
return expanded
def _get_plugin_description(self, func_name):
"""获取插件函数的描述,优先精确匹配函数名,其次匹配模块名。"""
plugins = self.config.get("plugins", {})
# 精确匹配函数名
if func_name in plugins:
return plugins[func_name].get("description", "")
# 通过 module_func_map 反向查找模块名
for module_name, func_names in module_func_map.items():
if func_name in func_names and module_name in plugins:
return plugins[module_name].get("description", "")
return ""
def get_tools(self) -> Dict[str, ToolDefinition]:
"""获取所有注册的服务端插件工具"""
tools = {}
@@ -66,12 +103,8 @@ class ServerPluginExecutor(ToolExecutor):
self.config["selected_module"]["Intent"]
].get("functions", [])
# 转换为列表
if not isinstance(config_functions, list):
try:
config_functions = list(config_functions)
except TypeError:
config_functions = []
# 将模块级别的插件名展开为具体函数名(兜底机制)
config_functions = self._expand_plugin_names(config_functions)
# 合并所有需要的函数
all_required_functions = list(set(necessary_functions + config_functions))
@@ -79,12 +112,8 @@ class ServerPluginExecutor(ToolExecutor):
for func_name in all_required_functions:
func_item = all_function_registry.get(func_name)
if func_item:
# 从函数注册中获取描述
fun_description = (
self.config.get("plugins", {})
.get(func_name, {})
.get("description", "")
)
# 从函数注册中获取描述(支持模块名和函数名的双层查找)
fun_description = self._get_plugin_description(func_name)
if fun_description is not None and len(fun_description) > 0:
if "function" in func_item.description and isinstance(
func_item.description["function"], dict
@@ -1,98 +0,0 @@
import httpx
from config.logger import setup_logging
from plugins_func.functions.hass_init import initialize_hass_handler
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from core.connection import ConnectionHandler
TAG = __name__
logger = setup_logging()
hass_get_state_function_desc = {
"type": "function",
"function": {
"name": "hass_get_state",
"description": "获取homeassistant里设备的状态,包括查询灯光亮度、颜色、色温,媒体播放器的音量,设备的暂停、继续操作",
"parameters": {
"type": "object",
"properties": {
"entity_id": {
"type": "string",
"description": "需要操作的设备id,homeassistant里的entity_id",
}
},
"required": ["entity_id"],
},
},
}
@register_function("hass_get_state", hass_get_state_function_desc, ToolType.SYSTEM_CTL)
async def hass_get_state(conn: "ConnectionHandler", entity_id=""):
try:
ha_response = await handle_hass_get_state(conn, entity_id)
return ActionResponse(Action.REQLLM, ha_response, None)
except httpx.TimeoutException:
logger.bind(tag=TAG).error("获取Home Assistant状态超时")
return ActionResponse(Action.ERROR, "请求超时", None)
except Exception as e:
error_msg = f"执行Home Assistant操作失败"
logger.bind(tag=TAG).error(error_msg)
return ActionResponse(Action.ERROR, error_msg, None)
async def handle_hass_get_state(conn: "ConnectionHandler", entity_id):
ha_config = initialize_hass_handler(conn)
api_key = ha_config.get("api_key")
base_url = ha_config.get("base_url")
url = f"{base_url}/api/states/{entity_id}"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client:
response = await client.get(url, headers=headers)
if response.status_code == 200:
responsetext = "设备状态:" + response.json()["state"] + " "
logger.bind(tag=TAG).info(f"api返回内容: {response.json()}")
if "media_title" in response.json()["attributes"]:
responsetext = (
responsetext
+ "正在播放的是:"
+ str(response.json()["attributes"]["media_title"])
+ " "
)
if "volume_level" in response.json()["attributes"]:
responsetext = (
responsetext
+ "音量是:"
+ str(response.json()["attributes"]["volume_level"])
+ " "
)
if "color_temp_kelvin" in response.json()["attributes"]:
responsetext = (
responsetext
+ "色温是:"
+ str(response.json()["attributes"]["color_temp_kelvin"])
+ " "
)
if "rgb_color" in response.json()["attributes"]:
responsetext = (
responsetext
+ "rgb颜色是:"
+ str(response.json()["attributes"]["rgb_color"])
+ " "
)
if "brightness" in response.json()["attributes"]:
responsetext = (
responsetext
+ "亮度是:"
+ str(response.json()["attributes"]["brightness"])
+ " "
)
logger.bind(tag=TAG).info(f"查询返回内容: {responsetext}")
return responsetext
else:
return f"切换失败,错误码: {response.status_code}"
@@ -16,7 +16,7 @@ def append_devices_to_prompt(conn):
config_source = (
"home_assistant"
if plugins_config.get("home_assistant")
else "hass_get_state"
else "hass_state"
)
if "hass_get_state" in funcs or "hass_set_state" in funcs:
@@ -36,7 +36,7 @@ def initialize_hass_handler(conn):
plugins_config = conn.config.get("plugins", {})
# 确定配置来源
config_source = (
"home_assistant" if plugins_config.get("home_assistant") else "hass_get_state"
"home_assistant" if plugins_config.get("home_assistant") else "hass_state"
)
if not plugins_config.get(config_source):
return ha_config
@@ -10,6 +10,24 @@ if TYPE_CHECKING:
TAG = __name__
logger = setup_logging()
hass_get_state_function_desc = {
"type": "function",
"function": {
"name": "hass_get_state",
"description": "获取homeassistant里设备的状态,包括查询灯光亮度、颜色、色温,媒体播放器的音量,设备的暂停、继续操作",
"parameters": {
"type": "object",
"properties": {
"entity_id": {
"type": "string",
"description": "需要操作的设备id,homeassistant里的entity_id",
}
},
"required": ["entity_id"],
},
},
}
hass_set_state_function_desc = {
"type": "function",
"function": {
@@ -52,6 +70,20 @@ hass_set_state_function_desc = {
}
@register_function("hass_get_state", hass_get_state_function_desc, ToolType.SYSTEM_CTL)
async def hass_get_state(conn: "ConnectionHandler", entity_id=""):
try:
ha_response = await handle_hass_get_state(conn, entity_id)
return ActionResponse(Action.REQLLM, ha_response, None)
except httpx.TimeoutException:
logger.bind(tag=TAG).error("获取Home Assistant状态超时")
return ActionResponse(Action.ERROR, "请求超时", None)
except Exception as e:
error_msg = f"执行Home Assistant操作失败"
logger.bind(tag=TAG).error(error_msg)
return ActionResponse(Action.ERROR, error_msg, None)
@register_function("hass_set_state", hass_set_state_function_desc, ToolType.SYSTEM_CTL)
async def hass_set_state(conn: "ConnectionHandler", entity_id="", state=None):
if state is None:
@@ -68,6 +100,61 @@ async def hass_set_state(conn: "ConnectionHandler", entity_id="", state=None):
return ActionResponse(Action.ERROR, error_msg, None)
async def handle_hass_get_state(conn: "ConnectionHandler", entity_id):
ha_config = initialize_hass_handler(conn)
api_key = ha_config.get("api_key")
base_url = ha_config.get("base_url")
url = f"{base_url}/api/states/{entity_id}"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client:
response = await client.get(url, headers=headers)
if response.status_code == 200:
responsetext = "设备状态:" + response.json()["state"] + " "
logger.bind(tag=TAG).info(f"api返回内容: {response.json()}")
if "media_title" in response.json()["attributes"]:
responsetext = (
responsetext
+ "正在播放的是:"
+ str(response.json()["attributes"]["media_title"])
+ " "
)
if "volume_level" in response.json()["attributes"]:
responsetext = (
responsetext
+ "音量是:"
+ str(response.json()["attributes"]["volume_level"])
+ " "
)
if "color_temp_kelvin" in response.json()["attributes"]:
responsetext = (
responsetext
+ "色温是:"
+ str(response.json()["attributes"]["color_temp_kelvin"])
+ " "
)
if "rgb_color" in response.json()["attributes"]:
responsetext = (
responsetext
+ "rgb颜色是:"
+ str(response.json()["attributes"]["rgb_color"])
+ " "
)
if "brightness" in response.json()["attributes"]:
responsetext = (
responsetext
+ "亮度是:"
+ str(response.json()["attributes"]["brightness"])
+ " "
)
logger.bind(tag=TAG).info(f"查询返回内容: {responsetext}")
return responsetext
else:
return f"切换失败,错误码: {response.status_code}"
async def handle_hass_set_state(conn: "ConnectionHandler", entity_id, state):
ha_config = initialize_hass_handler(conn)
api_key = ha_config.get("api_key")
@@ -78,6 +78,8 @@ class DeviceTypeRegistry:
# 初始化函数注册字典
all_function_registry = {}
# 模块名 -> 函数名列表的映射,用于将模块级别的插件名展开为具体的函数名
module_func_map = {}
def register_function(name, desc, type=None):
@@ -85,6 +87,9 @@ def register_function(name, desc, type=None):
def decorator(func):
all_function_registry[name] = FunctionItem(name, desc, func, type)
# 记录模块名到函数名的映射,用于 expand 模块级别的插件配置
module_name = func.__module__.split(".")[-1]
module_func_map.setdefault(module_name, []).append(name)
logger.bind(tag=TAG).debug(f"函数 '{name}' 已加载,可以注册使用")
return func