Compare commits

...
31 Commits
Author SHA1 Message Date
hrzandGitHub 83f86406b5 update:更新固件下载源码路径 (#923) 2025-04-21 21:08:15 +08:00
dde237ff0d update:增加dashscope依赖 (#920)
* update:增加腾讯TTS、腾讯ASR供应器

* update:增加dashscope依赖

---------

Co-authored-by: 欣南科技 <huangrongzhuang@xin-nan.com>
2025-04-21 17:55:27 +08:00
hrzandGitHub 6a4887e252 Merge pull request #916 from laride/fix/empty-chunk
处理 OpenAI 格式响应中 `choices` 为空的 chunk
2025-04-21 17:41:04 +08:00
d945b94910 update:增加腾讯TTS、腾讯ASR供应器 (#918)
Co-authored-by: 欣南科技 <huangrongzhuang@xin-nan.com>
2025-04-21 17:35:10 +08:00
larideandGitHub 16a7aeec3b fix: handle empty choices in some chunk of OpenAI streaming response 2025-04-21 14:24:47 +08:00
Sakura-RanChenandGitHub 94605345e5 Merge pull request #914 from xinnan-tech/web-table-loading
Web table loading
2025-04-21 11:40:47 +08:00
CGD ec9fa32c81 update:添加多个组件"保存"时的loading状态 2025-04-21 11:36:10 +08:00
CGDandGitHub 264aa15281 Merge pull request #913 from xinnan-tech/web-role-exit
增加“角色配置”页面返回按钮,智能体管理骨架屏加载显示
2025-04-21 11:26:39 +08:00
Ran_Chen 180a6ff824 增加“角色配置”页面返回按钮,智能体管理骨架屏加载显示 2025-04-21 10:24:04 +08:00
CGD 5904ee1571 update:添加“模型配置”、“用户管理”、“参数管理”页面“获取列表”时的加载状态 2025-04-21 10:02:50 +08:00
hrzandGitHub bc99341f85 Merge pull request #909 from xinnan-tech/ota-activate
update:输入验证码后设备端快速刷新结果
2025-04-20 21:15:00 +08:00
hrzandGitHub 8b9174b67e update:绑定设备时,板子类型优先获取品牌商版名称 (#903)
* update:Server端兼容老设备缺失"client-id"请求头

* update:优化iot日志

* update:绑定设备时,板子类型优先获取品牌商版名称

* update:更新版本号
2025-04-20 01:40:21 +08:00
hrzandGitHub 6c04a6cf69 update:Server端兼容老设备缺失"client-id"请求头 (#902)
* update:Server端兼容老设备缺失"client-id"请求头

* update:优化iot日志

* update:绑定设备时,板子类型优先获取品牌商版名称
2025-04-20 01:28:01 +08:00
hrzandGitHub 14a63c4b97 update:补齐ddos攻击提示音 (#896) 2025-04-19 13:24:03 +08:00
hrzandGitHub 098d13a34b update:更新0.3.7 (#892) 2025-04-19 00:11:12 +08:00
488f247744 update:增加单台设备每天最多聊天字数,防止被ddos
* 添加清空redis所有库的接口
--AdminController.java 添加了清除所有的接口
--RedisUtils.java 添加了清除redis所有key的方法,redisTemplate提供的清空方法已经被标记为弃用了,所有选择用执行lua脚本方式

* fix:修复使用本地配置时忘记附带提示词

* fix:修复意图识别插件名称格式bug

* update:版本升级后强制刷新redis

* update:增加单台设备每天最多聊天字数,防止被ddos

---------

Co-authored-by: 剑雨 <2375294554@qq.com>
2025-04-18 23:39:56 +08:00
hrzandGitHub c74dbf03cd fix:修复意图识别插件名称格式bug (#885)
* fix:修复使用本地配置时忘记附带提示词

* fix:修复意图识别插件名称格式bug
2025-04-18 16:39:18 +08:00
hrzandGitHub 1f836c3235 fix:修复使用本地配置时忘记附带提示词 (#880) 2025-04-18 11:38:31 +08:00
hrzandGitHub bd2e2e77d5 update:更新版本号 (#874) 2025-04-18 00:51:09 +08:00
6282ef14e8 fix:修复第一句话是默认配置的bug (#873)
* update: 增加对httpClient的统一管理

* update: 增加retry机制

* update: 增加retry机制

* fix:websocket连接后不说话的备用关闭方法

* fix:修复第一句话是默认配置的bug

---------

Co-authored-by: haotian <haotian@codemao.cn>
Co-authored-by: GoodyHao <865700600@qq.com>
2025-04-18 00:37:40 +08:00
558f23688f fix:websocket连接后不说话的备用关闭方法 (#872)
* update: 增加对httpClient的统一管理

* update: 增加retry机制

* update: 增加retry机制

* fix:websocket连接后不说话的备用关闭方法

---------

Co-authored-by: haotian <haotian@codemao.cn>
Co-authored-by: GoodyHao <865700600@qq.com>
2025-04-17 23:57:04 +08:00
CGDandGitHub 541f2de599 Merge pull request #856 from xinnan-tech/web-page-check
解决切换页面样式变化的问题
2025-04-17 13:26:26 +08:00
hrzandGitHub 68116254cd dify、coze对话模式支持functioncall
Function call v2
2025-04-17 11:54:04 +08:00
hrzandGitHub 77ff4599ea Merge branch 'main' into function-call-v2 2025-04-17 11:53:25 +08:00
玄凤科技 652f5a3247 Merge branch 'function-call-v2' of https://github.com/xinnan-tech/xiaozhi-esp32-server into function-call-v2 2025-04-17 10:00:23 +08:00
玄凤科技 9b6e57b143 修改提示词,增强tool调用约束 2025-04-17 10:00:13 +08:00
Ran_Chen bdc19256bf 解决切换页面样式变化的问题 2025-04-17 09:34:42 +08:00
hrz 9fc164a69d udpate:增加iot消息properties和methods可能为空的情况 2025-04-16 23:30:46 +08:00
玄凤科技 208c045d3d 触发function call后,清空回复队列 2025-04-16 11:48:40 +08:00
玄凤科技 8c7d129089 同步coze llm 2025-04-16 08:58:55 +08:00
玄凤科技 a3a9b98a1d 增加系统提示词,支持dify使用function call 2025-04-16 08:56:23 +08:00
48 changed files with 1227 additions and 392 deletions
+1
View File
@@ -158,6 +158,7 @@ my_wakeup_words.mp3
!main/xiaozhi-server/config/assets/bind_code.wav !main/xiaozhi-server/config/assets/bind_code.wav
!main/xiaozhi-server/config/assets/bind_not_found.wav !main/xiaozhi-server/config/assets/bind_not_found.wav
!main/xiaozhi-server/config/assets/bind_code/*.wav !main/xiaozhi-server/config/assets/bind_code/*.wav
!main/xiaozhi-server/config/assets/max_output_size.wav
main/manager-api/.vscode main/manager-api/.vscode
# Ignore webpack cache directory # Ignore webpack cache directory
+10 -1
View File
@@ -4,7 +4,16 @@
先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf) 先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
## 第2步 打开配置文件 ## 第2步 打开配置文件
配置好编译环境后,下载虾哥[xiaozhi-esp32](https://github.com/78/xiaozhi-esp32)项目源码,进入虾哥项目,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。 配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
下载后,解压缩包,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
## 第3步 修改WEBSOCKET地址 ## 第3步 修改WEBSOCKET地址
找到`WEBSOCKET_URL``default`的内容,把`wss://api.tenclass.net/xiaozhi/v1/` 找到`WEBSOCKET_URL``default`的内容,把`wss://api.tenclass.net/xiaozhi/v1/`
@@ -163,4 +163,9 @@ public interface Constant {
return value; return value;
} }
} }
/**
* 版本号
*/
public static final String VERSION = "0.3.9";
} }
@@ -75,4 +75,11 @@ public class RedisKeys {
public static String getTimbreDetailsKey(String id) { public static String getTimbreDetailsKey(String id) {
return "timbre:details:" + id; return "timbre:details:" + id;
} }
/**
* 获取版本号Key
*/
public static String getVersionKey() {
return "system:version";
}
} }
@@ -1,11 +1,14 @@
package xiaozhi.common.redis; package xiaozhi.common.redis;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -27,7 +30,7 @@ public class RedisUtils {
/** /**
* 过期时长为1小时,单位:秒 * 过期时长为1小时,单位:秒
*/ */
public final static long HOUR_ONE_EXPIRE = 60 * 60 * 1L; public final static long HOUR_ONE_EXPIRE = (long) 60 * 60;
/** /**
* 过期时长为6小时,单位:秒 * 过期时长为6小时,单位:秒
*/ */
@@ -124,4 +127,24 @@ public class RedisUtils {
public Object rightPop(String key) { public Object rightPop(String key) {
return redisTemplate.opsForList().rightPop(key); return redisTemplate.opsForList().rightPop(key);
} }
/**
* 清空所有 Redis 数据库中的所有键
*/
public void emptyAll() {
// Lua 脚本 FLUSHALL是redis清空所有库的命令
String luaScript ="redis.call('FLUSHALL')";
// 创建 DefaultRedisScript 对象
DefaultRedisScript<Void> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(luaScript); // 设置 Lua 脚本内容
redisScript.setResultType(Void.class); // 设置返回值类型
// 执行 Lua 脚本
List<String> keys = Collections.emptyList(); // 如果脚本不依赖 key,可以传入空列表
redisTemplate.execute(redisScript, keys);
}
} }
@@ -5,6 +5,9 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.DependsOn;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.config.service.ConfigService; import xiaozhi.modules.config.service.ConfigService;
import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysParamsService;
@@ -18,8 +21,20 @@ public class SystemInitConfig {
@Autowired @Autowired
private ConfigService configService; private ConfigService configService;
@Autowired
private RedisUtils redisUtils;
@PostConstruct @PostConstruct
public void init() { public void init() {
// 检查版本号
String redisVersion = (String) redisUtils.get(RedisKeys.getVersionKey());
if (!Constant.VERSION.equals(redisVersion)) {
// 如果版本不一致,清空Redis
redisUtils.emptyAll();
// 存储新版本号
redisUtils.set(RedisKeys.getVersionKey(), Constant.VERSION);
}
sysParamsService.initServerSecret(); sysParamsService.initServerSecret();
configService.getConfig(false); configService.getConfig(false);
} }
@@ -91,6 +91,7 @@ public class ConfigServiceImpl implements ConfigService {
} }
throw new RenException(ErrorCode.OTA_DEVICE_NOT_FOUND, "not found device"); throw new RenException(ErrorCode.OTA_DEVICE_NOT_FOUND, "not found device");
} }
// 获取智能体信息 // 获取智能体信息
AgentEntity agent = agentService.getAgentById(device.getAgentId()); AgentEntity agent = agentService.getAgentById(device.getAgentId());
if (agent == null) { if (agent == null) {
@@ -104,7 +105,9 @@ public class ConfigServiceImpl implements ConfigService {
} }
// 构建返回数据 // 构建返回数据
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
// 获取单台设备每天最多输出字数
String deviceMaxOutputSize = sysParamsService.getValue("device_max_output_size", true);
result.put("device_max_output_size", deviceMaxOutputSize);
// 如果客户端已实例化模型,则不返回 // 如果客户端已实例化模型,则不返回
String alreadySelectedVadModelId = (String) selectedModule.get("VAD"); String alreadySelectedVadModelId = (String) selectedModule.get("VAD");
if (alreadySelectedVadModelId != null && alreadySelectedVadModelId.equals(agent.getVadModelId())) { if (alreadySelectedVadModelId != null && alreadySelectedVadModelId.equals(agent.getVadModelId())) {
@@ -267,6 +270,12 @@ public class ConfigServiceImpl implements ConfigService {
if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) { if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) {
intentLLMModelId = null; intentLLMModelId = null;
} }
} else if ("function_call".equals(map.get("type"))) {
String functionStr = (String) map.get("functions");
if (StringUtils.isNotBlank(functionStr)) {
String[] functions = functionStr.split("\\;");
map.put("functions", functions);
}
} }
} }
// 如果是LLM类型,且intentLLMModelId不为空,则添加附加模型 // 如果是LLM类型,且intentLLMModelId不为空,则添加附加模型
@@ -282,7 +291,7 @@ public class ConfigServiceImpl implements ConfigService {
result.put("selected_module", selectedModule); result.put("selected_module", selectedModule);
if (StringUtils.isNotBlank(prompt)) { if (StringUtils.isNotBlank(prompt)) {
prompt = prompt.replace("{{assistant_name}}", "小智"); prompt = prompt.replace("{{assistant_name}}", StringUtils.isBlank(assistantName) ? "小智" : assistantName);
} }
result.put("prompt", prompt); result.put("prompt", prompt);
} }
@@ -15,25 +15,29 @@ import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.modules.device.dto.DeviceReportReqDTO; import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO; import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService; import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.device.utils.NetworkUtil; import xiaozhi.modules.device.utils.NetworkUtil;
@Tag(name = "设备管理", description = "OTA 相关接口") @Tag(name = "设备管理", description = "OTA 相关接口")
@Slf4j
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/ota/") @RequestMapping("/ota/")
public class OTAController { public class OTAController {
private final DeviceService deviceService; private final DeviceService deviceService;
@Operation(summary = "检查 OTA 版本和设备激活状态") @Operation(summary = "OTA版本和设备激活状态检查")
@PostMapping @PostMapping
public ResponseEntity<String> checkOTAVersion( public ResponseEntity<String> checkOTAVersion(
@RequestBody DeviceReportReqDTO deviceReportReqDTO, @RequestBody DeviceReportReqDTO deviceReportReqDTO,
@@ -54,9 +58,25 @@ public class OTAController {
return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO)); return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO));
} }
@Operation(summary = "设备快速检查激活状态")
@PostMapping("activate")
public ResponseEntity<String> activateDevice(
@Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Device-Id") String deviceId,
@Parameter(name = "Client-Id", description = "客户端标识", required = false, in = ParameterIn.HEADER) @RequestHeader(value = "Client-Id", required = false) String clientId) {
if (StringUtils.isBlank(deviceId)) {
return ResponseEntity.status(202).build();
}
DeviceEntity device = deviceService.getDeviceByMacAddress(deviceId);
if (device == null) {
return ResponseEntity.status(202).build();
}
return ResponseEntity.ok("success");
}
@GetMapping @GetMapping
public ResponseEntity<String> getOTAPrompt() { @Hidden
return createResponse(DeviceReportRespDTO.createError("请提交正确的ota参数")); public ResponseEntity<String> getOTA() {
return ResponseEntity.ok("OTA接口运行正常");
} }
@SneakyThrows @SneakyThrows
@@ -44,6 +44,8 @@ public class DeviceReportRespDTO {
@Schema(description = "激活码信息: 激活地址") @Schema(description = "激活码信息: 激活地址")
private String message; private String message;
@Schema(description = "挑战码")
private String challenge;
} }
@Getter @Getter
@@ -251,17 +251,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
if (StringUtils.isNotBlank(cachedCode)) { if (StringUtils.isNotBlank(cachedCode)) {
code.setCode(cachedCode); code.setCode(cachedCode);
code.setMessage(frontedUrl + "\n" + cachedCode); code.setMessage(frontedUrl + "\n" + cachedCode);
code.setChallenge(deviceId);
} else { } else {
String newCode = RandomUtil.randomNumbers(6); String newCode = RandomUtil.randomNumbers(6);
code.setCode(newCode); code.setCode(newCode);
code.setMessage(frontedUrl + "\n" + newCode); code.setMessage(frontedUrl + "\n" + newCode);
code.setChallenge(deviceId);
Map<String, Object> dataMap = new HashMap<>(); Map<String, Object> dataMap = new HashMap<>();
dataMap.put("id", deviceId); dataMap.put("id", deviceId);
dataMap.put("mac_address", deviceId); dataMap.put("mac_address", deviceId);
dataMap.put("board", (deviceReport.getChipModelName() != null) ? deviceReport.getChipModelName() dataMap.put("board", (deviceReport.getBoard() != null && deviceReport.getBoard().getType() != null)
: (deviceReport.getBoard() != null ? deviceReport.getBoard().getType() : "unknown")); ? deviceReport.getBoard().getType()
: (deviceReport.getChipModelName() != null ? deviceReport.getChipModelName() : "unknown"));
dataMap.put("app_version", (deviceReport.getApplication() != null) dataMap.put("app_version", (deviceReport.getApplication() != null)
? deviceReport.getApplication().getVersion() ? deviceReport.getApplication().getVersion()
: null); : null);
@@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.TokenDTO; import xiaozhi.common.page.TokenDTO;
@@ -121,7 +122,7 @@ public class LoginController {
@Operation(summary = "公共配置") @Operation(summary = "公共配置")
public Result<Map<String, Object>> pubConfig() { public Result<Map<String, Object>> pubConfig() {
Map<String, Object> config = new HashMap<>(); Map<String, Object> config = new HashMap<>();
config.put("version", "0.3.5"); config.put("version", Constant.VERSION);
config.put("allowUserRegister", sysUserService.getAllowUserRegister()); config.put("allowUserRegister", sysUserService.getAllowUserRegister());
return new Result<Map<String, Object>>().ok(config); return new Result<Map<String, Object>>().ok(config);
} }
@@ -0,0 +1,2 @@
delete from `ai_model_config` where id = 'Intent_function_call';
INSERT INTO `ai_model_config` VALUES ('Intent_function_call', 'Intent', 'function_call', '函数调用意图识别', 0, 1, '{\"type\": \"function_call\", \"functions\": \"change_role;get_weather;get_news;play_music\"}', NULL, NULL, 3, NULL, NULL, NULL, NULL);
@@ -0,0 +1,7 @@
-- 调整意图识别配置
delete from `ai_model_config` where id = 'Intent_function_call';
INSERT INTO `ai_model_config` VALUES ('Intent_function_call', 'Intent', 'function_call', '函数调用意图识别', 0, 1, '{\"type\": \"function_call\", \"functions\": \"change_role;get_weather;get_news;play_music\"}', NULL, NULL, 3, NULL, NULL, NULL, NULL);
-- 增加单台设备每天最多聊天句数
delete from `sys_params` where id = 105;
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (105, 'device_max_output_size', '0', 'number', 1, '单台设备每天最多输出字数,0表示不限制');
@@ -0,0 +1,19 @@
-- 删除无用模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_LLM_doubao';
delete from `ai_model_provider` where id = 'SYSTEM_LLM_chatglm';
delete from `ai_model_provider` where id = 'SYSTEM_TTS_302ai';
delete from `ai_model_provider` where id = 'SYSTEM_TTS_gizwits';
-- 添加模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_ASR_TencentASR';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_TencentASR', 'ASR', 'tencent', '腾讯语音识别', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 4, 1, NOW(), 1, NOW());
-- 添加腾讯语音合成模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_TTS_TencentTTS';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_TTS_TencentTTS', 'TTS', 'tencent', '腾讯语音合成', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"region","label":"区域","type":"string"},{"key":"voice","label":"音色ID","type":"string"}]', 5, 1, NOW(), 1, NOW());
-- 添加腾讯语音合成音色
delete from `ai_tts_voice` where id = 'TTS_TencentTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_TencentTTS0001', 'TTS_TencentTTS', '智瑜', '101001', '中文', NULL, NULL, 1, NULL, NULL, NULL, NULL);
@@ -58,3 +58,17 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202504151206.sql path: classpath:db/changelog/202504151206.sql
- changeSet:
id: 202504181536
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504181536.sql
- changeSet:
id: 202504211118
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504211118.sql
@@ -77,7 +77,12 @@
</div> </div>
<div style="display: flex;justify-content: center;"> <div style="display: flex;justify-content: center;">
<el-button type="primary" @click="confirm" class="save-btn"> <el-button
type="primary"
@click="confirm"
class="save-btn"
:loading="saving"
:disabled="saving">
保存 保存
</el-button> </el-button>
</div> </div>
@@ -94,6 +99,7 @@ export default {
}, },
data() { data() {
return { return {
saving: false,
providers: [], providers: [],
dialogVisible: false, dialogVisible: false,
providersLoaded: false, providersLoaded: false,
@@ -175,6 +181,7 @@ export default {
}, },
handleClose() { handleClose() {
this.saving = false;
this.$emit('update:visible', false); this.$emit('update:visible', false);
}, },
initDynamicConfig() { initDynamicConfig() {
@@ -185,8 +192,11 @@ export default {
this.formData.configJson = newConfig; this.formData.configJson = newConfig;
}, },
confirm() { confirm() {
this.saving = true;
if (!this.formData.supplier) { if (!this.formData.supplier) {
this.$message.error('请选择供应器'); this.$message.error('请选择供应器');
this.saving = false;
return; return;
} }
@@ -206,11 +216,18 @@ export default {
} }
}; };
this.$emit('confirm', submitData); try {
this.$emit('update:visible', false); this.$emit('confirm', submitData);
this.resetForm(); this.$emit('update:visible', false);
this.resetForm();
} catch (e) {
console.error(e);
} finally {
this.saving = false;
}
}, },
resetForm() { resetForm() {
this.saving = false;
this.formData = { this.formData = {
modelName: '', modelName: '',
modelCode: '', modelCode: '',
@@ -23,12 +23,6 @@
<div class="settings-btn" @click="handleConfigure"> <div class="settings-btn" @click="handleConfigure">
配置角色 配置角色
</div> </div>
<!-- <div class="settings-btn">-->
<!-- 声纹识别-->
<!-- </div>-->
<!-- <div class="settings-btn">-->
<!-- 历史对话-->
<!-- </div>-->
<div class="settings-btn" @click="handleDeviceManage"> <div class="settings-btn" @click="handleDeviceManage">
设备管理({{ device.deviceCount }}) 设备管理({{ device.deviceCount }})
</div> </div>
@@ -80,7 +74,7 @@ export default {
.settings-btn { .settings-btn {
font-weight: 500; font-weight: 500;
font-size: 10px; font-size: 12px;
color: #5778ff; color: #5778ff;
background: #e6ebff; background: #e6ebff;
width: auto; width: auto;
@@ -95,7 +89,7 @@ export default {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-top: 15px; margin-top: 15px;
font-size: 10px; font-size: 12px;
color: #979db1; color: #979db1;
font-weight: 400; font-weight: 400;
} }
@@ -170,7 +170,7 @@ export default {
.header { .header {
background: #f6fcfe66; background: #f6fcfe66;
border: 1px solid #fff; border: 1px solid #fff;
height: 53px !important; height: 63px !important;
min-width: 900px; min-width: 900px;
/* 设置最小宽度防止过度压缩 */ /* 设置最小宽度防止过度压缩 */
overflow: hidden; overflow: hidden;
@@ -197,7 +197,7 @@ export default {
} }
.brand-img { .brand-img {
height: 18px; height: 20px;
} }
.header-center { .header-center {
@@ -219,13 +219,13 @@ export default {
.equipment-management { .equipment-management {
padding: 0 9px; padding: 0 9px;
width: 82px; width: px;
height: 24px; height: 30px;
border-radius: 12px; border-radius: 15px;
background: #deeafe; background: #deeafe;
display: flex; display: flex;
justify-content: center; justify-content: center;
font-size: 10px; font-size: 14px;
font-weight: 500; font-weight: 500;
gap: 7px; gap: 7px;
color: #3d4566; color: #3d4566;
@@ -235,6 +235,7 @@ export default {
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
/* 防止导航按钮被压缩 */ /* 防止导航按钮被压缩 */
padding: 0px 15px;
} }
.equipment-management.active-tab { .equipment-management.active-tab {
@@ -76,7 +76,12 @@
</div> </div>
<div style="display: flex;justify-content: center;"> <div style="display: flex;justify-content: center;">
<el-button type="primary" @click="handleSave" class="save-btn"> <el-button
type="primary"
@click="handleSave"
class="save-btn"
:loading="saving"
:disabled="saving">
保存 保存
</el-button> </el-button>
</div> </div>
@@ -102,6 +107,7 @@ export default {
dialogVisible: this.visible, dialogVisible: this.visible,
providers: [], providers: [],
providersLoaded: false, providersLoaded: false,
saving: false,
allProvidersData: null, allProvidersData: null,
pendingProviderType: null, pendingProviderType: null,
pendingModelData: null, pendingModelData: null,
@@ -195,6 +201,8 @@ export default {
} }
}, },
handleSave() { handleSave() {
this.saving = true; // 开始保存加载
const provideCode = this.form.configJson.type; const provideCode = this.form.configJson.type;
const formData = { const formData = {
id: this.modelData.id, id: this.modelData.id,
@@ -209,8 +217,19 @@ export default {
...this.form.configJson, ...this.form.configJson,
} }
}; };
this.$emit("save", { provideCode, formData });
this.dialogVisible = false; this.$emit("save", {
provideCode,
formData,
done: () => {
this.saving = false; // 保存完成后回调
}
});
// 如果父组件不处理done回调,3秒后自动关闭加载状态
setTimeout(() => {
this.saving = false;
}, 3000);
}, },
loadProviders() { loadProviders() {
if (this.providersLoaded) return; if (this.providersLoaded) return;
@@ -40,7 +40,12 @@
</el-form> </el-form>
<div class="dialog-footer"> <div class="dialog-footer">
<el-button type="primary" @click="submit" class="save-btn"> <el-button
type="primary"
@click="submit"
class="save-btn"
:loading="saving"
:disabled="saving">
保存 保存
</el-button> </el-button>
<el-button @click="cancel" class="cancel-btn"> <el-button @click="cancel" class="cancel-btn">
@@ -76,6 +81,7 @@ export default {
data() { data() {
return { return {
dialogKey: Date.now(), dialogKey: Date.now(),
saving: false,
valueTypeOptions: [ valueTypeOptions: [
{ value: 'string', label: '字符串(string)' }, { value: 'string', label: '字符串(string)' },
{ value: 'number', label: '数字(number)' }, { value: 'number', label: '数字(number)' },
@@ -100,11 +106,22 @@ export default {
submit() { submit() {
this.$refs.form.validate((valid) => { this.$refs.form.validate((valid) => {
if (valid) { if (valid) {
this.$emit('submit', this.form); this.saving = true; // 开始加载
this.$emit('submit', {
form: this.form,
done: () => {
this.saving = false; // 加载完成
}
});
setTimeout(() => {
this.saving = false;
}, 3000);
} }
}); });
}, },
cancel() { cancel() {
this.saving = false; // 取消时重置状态
this.$emit('cancel'); this.$emit('cancel');
} }
}, },
+72 -42
View File
@@ -44,12 +44,22 @@
<!-- 右侧内容 --> <!-- 右侧内容 -->
<div class="content-area"> <div class="content-area">
<el-card class="model-card" shadow="never"> <el-card class="model-card" shadow="never">
<el-table ref="modelTable" style="width: 100%" :header-cell-style="{ background: 'transparent' }" <el-table
:data="modelList" class="data-table" header-row-class-name="table-header" ref="modelTable"
:header-cell-class-name="headerCellClassName" @selection-change="handleSelectionChange"> style="width: 100%"
v-loading="loading"
element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-style="{ background: 'transparent' }"
:data="modelList"
class="data-table"
header-row-class-name="table-header"
:header-cell-class-name="headerCellClassName"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center"></el-table-column> <el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="模型ID" prop="id" align="center"></el-table-column>
<el-table-column label="模型名称" prop="modelName" align="center"></el-table-column> <el-table-column label="模型名称" prop="modelName" align="center"></el-table-column>
<el-table-column label="模型编码" prop="modelCode" align="center"></el-table-column>
<el-table-column label="提供商" align="center"> <el-table-column label="提供商" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
{{ scope.row.configJson.type || '未知' }} {{ scope.row.configJson.type || '未知' }}
@@ -86,41 +96,37 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="table-footer"> <div class="table-footer">
<div class="batch-actions"> <div class="batch-actions">
<el-button size="mini" type="primary" @click="selectAll"> <el-button size="mini" type="primary" @click="selectAll">
{{ isAllSelected ? {{ isAllSelected ?
'取消全选' : '全选' }} '取消全选' : '全选' }}
</el-button> </el-button>
<el-button type="success" size="mini" @click="addModel" class="add-btn"> <el-button type="success" size="mini" @click="addModel" class="add-btn">
新增 新增
</el-button> </el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete"> <el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">
删除 删除
</el-button> </el-button>
</div> </div>
<div class="custom-pagination"> <div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select"> <el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option <el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
v-for="item in pageSizeOptions"
:key="item"
:label="`${item}条/页`"
:value="item">
</el-option> </el-option>
</el-select> </el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button> <button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button> <button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn" <button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)"> :class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }} {{ page }}
</button> </button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button> <button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
<span class="total-text">{{ total }}条记录</span> <span class="total-text">{{ total }}条记录</span>
</div>
</div> </div>
</div>
</el-card> </el-card>
</div> </div>
</div> </div>
@@ -157,7 +163,8 @@ export default {
pageSize: 10, pageSize: 10,
total: 0, total: 0,
selectedModels: [], selectedModels: [],
isAllSelected: false isAllSelected: false,
loading: false
}; };
}, },
@@ -301,9 +308,10 @@ export default {
this.currentPage = page; this.currentPage = page;
this.$refs.modelTable.clearSelection(); this.$refs.modelTable.clearSelection();
}, },
handleModelSave({ provideCode, formData }) { handleModelSave({ provideCode, formData, done }) {
const modelType = this.activeTab; const modelType = this.activeTab;
const id = formData.id; const id = formData.id;
Api.model.updateModel( Api.model.updateModel(
{ modelType, provideCode, id, formData }, { modelType, provideCode, id, formData },
({ data }) => { ({ data }) => {
@@ -314,6 +322,7 @@ export default {
} else { } else {
this.$message.error(data.msg || '保存失败'); this.$message.error(data.msg || '保存失败');
} }
done && done(); // 调用done回调关闭加载状态
} }
); );
}, },
@@ -385,6 +394,7 @@ export default {
// 获取模型配置列表 // 获取模型配置列表
loadData() { loadData() {
this.loading = true; // 开始加载
const params = { const params = {
modelType: this.activeTab, modelType: this.activeTab,
modelName: this.search, modelName: this.search,
@@ -393,6 +403,7 @@ export default {
}; };
Api.model.getModelList(params, ({ data }) => { Api.model.getModelList(params, ({ data }) => {
this.loading = false; // 结束加载
if (data.code === 0) { if (data.code === 0) {
this.modelList = data.data.list; this.modelList = data.data.list;
this.total = data.data.total; this.total = data.data.total;
@@ -592,12 +603,12 @@ export default {
transition: border-color 0.2s; transition: border-color 0.2s;
} }
::v-deep .page-size-select{ ::v-deep .page-size-select {
width: 100px; width: 100px;
margin-right: 8px; margin-right: 8px;
} }
::v-deep .page-size-select .el-input__inner{ ::v-deep .page-size-select .el-input__inner {
height: 32px; height: 32px;
line-height: 32px; line-height: 32px;
border-radius: 4px; border-radius: 4px;
@@ -606,7 +617,8 @@ export default {
color: #606266; color: #606266;
font-size: 14px; font-size: 14px;
} }
::v-deep .page-size-select .el-input__suffix{
::v-deep .page-size-select .el-input__suffix {
right: 6px; right: 6px;
width: 15px; width: 15px;
height: 20px; height: 20px;
@@ -617,13 +629,14 @@ export default {
border-radius: 4px; border-radius: 4px;
} }
::v-deep .page-size-select .el-input__suffix-inner{ ::v-deep .page-size-select .el-input__suffix-inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
::v-deep .page-size-select .el-icon-arrow-up:before{
::v-deep .page-size-select .el-icon-arrow-up:before {
content: ""; content: "";
display: inline-block; display: inline-block;
border-left: 6px solid transparent; border-left: 6px solid transparent;
@@ -884,7 +897,7 @@ export default {
} }
} }
.model-card{ .model-card {
background: white; background: white;
flex: 1; flex: 1;
display: flex; display: flex;
@@ -894,7 +907,7 @@ export default {
overflow: hidden; overflow: hidden;
} }
.model-card ::v-deep .el-card__body{ .model-card ::v-deep .el-card__body {
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -912,4 +925,21 @@ export default {
overflow-y: auto; overflow-y: auto;
} }
::v-deep .el-loading-mask {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
::v-deep .el-loading-spinner .circular {
width: 28px;
height: 28px;
}
::v-deep .el-loading-spinner .path {
stroke: #6b8cff;
}
::v-deep .el-loading-text {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style> </style>
+81 -47
View File
@@ -15,8 +15,15 @@
<div class="content-panel"> <div class="content-panel">
<div class="content-area"> <div class="content-area">
<el-card class="params-card" shadow="never"> <el-card class="params-card" shadow="never">
<el-table ref="paramsTable" :data="paramsList" class="transparent-table" <el-table
:header-cell-class-name="headerCellClassName"> ref="paramsTable"
:data="paramsList"
class="transparent-table"
v-loading="loading"
element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-class-name="headerCellClassName">
<el-table-column label="选择" align="center" width="120"> <el-table-column label="选择" align="center" width="120">
<template slot-scope="scope"> <template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox> <el-checkbox v-model="scope.row.selected"></el-checkbox>
@@ -92,6 +99,7 @@ export default {
searchCode: "", searchCode: "",
paramsList: [], paramsList: [],
currentPage: 1, currentPage: 1,
loading: false,
pageSize: 10, pageSize: 10,
pageSizeOptions: [10, 20, 50, 100], pageSizeOptions: [10, 20, 50, 100],
total: 0, total: 0,
@@ -138,27 +146,29 @@ export default {
this.fetchParams(); this.fetchParams();
}, },
fetchParams() { fetchParams() {
Api.admin.getParamsList( this.loading = true;
{ Api.admin.getParamsList(
page: this.currentPage, {
limit: this.pageSize, page: this.currentPage,
paramCode: this.searchCode, limit: this.pageSize,
}, paramCode: this.searchCode,
({ data }) => { },
if (data.code === 0) { ({ data }) => {
this.paramsList = data.data.list.map(item => ({ this.loading = false;
...item, if (data.code === 0) {
selected: false this.paramsList = data.data.list.map(item => ({
})); ...item,
this.total = data.data.total; selected: false
} else { }));
this.$message.error({ this.total = data.data.total;
message: data.msg || '获取参数列表失败', } else {
showClose: true this.$message.error({
}); message: data.msg || '获取参数列表失败',
} showClose: true
} });
); }
}
);
}, },
handleSearch() { handleSearch() {
this.currentPage = 1; this.currentPage = 1;
@@ -185,32 +195,35 @@ export default {
this.paramForm = { ...row }; this.paramForm = { ...row };
this.dialogVisible = true; this.dialogVisible = true;
}, },
handleSubmit(form) {
if (form.id) { handleSubmit({ form, done }) {
// 编辑 if (form.id) {
Api.admin.updateParam(form, ({ data }) => { // 编辑
if (data.code === 0) { Api.admin.updateParam(form, ({ data }) => {
this.$message.success({ if (data.code === 0) {
message:"修改成功", this.$message.success({
showClose:true message:"修改成功",
}); showClose:true
this.dialogVisible = false;
this.fetchParams();
}
}); });
} else { this.dialogVisible = false;
// 新增 this.fetchParams();
Api.admin.addParam(form, ({ data }) => { }
if (data.code === 0) { done && done();
this.$message.success({ });
message:"新增成功", } else {
showClose:true // 新增
}); Api.admin.addParam(form, ({ data }) => {
this.dialogVisible = false; if (data.code === 0) {
this.fetchParams(); this.$message.success({
} message:"新增成功",
showClose:true
}); });
} this.dialogVisible = false;
this.fetchParams();
}
done && done();
});
}
}, },
deleteSelectedParams() { deleteSelectedParams() {
@@ -656,4 +669,25 @@ export default {
max-height: calc(var(--table-max-height) - 40px); max-height: calc(var(--table-max-height) - 40px);
} }
} }
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style> </style>
+44 -18
View File
@@ -15,7 +15,14 @@
<div class="content-panel"> <div class="content-panel">
<div class="content-area"> <div class="content-area">
<el-card class="user-card" shadow="never"> <el-card class="user-card" shadow="never">
<el-table ref="userTable" :data="userList" class="transparent-table"> <el-table
ref="userTable"
:data="userList"
class="transparent-table"
v-loading="loading"
element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)">
<el-table-column label="选择" align="center" width="120"> <el-table-column label="选择" align="center" width="120">
<template slot-scope="scope"> <template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox> <el-checkbox v-model="scope.row.selected"></el-checkbox>
@@ -103,7 +110,8 @@ export default {
currentPage: 1, currentPage: 1,
pageSize: 10, pageSize: 10,
total: 0, total: 0,
isAllSelected: false isAllSelected: false,
loading: false,
}; };
}, },
created() { created() {
@@ -137,22 +145,24 @@ export default {
}, },
fetchUsers() { fetchUsers() {
Api.admin.getUserList( this.loading = true;
{ Api.admin.getUserList(
page: this.currentPage, {
limit: this.pageSize, page: this.currentPage,
mobile: this.searchPhone, limit: this.pageSize,
}, mobile: this.searchPhone,
({ data }) => { },
if (data.code === 0) { ({ data }) => {
this.userList = data.data.list.map(item => ({ this.loading = false; // 结束加载
...item, if (data.code === 0) {
selected: false this.userList = data.data.list.map(item => ({
})); ...item,
this.total = data.data.total; selected: false
} }));
} this.total = data.data.total;
); }
}
);
}, },
handleSearch() { handleSearch() {
this.currentPage = 1; this.currentPage = 1;
@@ -682,5 +692,21 @@ export default {
} }
} }
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style> </style>
+107 -10
View File
@@ -31,8 +31,30 @@
</div> </div>
</div> </div>
<div class="device-list-container"> <div class="device-list-container">
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" @configure="goToRoleConfig" <template v-if="isLoading">
@deviceManage="handleDeviceManage" @delete="handleDeleteAgent" /> <div
v-for="i in skeletonCount"
:key="'skeleton-'+i"
class="skeleton-item"
>
<div class="skeleton-image"></div>
<div class="skeleton-content">
<div class="skeleton-line"></div>
<div class="skeleton-line-short"></div>
</div>
</div>
</template>
<template v-else>
<DeviceItem
v-for="(item, index) in devices"
:key="index"
:device="item"
@configure="goToRoleConfig"
@deviceManage="handleDeviceManage"
@delete="handleDeleteAgent"
/>
</template>
</div> </div>
</div> </div>
<AddWisdomBodyDialog :visible.sync="addDeviceDialogVisible" @confirm="handleWisdomBodyAdded" /> <AddWisdomBodyDialog :visible.sync="addDeviceDialogVisible" @confirm="handleWisdomBodyAdded" />
@@ -60,7 +82,9 @@ export default {
devices: [], devices: [],
originalDevices: [], originalDevices: [],
isSearching: false, isSearching: false,
searchRegex: null searchRegex: null,
isLoading: true,
skeletonCount: localStorage.getItem('skeletonCount') || 8,
} }
}, },
@@ -109,12 +133,26 @@ export default {
}, },
// 获取智能体列表 // 获取智能体列表
fetchAgentList() { fetchAgentList() {
this.isLoading = true;
Api.agent.getAgentList(({ data }) => { Api.agent.getAgentList(({ data }) => {
this.originalDevices = data.data.map(item => ({ if (data?.data) {
...item, this.originalDevices = data.data.map(item => ({
agentId: item.id // 字段映射 ...item,
})); agentId: item.id
this.handleSearchReset(); // 重置搜索状态 }));
// 动态设置骨架屏数量(可选)
this.skeletonCount = Math.min(
Math.max(this.originalDevices.length, 3), // 最少3个
10 // 最多10个
);
this.handleSearchReset();
}
this.isLoading = false;
}, (error) => {
console.error('Failed to fetch agent list:', error);
this.isLoading = false;
}); });
}, },
// 删除智能体 // 删除智能体
@@ -199,7 +237,7 @@ export default {
.hi-hint { .hi-hint {
font-weight: 400; font-weight: 400;
font-size: 10px; font-size: 12px;
text-align: left; text-align: left;
color: #818cae; color: #818cae;
margin-left: 75px; margin-left: 75px;
@@ -220,7 +258,7 @@ export default {
border-radius: 17px; border-radius: 17px;
background: #5778ff; background: #5778ff;
color: #fff; color: #fff;
font-size: 10px; font-size: 14px;
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
line-height: 34px; line-height: 34px;
@@ -262,6 +300,65 @@ export default {
/* 居中显示 */ /* 居中显示 */
} }
/* 骨架屏动画 */
@keyframes shimmer {
100% { transform: translateX(100%); }
}
.skeleton-item {
background: #fff;
border-radius: 8px;
padding: 20px;
height: 120px;
position: relative;
overflow: hidden;
margin-bottom: 20px;
}
.skeleton-image {
width: 80px;
height: 80px;
background: #f0f2f5;
border-radius: 4px;
float: left;
position: relative;
overflow: hidden;
}
.skeleton-content {
margin-left: 100px;
}
.skeleton-line {
height: 16px;
background: #f0f2f5;
border-radius: 4px;
margin-bottom: 12px;
width: 70%;
position: relative;
overflow: hidden;
}
.skeleton-line-short {
height: 12px;
background: #f0f2f5;
border-radius: 4px;
width: 50%;
}
.skeleton-item::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255,255,255,0),
rgba(255,255,255,0.3),
rgba(255,255,255,0)
);
animation: shimmer 1.5s infinite;
}
</style> </style>
+54 -15
View File
@@ -15,6 +15,9 @@
<img loading="lazy" src="@/assets/home/setting-user.png" alt=""> <img loading="lazy" src="@/assets/home/setting-user.png" alt="">
</div> </div>
<span class="header-title">{{ form.agentName }}</span> <span class="header-title">{{ form.agentName }}</span>
<button class="custom-close-btn" @click="goToHome">
×
</button>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
@@ -165,6 +168,9 @@ export default {
} }
}, },
methods: { methods: {
goToHome() {
this.$router.push('/home');
},
saveConfig() { saveConfig() {
const configData = { const configData = {
agentCode: this.form.agentCode, agentCode: this.form.agentCode,
@@ -423,6 +429,7 @@ export default {
} }
.config-header { .config-header {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 13px; gap: 13px;
@@ -512,20 +519,25 @@ export default {
align-items: center; align-items: center;
} }
.save-btn { .action-bar {
background: #5778ff; .el-button.save-btn {
color: white; background: #5778ff;
border: none; color: white;
border-radius: 18px; border: none;
padding: 10px 20px; border-radius: 18px;
} padding: 10px 20px;
width: 100px;
height: 35px;
font-size: 14px;
}
.reset-btn { .el-button.reset-btn {
background: #e6ebff; background: #e6ebff;
color: #5778ff; color: #5778ff;
border: 1px solid #adbdff; border: 1px solid #adbdff;
border-radius: 18px; border-radius: 18px;
padding: 10px 20px; padding: 10px 20px;
}
} }
.hint-text { .hint-text {
@@ -555,8 +567,35 @@ export default {
background: none; background: none;
position: absolute; position: absolute;
font-size: 12px; font-size: 12px;
bottom: -10px; bottom: -10%;
right: 21px; right: 3%;
}
.custom-close-btn {
position: absolute;
top: 25%;
right: 0;
transform: translateY(-50%);
width: 35px;
height: 35px;
border-radius: 50%;
border: 2px solid #cfcfcf;
background: none;
font-size: 30px;
font-weight: lighter;
color: #cfcfcf;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
padding: 0;
outline: none;
}
.custom-close-btn:hover {
color: #409EFF;
border-color: #409EFF;
} }
</style> </style>
+2 -2
View File
@@ -147,7 +147,7 @@ selected_module:
Memory: nomem Memory: nomem
# 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。 # 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
# 不想开通意图识别,就设置成:nointent # 不想开通意图识别,就设置成:nointent
# 意图识别可使用intent_llm,如果你的LLM是DifyLLM或CozeLLM,建议使用这个。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作 # 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作
# 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令 # 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令
# 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-pro-32k-functioncall-241028 # 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-pro-32k-functioncall-241028
Intent: function_call Intent: function_call
@@ -163,7 +163,7 @@ Intent:
type: intent_llm type: intent_llm
# 配备意图识别独立的思考模型 # 配备意图识别独立的思考模型
# 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型 # 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型
# 如果你的selected_module.LLM选择了DifyLLM或CozeLLM,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM # 如果你的不想使用selected_module.LLM意图识别,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
llm: ChatGLMLLM llm: ChatGLMLLM
function_call: function_call:
# 不需要动type # 不需要动type
+11 -92
View File
@@ -1,18 +1,7 @@
import os import os
import argparse import argparse
import requests
import yaml import yaml
import time from config.manage_api_client import init_service, get_server_config, get_agent_models
class DeviceNotFoundException(Exception):
pass
class DeviceBindException(Exception):
def __init__(self, bind_code):
self.bind_code = bind_code
super().__init__(f"设备绑定异常,绑定码: {bind_code}")
# 添加全局配置缓存 # 添加全局配置缓存
@@ -65,97 +54,27 @@ def get_config_file():
return config_file return config_file
def _make_api_request(api_url, secret, endpoint, json_data=None):
"""执行API请求的通用函数
Args:
api_url: API的基础URL
secret: API密钥
endpoint: API端点
json_data: 请求的JSON数据
Returns:
dict: API返回的数据
Raises:
Exception: 当请求失败时抛出异常
"""
if not api_url or not secret:
raise Exception("manager-api的url或secret配置错误")
if "" in secret:
raise Exception("请先配置manager-api的secret")
max_retries = 10
retry_delay = 2 # 秒
for attempt in range(max_retries):
try:
response = requests.post(f"{api_url}{endpoint}", json=json_data)
if response.status_code == 200:
result = response.json()
if result.get("code") == 10041:
raise DeviceNotFoundException(result.get("msg"))
elif result.get("code") == 10042:
raise DeviceBindException(result.get("msg"))
elif result.get("code") != 0:
raise Exception(f"API返回错误: {result.get('msg', '未知错误')}")
return result.get("data")
error_msg = f"manager-api请求失败,状态码: {response.status_code}"
try:
error_data = response.json()
if "msg" in error_data:
error_msg = f"{error_msg}, 错误信息: {error_data['msg']}"
except:
error_msg = f"{error_msg}, 响应内容: {response.text}"
if attempt < max_retries - 1:
print(f"请求manager-api失败,正在重试 ({attempt + 1}/{max_retries})...")
time.sleep(retry_delay)
else:
raise Exception(error_msg)
except requests.exceptions.RequestException as e:
if attempt < max_retries - 1:
print(f"请求manager-api异常,正在重试 ({attempt + 1}/{max_retries})...")
time.sleep(retry_delay)
else:
raise Exception(f"manager-api请求异常: {str(e)}")
def get_config_from_api(config): def get_config_from_api(config):
"""从Java API获取配置""" """从Java API获取配置"""
api_url = config["manager-api"].get("url", "") # 初始化API客户端
secret = config["manager-api"].get("secret", "") init_service(config)
# 获取服务器配置
config_data = get_server_config()
if config_data is None:
raise Exception("Failed to fetch server config from API")
config_data = _make_api_request(
api_url, secret, "/config/server-base", {"secret": secret}
)
config_data["read_config_from_api"] = True config_data["read_config_from_api"] = True
config_data["manager-api"] = { config_data["manager-api"] = {
"url": api_url, "url": config["manager-api"].get("url", ""),
"secret": secret, "secret": config["manager-api"].get("secret", ""),
} }
return config_data return config_data
def get_private_config_from_api(config, device_id, client_id): def get_private_config_from_api(config, device_id, client_id):
"""从Java API获取私有配置""" """从Java API获取私有配置"""
api_url = config["manager-api"].get("url", "") return get_agent_models(device_id, client_id, config["selected_module"])
secret = config["manager-api"].get("secret", "")
return _make_api_request(
api_url,
secret,
"/config/agent-models",
{
"secret": secret,
"macAddress": device_id,
"clientId": client_id,
"selectedModule": config["selected_module"],
},
)
def ensure_directories(config): def ensure_directories(config):
+1 -1
View File
@@ -3,7 +3,7 @@ import sys
from loguru import logger from loguru import logger
from config.config_loader import load_config from config.config_loader import load_config
SERVER_VERSION = "0.3.5" SERVER_VERSION = "0.3.9"
def get_module_abbreviation(module_name, module_dict): def get_module_abbreviation(module_name, module_dict):
@@ -0,0 +1,154 @@
import os
import time
from typing import Optional, Dict
import httpx
TAG = __name__
class DeviceNotFoundException(Exception):
pass
class DeviceBindException(Exception):
def __init__(self, bind_code):
self.bind_code = bind_code
super().__init__(f"设备绑定异常,绑定码: {bind_code}")
class ManageApiClient:
_instance = None
_client = None
_secret = None
def __new__(cls, config):
"""单例模式确保全局唯一实例,并支持传入配置参数"""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._init_client(config)
return cls._instance
@classmethod
def _init_client(cls, config):
"""初始化持久化连接池"""
cls.config = config.get("manager-api")
if not cls.config:
raise Exception("manager-api配置错误")
if not cls.config.get("url") or not cls.config.get("secret"):
raise Exception("manager-api的url或secret配置错误")
if "" in cls.config.get("secret"):
raise Exception("请先配置manager-api的secret")
cls._secret = cls.config.get("secret")
cls.max_retries = cls.config.get("max_retries", 6) # 最大重试次数
cls.retry_delay = cls.config.get("retry_delay", 10) # 初始重试延迟(秒)
# NOTE(goody): 2025/4/16 http相关资源统一管理,后续可以增加线程池或者超时
# 后续也可以统一配置apiToken之类的走通用的Auth
cls._client = httpx.Client(
base_url=cls.config.get("url"),
headers={
"User-Agent": f"PythonClient/2.0 (PID:{os.getpid()})",
"Accept": "application/json",
},
timeout=cls.config.get("timeout", 30), # 默认超时时间30秒
)
@classmethod
def _request(cls, method: str, endpoint: str, **kwargs) -> Dict:
"""发送单次HTTP请求并处理响应"""
endpoint = endpoint.lstrip("/")
response = cls._client.request(method, endpoint, **kwargs)
response.raise_for_status()
result = response.json()
# 处理API返回的业务错误
if result.get("code") == 10041:
raise DeviceNotFoundException(result.get("msg"))
elif result.get("code") == 10042:
raise DeviceBindException(result.get("msg"))
elif result.get("code") != 0:
raise Exception(f"API返回错误: {result.get('msg', '未知错误')}")
# 返回成功数据
return result.get("data") if result.get("code") == 0 else None
@classmethod
def _should_retry(cls, exception: Exception) -> bool:
"""判断异常是否应该重试"""
# 网络连接相关错误
if isinstance(
exception, (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError)
):
return True
# HTTP状态码错误
if isinstance(exception, httpx.HTTPStatusError):
status_code = exception.response.status_code
return status_code in [408, 429, 500, 502, 503, 504]
return False
@classmethod
def _execute_request(cls, method: str, endpoint: str, **kwargs) -> Dict:
"""带重试机制的请求执行器"""
retry_count = 0
while retry_count <= cls.max_retries:
try:
# 执行请求
return cls._request(method, endpoint, **kwargs)
except Exception as e:
# 判断是否应该重试
if retry_count < cls.max_retries and cls._should_retry(e):
retry_count += 1
print(
f"{method} {endpoint} 请求失败,将在 {cls.retry_delay:.1f} 秒后进行第 {retry_count} 次重试"
)
time.sleep(cls.retry_delay)
continue
else:
# 不重试,直接抛出异常
raise
@classmethod
def safe_close(cls):
"""安全关闭连接池"""
if cls._client:
cls._client.close()
cls._instance = None
def get_server_config() -> Optional[Dict]:
"""获取服务器基础配置"""
return ManageApiClient._instance._execute_request(
"POST", "/config/server-base", json={"secret": ManageApiClient._secret}
)
def get_agent_models(
mac_address: str, client_id: str, selected_module: Dict
) -> Optional[Dict]:
"""获取代理模型配置"""
return ManageApiClient._instance._execute_request(
"POST",
"/config/agent-models",
json={
"secret": ManageApiClient._secret,
"macAddress": mac_address,
"clientId": client_id,
"selectedModule": selected_module,
},
)
def init_service(config):
ManageApiClient(config)
def manage_api_http_safe_close():
ManageApiClient.safe_close()
+8
View File
@@ -43,6 +43,14 @@ def check_config_file():
missing_keys = find_missing_keys(new_config, old_config) missing_keys = find_missing_keys(new_config, old_config)
read_config_from_api = old_config.get("read_config_from_api", False) read_config_from_api = old_config.get("read_config_from_api", False)
if read_config_from_api: if read_config_from_api:
old_config_origin = read_config(old_config_file)
if old_config_origin.get("selected_module") is not None:
missing_keys_str = "\n".join(f"- {key}" for key in missing_keys)
error_msg = "您的配置文件好像既包含智控台的配置又包含本地配置:\n"
error_msg += "\n建议您:\n"
error_msg += "1、将根目录的config_from_api.yaml文件复制到data下,重命名为.config.yaml\n"
error_msg += "2、按教程配置好接口地址和密钥\n"
raise ValueError(error_msg)
return return
if missing_keys: if missing_keys:
+98 -46
View File
@@ -27,11 +27,9 @@ from core.handle.functionHandler import FunctionHandler
from plugins_func.register import Action, ActionResponse from plugins_func.register import Action, ActionResponse
from core.auth import AuthMiddleware, AuthenticationError from core.auth import AuthMiddleware, AuthenticationError
from core.mcp.manager import MCPManager from core.mcp.manager import MCPManager
from config.config_loader import ( from config.config_loader import get_private_config_from_api
get_private_config_from_api, from config.manage_api_client import DeviceNotFoundException, DeviceBindException
DeviceNotFoundException, from core.utils.output_counter import add_device_output
DeviceBindException,
)
TAG = __name__ TAG = __name__
@@ -60,6 +58,7 @@ class ConnectionHandler:
self.session_id = None self.session_id = None
self.prompt = None self.prompt = None
self.welcome_msg = None self.welcome_msg = None
self.max_output_size = 0
# 客户端状态相关 # 客户端状态相关
self.client_abort = False self.client_abort = False
@@ -112,6 +111,11 @@ class ConnectionHandler:
self.close_after_chat = False # 是否在聊天结束后关闭连接 self.close_after_chat = False # 是否在聊天结束后关闭连接
self.use_function_call_mode = False self.use_function_call_mode = False
self.timeout_task = None
self.timeout_seconds = (
int(self.config.get("close_connection_no_voice_time", 120)) + 60
) # 在原来第一道关闭的基础上加60秒,进行二道关闭
async def handle_connection(self, ws): async def handle_connection(self, ws):
try: try:
# 获取并验证headers # 获取并验证headers
@@ -150,12 +154,17 @@ class ConnectionHandler:
self.websocket = ws self.websocket = ws
self.session_id = str(uuid.uuid4()) self.session_id = str(uuid.uuid4())
# 启动超时检查任务
self.timeout_task = asyncio.create_task(self._check_timeout())
self.welcome_msg = self.config["xiaozhi"] self.welcome_msg = self.config["xiaozhi"]
self.welcome_msg["session_id"] = self.session_id self.welcome_msg["session_id"] = self.session_id
await self.websocket.send(json.dumps(self.welcome_msg)) await self.websocket.send(json.dumps(self.welcome_msg))
# 获取差异化配置
private_config = self._initialize_private_config()
# 异步初始化 # 异步初始化
self.executor.submit(self._initialize_components) self.executor.submit(self._initialize_components, private_config)
# tts 消化线程 # tts 消化线程
self.tts_priority_thread = threading.Thread( self.tts_priority_thread = threading.Thread(
target=self._tts_priority_thread, daemon=True target=self._tts_priority_thread, daemon=True
@@ -195,18 +204,23 @@ class ConnectionHandler:
async def _route_message(self, message): async def _route_message(self, message):
"""消息路由""" """消息路由"""
# 重置超时计时器
if self.timeout_task:
self.timeout_task.cancel()
self.timeout_task = asyncio.create_task(self._check_timeout())
if isinstance(message, str): if isinstance(message, str):
await handleTextMessage(self, message) await handleTextMessage(self, message)
elif isinstance(message, bytes): elif isinstance(message, bytes):
await handleAudioMessage(self, message) await handleAudioMessage(self, message)
def _initialize_components(self): def _initialize_components(self, private_config):
"""初始化组件""" """初始化组件"""
self._initialize_models() if private_config is not None:
self._initialize_models(private_config)
"""加载提示词""" else:
self.dialogue.put(Message(role="system", content=self.prompt)) self.prompt = self.config["prompt"]
self.change_system_prompt(self.prompt)
"""加载记忆""" """加载记忆"""
self._initialize_memory() self._initialize_memory()
"""加载意图识别""" """加载意图识别"""
@@ -219,20 +233,23 @@ class ConnectionHandler:
self.dialogue.update_system_message(self.prompt) self.dialogue.update_system_message(self.prompt)
def _initialize_models(self): def _initialize_private_config(self):
read_config_from_api = self.config.get("read_config_from_api", False) read_config_from_api = self.config.get("read_config_from_api", False)
"""如果是从配置文件获取,则进行二次实例化""" """如果是从配置文件获取,则进行二次实例化"""
if not read_config_from_api: if not read_config_from_api:
return return
"""从接口获取差异化的配置进行二次实例化,非全量重新实例化""" """从接口获取差异化的配置进行二次实例化,非全量重新实例化"""
try: try:
begin_time = time.time()
private_config = get_private_config_from_api( private_config = get_private_config_from_api(
self.config, self.config,
self.headers.get("device-id", None), self.headers.get("device-id"),
self.headers.get("client-id", None), self.headers.get("client-id", self.headers.get("device-id")),
) )
private_config["delete_audio"] = bool(self.config.get("delete_audio", True)) private_config["delete_audio"] = bool(self.config.get("delete_audio", True))
self.logger.bind(tag=TAG).info(f"获取差异化配置成功: {private_config}") self.logger.bind(tag=TAG).info(
f"{time.time() - begin_time} 秒,获取差异化配置成功: {private_config}"
)
except DeviceNotFoundException as e: except DeviceNotFoundException as e:
self.need_bind = True self.need_bind = True
private_config = {} private_config = {}
@@ -245,8 +262,37 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}") self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}")
private_config = {} private_config = {}
init_vad, init_asr, init_llm, init_tts, init_memory, init_intent = ( init_tts = False
False, if private_config.get("TTS", None) is not None:
init_tts = True
self.config["TTS"] = private_config["TTS"]
self.config["selected_module"]["TTS"] = private_config["selected_module"][
"TTS"
]
try:
modules = initialize_modules(
self.logger,
private_config,
False,
False,
False,
init_tts,
False,
False,
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
modules = {}
if modules.get("tts", None) is not None:
self.tts = modules["tts"]
if modules.get("prompt", None) is not None:
self.change_system_prompt(modules["prompt"])
private_config["prompt"] = None
return private_config
def _initialize_models(self, private_config):
init_vad, init_asr, init_llm, init_memory, init_intent = (
False, False,
False, False,
False, False,
@@ -271,12 +317,6 @@ class ConnectionHandler:
self.config["selected_module"]["LLM"] = private_config["selected_module"][ self.config["selected_module"]["LLM"] = private_config["selected_module"][
"LLM" "LLM"
] ]
if private_config.get("TTS", None) is not None:
init_tts = True
self.config["TTS"] = private_config["TTS"]
self.config["selected_module"]["TTS"] = private_config["selected_module"][
"TTS"
]
if private_config.get("Memory", None) is not None: if private_config.get("Memory", None) is not None:
init_memory = True init_memory = True
self.config["Memory"] = private_config["Memory"] self.config["Memory"] = private_config["Memory"]
@@ -289,6 +329,8 @@ class ConnectionHandler:
self.config["selected_module"]["Intent"] = private_config[ self.config["selected_module"]["Intent"] = private_config[
"selected_module" "selected_module"
]["Intent"] ]["Intent"]
if private_config.get("device_max_output_size", None) is not None:
self.max_output_size = int(private_config["device_max_output_size"])
try: try:
modules = initialize_modules( modules = initialize_modules(
self.logger, self.logger,
@@ -296,7 +338,7 @@ class ConnectionHandler:
init_vad, init_vad,
init_asr, init_asr,
init_llm, init_llm,
init_tts, False,
init_memory, init_memory,
init_intent, init_intent,
) )
@@ -307,16 +349,12 @@ class ConnectionHandler:
self.vad = modules["vad"] self.vad = modules["vad"]
if modules.get("asr", None) is not None: if modules.get("asr", None) is not None:
self.asr = modules["asr"] self.asr = modules["asr"]
if modules.get("tts", None) is not None:
self.tts = modules["tts"]
if modules.get("llm", None) is not None: if modules.get("llm", None) is not None:
self.llm = modules["llm"] self.llm = modules["llm"]
if modules.get("intent", None) is not None: if modules.get("intent", None) is not None:
self.intent = modules["intent"] self.intent = modules["intent"]
if modules.get("memory", None) is not None: if modules.get("memory", None) is not None:
self.memory = modules["memory"] self.memory = modules["memory"]
if modules.get("prompt", None) is not None:
self.change_system_prompt(modules["prompt"])
def _initialize_memory(self): def _initialize_memory(self):
"""初始化记忆模块""" """初始化记忆模块"""
@@ -497,16 +535,19 @@ class ConnectionHandler:
function_id = None function_id = None
function_arguments = "" function_arguments = ""
content_arguments = "" content_arguments = ""
for response in llm_responses: for response in llm_responses:
content, tools_call = response content, tools_call = response
if "content" in response: if "content" in response:
content = response["content"] content = response["content"]
tools_call = None tools_call = None
if content is not None and len(content) > 0: if content is not None and len(content) > 0:
if len(response_message) <= 0 and ( content_arguments += content
content == "```" or "<tool_call>" in content
): if not tool_call_flag and content_arguments.startswith("<tool_call>"):
tool_call_flag = True # print("content_arguments", content_arguments)
tool_call_flag = True
if tools_call is not None: if tools_call is not None:
tool_call_flag = True tool_call_flag = True
@@ -518,9 +559,7 @@ class ConnectionHandler:
function_arguments += tools_call[0].function.arguments function_arguments += tools_call[0].function.arguments
if content is not None and len(content) > 0: if content is not None and len(content) > 0:
if tool_call_flag: if not tool_call_flag:
content_arguments += content
else:
response_message.append(content) response_message.append(content)
if self.client_abort: if self.client_abort:
@@ -581,10 +620,9 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error( self.logger.bind(tag=TAG).error(
f"function call error: {content_arguments}" f"function call error: {content_arguments}"
) )
else:
function_arguments = json.loads(function_arguments)
if not bHasError: if not bHasError:
self.logger.bind(tag=TAG).info( response_message.clear()
self.logger.bind(tag=TAG).debug(
f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}" f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}"
) )
function_call_data = { function_call_data = {
@@ -679,7 +717,6 @@ class ConnectionHandler:
self.tts_queue.put(future) self.tts_queue.put(future)
self.dialogue.put(Message(role="assistant", content=text)) self.dialogue.put(Message(role="assistant", content=text))
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复 elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
text = result.result text = result.result
if text is not None and len(text) > 0: if text is not None and len(text) > 0:
function_id = function_call_data["id"] function_id = function_call_data["id"]
@@ -706,18 +743,14 @@ class ConnectionHandler:
Message(role="tool", tool_call_id=function_id, content=text) Message(role="tool", tool_call_id=function_id, content=text)
) )
self.chat_with_function_calling(text, tool_call=True) self.chat_with_function_calling(text, tool_call=True)
elif result.action == Action.NOTFOUND: elif result.action == Action.NOTFOUND or result.action == Action.ERROR:
text = result.result text = result.result
self.recode_first_last_text(text, text_index) self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index) future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future) self.tts_queue.put(future)
self.dialogue.put(Message(role="assistant", content=text)) self.dialogue.put(Message(role="assistant", content=text))
else: else:
text = result.result pass
self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future)
self.dialogue.put(Message(role="assistant", content=text))
def _tts_priority_thread(self): def _tts_priority_thread(self):
while not self.stop_event.is_set(): while not self.stop_event.is_set():
@@ -815,6 +848,8 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"tts转换失败,{text}") self.logger.bind(tag=TAG).error(f"tts转换失败,{text}")
return None, text, text_index return None, text, text_index
self.logger.bind(tag=TAG).debug(f"TTS 文件生成完毕: {tts_file}") self.logger.bind(tag=TAG).debug(f"TTS 文件生成完毕: {tts_file}")
if self.max_output_size > 0:
add_device_output(self.headers.get("device-id"), len(text))
return tts_file, text, text_index return tts_file, text, text_index
def clearSpeakStatus(self): def clearSpeakStatus(self):
@@ -831,6 +866,11 @@ class ConnectionHandler:
async def close(self, ws=None): async def close(self, ws=None):
"""资源清理方法""" """资源清理方法"""
# 取消超时任务
if self.timeout_task:
self.timeout_task.cancel()
self.timeout_task = None
# 清理MCP资源 # 清理MCP资源
if hasattr(self, "mcp_manager") and self.mcp_manager: if hasattr(self, "mcp_manager") and self.mcp_manager:
await self.mcp_manager.cleanup_all() await self.mcp_manager.cleanup_all()
@@ -884,3 +924,15 @@ class ConnectionHandler:
self.close_after_chat = True self.close_after_chat = True
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"Chat and close error: {str(e)}") self.logger.bind(tag=TAG).error(f"Chat and close error: {str(e)}")
async def _check_timeout(self):
"""检查连接超时"""
try:
while not self.stop_event.is_set():
await asyncio.sleep(self.timeout_seconds)
if not self.stop_event.is_set():
self.logger.bind(tag=TAG).info("连接超时,准备关闭")
await self.close(self.websocket)
break
except Exception as e:
self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}")
@@ -79,7 +79,7 @@ class FunctionHandler:
func = funcItem.func func = funcItem.func
arguments = function_call_data["arguments"] arguments = function_call_data["arguments"]
arguments = json.loads(arguments) if arguments else {} arguments = json.loads(arguments) if arguments else {}
logger.bind(tag=TAG).info(f"调用函数: {function_name}, 参数: {arguments}") logger.bind(tag=TAG).debug(f"调用函数: {function_name}, 参数: {arguments}")
if ( if (
funcItem.type == ToolType.SYSTEM_CTL funcItem.type == ToolType.SYSTEM_CTL
or funcItem.type == ToolType.IOT_CTL or funcItem.type == ToolType.IOT_CTL
+54 -33
View File
@@ -57,7 +57,7 @@ def create_iot_function(device_name, method_name, method_info):
response_failure = "操作失败" response_failure = "操作失败"
# 打印响应参数 # 打印响应参数
logger.bind(tag=TAG).info( logger.bind(tag=TAG).debug(
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'" f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
) )
@@ -146,7 +146,7 @@ class IotDescriptor:
# 根据描述创建属性 # 根据描述创建属性
if properties is not None: if properties is not None:
for key, value in properties.items(): for key, value in properties.items():
property_item = globals()[key] = {} property_item = {}
property_item["name"] = key property_item["name"] = key
property_item["description"] = value["description"] property_item["description"] = value["description"]
if value["type"] == "number": if value["type"] == "number":
@@ -160,18 +160,17 @@ class IotDescriptor:
# 根据描述创建方法 # 根据描述创建方法
if methods is not None: if methods is not None:
for key, value in methods.items(): for key, value in methods.items():
method = globals()[key] = {} method = {}
method["description"] = value["description"] method["description"] = value["description"]
method["name"] = key method["name"] = key
for k, v in value["parameters"].items(): # 检查方法是否有参数
method[k] = {} if "parameters" in value:
method[k]["description"] = v["description"] method["parameters"] = {}
if v["type"] == "number": for k, v in value["parameters"].items():
method[k]["value"] = 0 method["parameters"][k] = {
elif v["type"] == "boolean": "description": v["description"],
method[k]["value"] = False "type": v["type"],
else: }
method[k]["value"] = ""
self.methods.append(method) self.methods.append(method)
@@ -221,13 +220,19 @@ def register_device_type(descriptor):
func_name = f"{device_name.lower()}_{method_name.lower()}" func_name = f"{device_name.lower()}_{method_name.lower()}"
# 创建参数字典,添加原有参数 # 创建参数字典,添加原有参数
parameters = { parameters = {}
param_name: { required_params = []
"type": param_info["type"],
"description": param_info["description"], # 如果方法有参数,则添加参数信息
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()
} }
for param_name, param_info in method_info["parameters"].items() required_params = list(method_info["parameters"].keys())
}
# 添加响应参数 # 添加响应参数
parameters.update( parameters.update(
@@ -244,7 +249,6 @@ def register_device_type(descriptor):
) )
# 构建必须参数列表(原有参数 + 响应参数) # 构建必须参数列表(原有参数 + 响应参数)
required_params = list(method_info["parameters"].keys())
required_params.extend(["response_success", "response_failure"]) required_params.extend(["response_success", "response_failure"])
func_desc = { func_desc = {
@@ -282,6 +286,25 @@ async def handleIotDescriptors(conn, descriptors):
functions_changed = False functions_changed = False
for descriptor in descriptors: 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设备描述符
iot_descriptor = IotDescriptor( iot_descriptor = IotDescriptor(
descriptor["name"], descriptor["name"],
@@ -375,19 +398,17 @@ async def send_iot_conn(conn, name, method_name, parameters):
for method in value.methods: for method in value.methods:
# 找到了方法 # 找到了方法
if method["name"] == method_name: if method["name"] == method_name:
await conn.websocket.send( # 构建命令对象
json.dumps( command = {
{ "name": name,
"type": "iot", "method": method_name,
"commands": [ }
{
"name": name, # 只有当参数不为空时才添加parameters字段
"method": method_name, if parameters:
"parameters": parameters, command["parameters"] = parameters
} send_message = json.dumps({"type": "iot", "commands": [command]})
], await conn.websocket.send(send_message)
} logger.bind(tag=TAG).info(f"发送物联网指令: {send_message}")
)
)
return return
logger.bind(tag=TAG).error(f"未找到方法{method_name}") logger.bind(tag=TAG).error(f"未找到方法{method_name}")
@@ -1,9 +1,9 @@
from config.logger import setup_logging from config.logger import setup_logging
import time import time
import asyncio
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length
from core.handle.sendAudioHandle import send_stt_message from core.handle.sendAudioHandle import send_stt_message
from core.handle.intentHandler import handle_user_intent from core.handle.intentHandler import handle_user_intent
from core.utils.output_counter import check_device_output_limit
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -51,6 +51,15 @@ async def startToChat(conn, text):
if conn.need_bind: if conn.need_bind:
await check_bind_device(conn) await check_bind_device(conn)
return return
# 如果当日的输出字数大于限定的字数
if conn.max_output_size > 0:
if check_device_output_limit(
conn.headers.get("device-id"), conn.max_output_size
):
await max_out_size(conn)
return
# 首先进行意图分析 # 首先进行意图分析
intent_handled = await handle_user_intent(conn, text) intent_handled = await handle_user_intent(conn, text)
@@ -89,6 +98,18 @@ async def no_voice_close_connect(conn):
await startToChat(conn, prompt) await startToChat(conn, prompt)
async def max_out_size(conn):
text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!"
await send_stt_message(conn, text)
conn.tts_first_text_index = 0
conn.tts_last_text_index = 0
conn.llm_finish_task = True
file_path = "config/assets/max_output_size.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(file_path)
conn.audio_play_queue.put((opus_packets, text, 0))
conn.close_after_chat = True
async def check_bind_device(conn): async def check_bind_device(conn):
if conn.bind_code: if conn.bind_code:
# 确保bind_code是6位数字 # 确保bind_code是6位数字
@@ -35,4 +35,5 @@ class LLMProviderBase(ABC):
""" """
# For providers that don't support functions, just return regular response # For providers that don't support functions, just return regular response
for token in self.response(session_id, dialogue): for token in self.response(session_id, dialogue):
yield {"type": "content", "content": token} yield token, None
@@ -7,14 +7,8 @@ import os
# official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py) # official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py)
from cozepy import COZE_CN_BASE_URL from cozepy import COZE_CN_BASE_URL
from cozepy import ( from cozepy import Coze, TokenAuth, Message, ChatStatus, MessageContentType, ChatEventType # noqa
Coze, from core.providers.llm.system_prompt import get_system_prompt_for_function
TokenAuth,
Message,
ChatStatus,
MessageContentType,
ChatEventType,
) # noqa
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -53,3 +47,23 @@ class LLMProvider(LLMProviderBase):
if event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA: if event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA:
print(event.message.content, end="", flush=True) print(event.message.content, end="", flush=True)
yield event.message.content yield event.message.content
def response_with_functions(self, session_id, dialogue, functions=None):
if len(dialogue) == 2 and functions is not None and len(functions) > 0:
# 第一次调用llm, 取最后一条用户消息,附加tool提示词
last_msg = dialogue[-1]["content"]
function_str = json.dumps(functions, ensure_ascii=False)
modify_msg = get_system_prompt_for_function(function_str) + last_msg
dialogue[-1]["content"] = modify_msg
# 如果最后一个是 role="tool",附加到user上
if len(dialogue) > 1 and dialogue[-1]["role"] == "tool":
assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n"
while len(dialogue) > 1 :
if dialogue[-1]["role"] == "user":
dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"]
break
dialogue.pop()
for token in self.response(session_id, dialogue):
yield token, None
@@ -2,6 +2,7 @@ import json
from config.logger import setup_logging from config.logger import setup_logging
import requests import requests
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
from core.providers.llm.system_prompt import get_system_prompt_for_function
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -81,3 +82,23 @@ class LLMProvider(LLMProviderBase):
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error in response generation: {e}") logger.bind(tag=TAG).error(f"Error in response generation: {e}")
yield "【服务响应异常】" yield "【服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None):
if len(dialogue) == 2 and functions is not None and len(functions) > 0:
# 第一次调用llm, 取最后一条用户消息,附加tool提示词
last_msg = dialogue[-1]["content"]
function_str = json.dumps(functions, ensure_ascii=False)
modify_msg = get_system_prompt_for_function(function_str) + last_msg
dialogue[-1]["content"] = modify_msg
# 如果最后一个是 role="tool",附加到user上
if len(dialogue) > 1 and dialogue[-1]["role"] == "tool":
assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n"
while len(dialogue) > 1 :
if dialogue[-1]["role"] == "user":
dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"]
break
dialogue.pop()
for token in self.response(session_id, dialogue):
yield token, None
@@ -63,4 +63,4 @@ class LLMProvider(LLMProviderBase):
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error in Ollama function call: {e}") logger.bind(tag=TAG).error(f"Error in Ollama function call: {e}")
yield {"type": "content", "content": f"【Ollama服务响应异常: {str(e)}"} yield f"【Ollama服务响应异常: {str(e)}", None
@@ -1,4 +1,5 @@
import openai import openai
from openai.types import CompletionUsage
from config.logger import setup_logging from config.logger import setup_logging
from core.utils.util import check_model_key from core.utils.util import check_model_key
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
@@ -70,8 +71,18 @@ class LLMProvider(LLMProviderBase):
) )
for chunk in stream: for chunk in stream:
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls # 检查是否存在有效的choice且content不为空
if getattr(chunk, "choices", None):
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls
# 存在 CompletionUsage 消息时,生成 Token 消耗 log
elif isinstance(getattr(chunk, 'usage', None), CompletionUsage):
usage_info = getattr(chunk, 'usage', None)
logger.bind(tag=TAG).info(
f"Token 消耗:输入 {getattr(usage_info, 'prompt_tokens', '未知')}"
f"输出 {getattr(usage_info, 'completion_tokens', '未知')}"
f"共计 {getattr(usage_info, 'total_tokens', '未知')}"
)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error in function call streaming: {e}") logger.bind(tag=TAG).error(f"Error in function call streaming: {e}")
yield {"type": "content", "content": f"【OpenAI服务响应异常: {e}"} yield f"【OpenAI服务响应异常: {e}", None
@@ -0,0 +1,103 @@
def get_system_prompt_for_function(functions: str) -> str:
"""
生成系统提示信息
:param functions: 可用的函数列表
:return: 系统提示信息
"""
SYSTEM_PROMPT = f"""
====
TOOL USE
You have access to a set of tools that are executed upon the user's approval. You can use one tool per message, and will receive the result of that tool use in the user's response.
You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use.
# Tool Use Formatting
Tool use is formatted using JSON-style tags. The tool name is enclosed in opening and closing tags, and each parameter is similarly enclosed within its own set of tags.
Here's the structure:
<tool_call>
{{
"name": "function name",
"arguments": {{
"param1": "value1",
"param2": "value2",
// Add more parameters as needed, if parameters are required, you must provide them
}}
}}
<tool_call>
For example:
if you got tool as follow
{{
"type": "function",
"function": {{
"name": "handle_exit_intent",
"description": "当用户想结束对话或需要退出系统时调用",
"parameters": {{
"type": "object",
"properties": {{
"say_goodbye": {{
"type": "string",
"description": "和用户友好结束对话的告别语",
}}
}},
"required": ["say_goodbye"],
}},
}},
}}
you should respond with the following format:
<tool_call>
{{
"name": "handle_exit_intent",
"arguments": {{
"say_goodbye": "再见,祝您生活愉快!"
}}
}}
</tool_call>
Always adhere to this format for the tool use to ensure proper parsing and execution.
# Tools
{functions}
# Tool Use Guidelines
1. Tools must be called in a separate message, Do not add thoughts when calling tools. The message must start with <tool_call> and end with </tool_call>, with the tool invocation JSON data in between. No additional response content is needed.
2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information.
For example using the list_files tool is more effective than running a command like \`ls\` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task.
3. If multiple actions are needed, use one tool at a time per message to accomplish the task iteratively, with each tool use being informed by the result of the previous tool use. Do not assume the outcome of any tool use.
Each step must be informed by the previous step's result.
4. Formulate your tool use using the JSON format specified for each tool.
5. After each tool use, the user will respond with the result of that tool use. This result will provide you with the necessary information to continue your task or make further decisions. This response may include:
- Information about whether the tool succeeded or failed, along with any reasons for failure.
- Linter errors that may have arisen due to the changes you made, which you'll need to address.
- New terminal output in reaction to the changes, which you may need to consider or act upon.
- Any other relevant feedback or information related to the tool use.
6. ALWAYS wait for user confirmation after each tool use before proceeding. Never assume the success of a tool use without explicit confirmation of the result from the user.
7. Tool calls should contain no extra information. Only after receiving the tool's response should you integrate it into a complete reply.
It is crucial to proceed step-by-step, waiting for the user's message after each tool use before moving forward with the task. This approach allows you to:
1. Confirm the success of each step before proceeding.
2. Address any issues or errors that arise immediately.
3. Adapt your approach based on new information or unexpected results.
4. Ensure that each action builds correctly on the previous ones.
By waiting for and carefully considering the user's response after each tool use, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.
====
USER CHAT CONTENT
The following additional message is the user's chat message, and should be followed to the best of your ability without interfering with the TOOL USE guidelines.
"""
return SYSTEM_PROMPT
@@ -15,7 +15,10 @@ logger = setup_logging()
class TTSProvider(TTSProviderBase): class TTSProvider(TTSProviderBase):
def __init__(self, config, delete_audio_file): def __init__(self, config, delete_audio_file):
super().__init__(config, delete_audio_file) super().__init__(config, delete_audio_file)
self.appid = config.get("appid") if config.get("appid"):
self.appid = int(config.get("appid"))
else:
self.appid = ""
self.access_token = config.get("access_token") self.access_token = config.get("access_token")
self.cluster = config.get("cluster") self.cluster = config.get("cluster")
@@ -5,6 +5,10 @@ import requests
import shutil import shutil
from datetime import datetime from datetime import datetime
from core.providers.tts.base import TTSProviderBase from core.providers.tts.base import TTSProviderBase
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class TTSProvider(TTSProviderBase): class TTSProvider(TTSProviderBase):
@@ -54,6 +58,7 @@ class TTSProvider(TTSProviderBase):
resp = requests.request("POST", url, data=payload) resp = requests.request("POST", url, data=payload)
if resp.status_code != 200: if resp.status_code != 200:
logger.bind(tag=TAG).error(f"TTS请求失败: {resp.text}")
return None return None
resp_json = resp.json() resp_json = resp.json()
try: try:
@@ -0,0 +1,50 @@
import datetime
from typing import Dict, Tuple
# 全局字典,用于存储每个设备的每日输出字数
_device_daily_output: Dict[Tuple[str, datetime.date], int] = {}
# 记录最后一次检查的日期
_last_check_date: datetime.date = None
def reset_device_output():
"""
重置所有设备的每日输出字数
每天0点调用此函数
"""
_device_daily_output.clear()
def get_device_output(device_id: str) -> int:
"""
获取设备当日的输出字数
"""
current_date = datetime.datetime.now().date()
return _device_daily_output.get((device_id, current_date), 0)
def add_device_output(device_id: str, char_count: int):
"""
增加设备的输出字数
"""
current_date = datetime.datetime.now().date()
global _last_check_date
# 如果是第一次调用或者日期发生变化,清空计数器
if _last_check_date is None or _last_check_date != current_date:
_device_daily_output.clear()
_last_check_date = current_date
current_count = _device_daily_output.get((device_id, current_date), 0)
_device_daily_output[(device_id, current_date)] = current_count + char_count
def check_device_output_limit(device_id: str, max_output_size: int) -> bool:
"""
检查设备是否超过输出限制
:return: True 如果超过限制,False 如果未超过
"""
if not device_id:
return False
current_output = get_device_output(device_id)
return current_output >= max_output_size
+1 -1
View File
@@ -211,7 +211,7 @@ def check_ffmpeg_installed():
def extract_json_from_string(input_string): def extract_json_from_string(input_string):
"""提取字符串中的 JSON 部分""" """提取字符串中的 JSON 部分"""
pattern = r"(\{.*\})" pattern = r"(\{.*\})"
match = re.search(pattern, input_string) match = re.search(pattern, input_string, re.DOTALL) #添加 re.DOTALL
if match: if match:
return match.group(1) # 返回提取的 JSON 字符串 return match.group(1) # 返回提取的 JSON 字符串
return None return None
+1 -2
View File
@@ -42,7 +42,6 @@ services:
- "8002:8002" - "8002:8002"
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
##记得改mysql和redis IP 密码
- SPRING_DATASOURCE_DRUID_URL=jdbc:mysql://xiaozhi-esp32-server-db:3306/xiaozhi_esp32_server?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&connectTimeout=30000&socketTimeout=30000&autoReconnect=true&failOverReadOnly=false&maxReconnects=10 - SPRING_DATASOURCE_DRUID_URL=jdbc:mysql://xiaozhi-esp32-server-db:3306/xiaozhi_esp32_server?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&connectTimeout=30000&socketTimeout=30000&autoReconnect=true&failOverReadOnly=false&maxReconnects=10
- SPRING_DATASOURCE_DRUID_USERNAME=root - SPRING_DATASOURCE_DRUID_USERNAME=root
- SPRING_DATASOURCE_DRUID_PASSWORD=123456 - SPRING_DATASOURCE_DRUID_PASSWORD=123456
@@ -60,7 +59,7 @@ services:
networks: networks:
- default - default
expose: expose:
- "3306:3306" - "3306"
volumes: volumes:
- ./mysql/data:/var/lib/mysql - ./mysql/data:/var/lib/mysql
environment: environment:
@@ -9,7 +9,7 @@ import traceback
from pathlib import Path from pathlib import Path
from core.utils import p3 from core.utils import p3
from core.handle.sendAudioHandle import send_stt_message from core.handle.sendAudioHandle import send_stt_message
from plugins_func.register import register_function,ToolType, ActionResponse, Action from plugins_func.register import register_function, ToolType, ActionResponse, Action
TAG = __name__ TAG = __name__
@@ -18,38 +18,41 @@ logger = setup_logging()
MUSIC_CACHE = {} MUSIC_CACHE = {}
play_music_function_desc = { play_music_function_desc = {
"type": "function", "type": "function",
"function": { "function": {
"name": "play_music", "name": "play_music",
"description": "唱歌、听歌、播放音乐的方法。", "description": "唱歌、听歌、播放音乐的方法。",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"song_name": { "song_name": {
"type": "string", "type": "string",
"description": "歌曲名称,如果用户没有指定具体歌名则为'random', 明确指定的时返回音乐的名字 示例: ```用户:播放两只老虎\n参数:两只老虎``` ```用户:播放音乐 \n参数:random ```" "description": "歌曲名称,如果用户没有指定具体歌名则为'random', 明确指定的时返回音乐的名字 示例: ```用户:播放两只老虎\n参数:两只老虎``` ```用户:播放音乐 \n参数:random ```",
}
},
"required": ["song_name"]
}
} }
} },
"required": ["song_name"],
},
},
}
@register_function('play_music', play_music_function_desc, ToolType.SYSTEM_CTL) @register_function("play_music", play_music_function_desc, ToolType.SYSTEM_CTL)
def play_music(conn, song_name: str): def play_music(conn, song_name: str):
try: try:
music_intent = f"播放音乐 {song_name}" if song_name != "random" else "随机播放音乐" music_intent = (
f"播放音乐 {song_name}" if song_name != "random" else "随机播放音乐"
)
# 检查事件循环状态 # 检查事件循环状态
if not conn.loop.is_running(): if not conn.loop.is_running():
logger.bind(tag=TAG).error("事件循环未运行,无法提交任务") logger.bind(tag=TAG).error("事件循环未运行,无法提交任务")
return ActionResponse(action=Action.RESPONSE, result="系统繁忙", response="请稍后再试") return ActionResponse(
action=Action.RESPONSE, result="系统繁忙", response="请稍后再试"
)
# 提交异步任务 # 提交异步任务
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
handle_music_command(conn, music_intent), handle_music_command(conn, music_intent), conn.loop
conn.loop
) )
# 非阻塞回调处理 # 非阻塞回调处理
@@ -62,10 +65,14 @@ def play_music(conn, song_name: str):
future.add_done_callback(handle_done) future.add_done_callback(handle_done)
return ActionResponse(action=Action.RESPONSE, result="指令已接收", response="正在为您播放音乐") return ActionResponse(
action=Action.NONE, result="指令已接收", response="正在为您播放音乐"
)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"处理音乐意图错误: {e}") logger.bind(tag=TAG).error(f"处理音乐意图错误: {e}")
return ActionResponse(action=Action.RESPONSE, result=str(e), response="播放音乐时出错了") return ActionResponse(
action=Action.RESPONSE, result=str(e), response="播放音乐时出错了"
)
def _extract_song_name(text): def _extract_song_name(text):
@@ -105,7 +112,9 @@ def get_music_files(music_dir, music_ext):
if ext in music_ext: if ext in music_ext:
# 添加相对路径 # 添加相对路径
music_files.append(str(file.relative_to(music_dir))) music_files.append(str(file.relative_to(music_dir)))
music_file_names.append(os.path.splitext(str(file.relative_to(music_dir)))[0]) music_file_names.append(
os.path.splitext(str(file.relative_to(music_dir)))[0]
)
return music_files, music_file_names return music_files, music_file_names
@@ -117,15 +126,20 @@ def initialize_music_handler(conn):
MUSIC_CACHE["music_dir"] = os.path.abspath( MUSIC_CACHE["music_dir"] = os.path.abspath(
MUSIC_CACHE["music_config"].get("music_dir", "./music") # 默认路径修改 MUSIC_CACHE["music_config"].get("music_dir", "./music") # 默认路径修改
) )
MUSIC_CACHE["music_ext"] = MUSIC_CACHE["music_config"].get("music_ext", (".mp3", ".wav", ".p3")) MUSIC_CACHE["music_ext"] = MUSIC_CACHE["music_config"].get(
MUSIC_CACHE["refresh_time"] = MUSIC_CACHE["music_config"].get("refresh_time", 60) "music_ext", (".mp3", ".wav", ".p3")
)
MUSIC_CACHE["refresh_time"] = MUSIC_CACHE["music_config"].get(
"refresh_time", 60
)
else: else:
MUSIC_CACHE["music_dir"] = os.path.abspath("./music") MUSIC_CACHE["music_dir"] = os.path.abspath("./music")
MUSIC_CACHE["music_ext"] = (".mp3", ".wav", ".p3") MUSIC_CACHE["music_ext"] = (".mp3", ".wav", ".p3")
MUSIC_CACHE["refresh_time"] = 60 MUSIC_CACHE["refresh_time"] = 60
# 获取音乐文件列表 # 获取音乐文件列表
MUSIC_CACHE["music_files"], MUSIC_CACHE["music_file_names"] = get_music_files(MUSIC_CACHE["music_dir"], MUSIC_CACHE["music_files"], MUSIC_CACHE["music_file_names"] = get_music_files(
MUSIC_CACHE["music_ext"]) MUSIC_CACHE["music_dir"], MUSIC_CACHE["music_ext"]
)
MUSIC_CACHE["scan_time"] = time.time() MUSIC_CACHE["scan_time"] = time.time()
return MUSIC_CACHE return MUSIC_CACHE
@@ -135,15 +149,16 @@ async def handle_music_command(conn, text):
global MUSIC_CACHE global MUSIC_CACHE
"""处理音乐播放指令""" """处理音乐播放指令"""
clean_text = re.sub(r'[^\w\s]', '', text).strip() clean_text = re.sub(r"[^\w\s]", "", text).strip()
logger.bind(tag=TAG).debug(f"检查是否是音乐命令: {clean_text}") logger.bind(tag=TAG).debug(f"检查是否是音乐命令: {clean_text}")
# 尝试匹配具体歌名 # 尝试匹配具体歌名
if os.path.exists(MUSIC_CACHE["music_dir"]): if os.path.exists(MUSIC_CACHE["music_dir"]):
if time.time() - MUSIC_CACHE["scan_time"] > MUSIC_CACHE["refresh_time"]: if time.time() - MUSIC_CACHE["scan_time"] > MUSIC_CACHE["refresh_time"]:
# 刷新音乐文件列表 # 刷新音乐文件列表
MUSIC_CACHE["music_files"], MUSIC_CACHE["music_file_names"] = get_music_files(MUSIC_CACHE["music_dir"], MUSIC_CACHE["music_files"], MUSIC_CACHE["music_file_names"] = (
MUSIC_CACHE["music_ext"]) get_music_files(MUSIC_CACHE["music_dir"], MUSIC_CACHE["music_ext"])
)
MUSIC_CACHE["scan_time"] = time.time() MUSIC_CACHE["scan_time"] = time.time()
potential_song = _extract_song_name(clean_text) potential_song = _extract_song_name(clean_text)
@@ -158,6 +173,23 @@ async def handle_music_command(conn, text):
return True return True
def _get_random_play_prompt(song_name):
"""生成随机播放引导语"""
# 移除文件扩展名
clean_name = os.path.splitext(song_name)[0]
prompts = [
f"正在为您播放,{clean_name}",
f"请欣赏歌曲,{clean_name}",
f"即将为您播放,{clean_name}",
f"为您带来,{clean_name}",
f"让我们聆听,{clean_name}",
f"接下来请欣赏,{clean_name}",
f"为您献上,{clean_name}",
]
# 直接使用random.choice,不设置seed
return random.choice(prompts)
async def play_local_music(conn, specific_file=None): async def play_local_music(conn, specific_file=None):
global MUSIC_CACHE global MUSIC_CACHE
"""播放本地音乐文件""" """播放本地音乐文件"""
@@ -180,16 +212,25 @@ async def play_local_music(conn, specific_file=None):
if not os.path.exists(music_path): if not os.path.exists(music_path):
logger.bind(tag=TAG).error(f"选定的音乐文件不存在: {music_path}") logger.bind(tag=TAG).error(f"选定的音乐文件不存在: {music_path}")
return return
text = f"正在播放{selected_music}" text = _get_random_play_prompt(selected_music)
await send_stt_message(conn, text) await send_stt_message(conn, text)
conn.tts_first_text_index = 0 conn.tts_first_text_index = 0
conn.tts_last_text_index = 0 conn.tts_last_text_index = 0
tts_file = await asyncio.to_thread(conn.tts.to_tts, text)
if tts_file is not None and os.path.exists(tts_file):
conn.tts_last_text_index = 1
opus_packets, _ = conn.tts.audio_to_opus_data(tts_file)
conn.audio_play_queue.put((opus_packets, None, 0))
os.remove(tts_file)
conn.llm_finish_task = True conn.llm_finish_task = True
if music_path.endswith(".p3"): if music_path.endswith(".p3"):
opus_packets, duration = p3.decode_opus_from_file(music_path) opus_packets, _ = p3.decode_opus_from_file(music_path)
else: else:
opus_packets, duration = conn.tts.audio_to_opus_data(music_path) opus_packets, _ = conn.tts.audio_to_opus_data(music_path)
conn.audio_play_queue.put((opus_packets, selected_music, 0)) conn.audio_play_queue.put((opus_packets, None, conn.tts_last_text_index))
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"播放音乐失败: {str(e)}") logger.bind(tag=TAG).error(f"播放音乐失败: {str(e)}")
+1
View File
@@ -25,3 +25,4 @@ sherpa_onnx==1.11.0
mcp==1.4.1 mcp==1.4.1
cnlunar==0.2.0 cnlunar==0.2.0
PySocks==1.7.1 PySocks==1.7.1
dashscope==1.23.1