Merge branch 'main' into manager-api-aly-message

This commit is contained in:
hrz
2025-05-18 17:42:09 +08:00
committed by GitHub
74 changed files with 3399 additions and 488 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
# 基于虾哥编译好的固件配置自定义服务器 # 基于虾哥编译好的固件配置自定义服务器
## 第1步 确认版本 ## 第1步 确认版本
烧录虾哥已经编译好的1.6.1版本固件 烧录虾哥已经编译好的[1.6.1版本以上固件](https://github.com/78/xiaozhi-esp32/releases)
## 第2步 准备你的ota地址 ## 第2步 准备你的ota地址
如果你按照教程使用的是全模块部署,就应该会有ota地址。 如果你按照教程使用的是全模块部署,就应该会有ota地址。
@@ -29,6 +29,7 @@ wss://2662r3426b.vicp.fun/xiaozhi/v1/
## 第3步 进入配网模式 ## 第3步 进入配网模式
进入机器的配网模式,在页面顶部,点击“高级选项”,在里面输入你服务器的`ota`地址,点击保存。重启设备 进入机器的配网模式,在页面顶部,点击“高级选项”,在里面输入你服务器的`ota`地址,点击保存。重启设备
![请参考-OTA地址设置](../docs/images/firmware-setting-ota.png)
## 第4步 唤醒小智,查看日志输出 ## 第4步 唤醒小智,查看日志输出
+72
View File
@@ -0,0 +1,72 @@
登录AutoDL,租赁镜像
选择镜像:
```
PyTorch / 2.1.0 / 3.10(ubuntu22.04) / cuda 12.1
```
机器开机后,设置学术加速
```
source /etc/network_turbo
```
进入工作目录
```
cd autodl-tmp/
```
拉取项目
```
git clone https://gitclone.com/github.com/fishaudio/fish-speech.git ; cd fish-speech
```
安装依赖
```
pip install -e.
```
如果报错,安装portaudio
```
apt-get install portaudio19-dev -y
```
安装后执行
```
pip install torch==2.3.1 torchvision==0.18.1 torchaudio==2.3.1 --index-url https://download.pytorch.org/whl/cu121
```
下载模型
```
cd tools
python download_models.py
```
下载完模型后运行接口
```
python -m tools.api_server --listen 0.0.0.0:6006
```
然后用浏览器去到aotodl实例页面
```
https://autodl.com/console/instance/list
```
如下图点击你刚才机器的`自定义服务`按钮,开启端口转发服务
![自定义服务](images/fishspeech/autodl-01.png)
端口转发服务设置完成后,你本地电脑打开网址`http://localhost:6006/`,就可以访问fish-speech的接口了
![服务预览](images/fishspeech/autodl-02.png)
如果你是单模块部署,核心配置如下
```
selected_module:
TTS: FishSpeech
TTS:
FishSpeech:
reference_audio: ["config/assets/wakeup_words.wav",]
reference_text: ["哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦",]
api_key: "123"
api_url: "http://127.0.0.1:6006/v1/tts"
```
然后重启服务
Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

@@ -0,0 +1,40 @@
package xiaozhi.common.config;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration
@EnableAsync
@EnableAspectJAutoProxy(exposeProxy = true)
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("AsyncThread-");
// 设置拒绝策略:由调用线程执行
executor.setRejectedExecutionHandler(new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
try {
// 如果线程池已满,则由调用线程执行
r.run();
} catch (Exception e) {
throw new RuntimeException("执行异步任务失败", e);
}
}
});
executor.initialize();
return executor;
}
}
@@ -1,5 +1,7 @@
package xiaozhi.common.constant; package xiaozhi.common.constant;
import lombok.Getter;
/** /**
* 常量 * 常量
* Copyright (c) 人人开源 All rights reserved. * Copyright (c) 人人开源 All rights reserved.
@@ -109,6 +111,11 @@ public interface Constant {
*/ */
String FILE_EXTENSION_SEG = "."; String FILE_EXTENSION_SEG = ".";
/**
* 无记忆
*/
String MEMORY_NO_MEM = "Memory_nomem";
enum SysBaseParam { enum SysBaseParam {
/** /**
* 系统全称 * 系统全称
@@ -214,8 +221,28 @@ public interface Constant {
} }
} }
@Getter
enum ChatHistoryConfEnum {
IGNORE(0, "不记录"),
RECORD_TEXT(1, "记录文本"),
RECORD_TEXT_AUDIO(2, "文本音频都记录");
private final int code;
private final String name;
ChatHistoryConfEnum(int code, String name) {
this.code = code;
this.name = name;
}
}
/** /**
* 版本号 * 版本号
*/ */
public static final String VERSION = "0.4.2"; public static final String VERSION = "0.4.4";
/**
* 无效固件URL
*/
String INVALID_FIRMWARE_URL = "http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL";
} }
@@ -1,7 +1,10 @@
package xiaozhi.common.exception; package xiaozhi.common.exception;
import org.apache.shiro.authz.UnauthorizedException; import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException;
@@ -10,6 +13,8 @@ import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
import java.util.List;
import java.util.Objects;
/** /**
* 异常处理器 * 异常处理器
* Copyright (c) 人人开源 All rights reserved. * Copyright (c) 人人开源 All rights reserved.
@@ -60,4 +65,16 @@ public class RenExceptionHandler {
return new Result<Void>().error(404, "资源不存在"); return new Result<Void>().error(404, "资源不存在");
} }
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
StringBuilder sb = new StringBuilder();
List<ObjectError> allErrors = ex.getBindingResult().getAllErrors();
String errorMsg = allErrors.stream()
.filter(Objects::nonNull)
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.findFirst()
.orElse("");
return new Result<Void>().error(400, errorMsg);
}
} }
@@ -1,6 +1,7 @@
package xiaozhi.common.service.impl; package xiaozhi.common.service.impl;
import java.io.Serializable; import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -18,6 +19,7 @@ import com.baomidou.mybatisplus.core.enums.SqlMethod;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.OrderItem; import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.baomidou.mybatisplus.core.toolkit.Constants; import com.baomidou.mybatisplus.core.toolkit.Constants;
import com.baomidou.mybatisplus.core.toolkit.ReflectionKit; import com.baomidou.mybatisplus.core.toolkit.ReflectionKit;
import com.baomidou.mybatisplus.core.toolkit.StringUtils; import com.baomidou.mybatisplus.core.toolkit.StringUtils;
@@ -45,6 +47,12 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
* @param params 分页查询参数 * @param params 分页查询参数
* @param defaultOrderField 默认排序字段 * @param defaultOrderField 默认排序字段
* @param isAsc 排序方式 * @param isAsc 排序方式
* @see xiaozhi.common.constant.Constant
* params.put(Constant.PAGE, "1");
* params.put(Constant.LIMIT, "10");
* params.put(Constant.ORDER_FIELD, "field"); // 单个字段
* params.put(Constant.ORDER_FIELD, List.of("field1", "field2")); // 多个字段
* params.put(Constant.ORDER, "asc");
*/ */
protected IPage<T> getPage(Map<String, Object> params, String defaultOrderField, boolean isAsc) { protected IPage<T> getPage(Map<String, Object> params, String defaultOrderField, boolean isAsc) {
// 分页参数 // 分页参数
@@ -65,28 +73,34 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
params.put(Constant.PAGE, page); params.put(Constant.PAGE, page);
// 排序字段 // 排序字段
String orderField = (String) params.get(Constant.ORDER_FIELD); Object orderField = params.get(Constant.ORDER_FIELD);
String order = (String) params.get(Constant.ORDER); String order = (String) params.get(Constant.ORDER);
// 前端字段排序 List<String> orderFields = new ArrayList<>();
if (StringUtils.isNotBlank(orderField) && StringUtils.isNotBlank(order)) {
if (Constant.ASC.equalsIgnoreCase(order)) { // 处理排序字段
return page.addOrder(OrderItem.asc(orderField)); if (orderField instanceof String) {
orderFields.add((String) orderField);
} else if (orderField instanceof List) {
orderFields.addAll((List<String>) orderField);
}
// 有排序字段则排序
if (CollectionUtils.isNotEmpty(orderFields)) {
if (StringUtils.isNotBlank(order) && Constant.ASC.equalsIgnoreCase(order)) {
return page.addOrder(OrderItem.ascs(orderFields.toArray(new String[0])));
} else { } else {
return page.addOrder(OrderItem.desc(orderField)); return page.addOrder(OrderItem.descs(orderFields.toArray(new String[0])));
} }
} }
// 没有排序字段,则不排序 // 没有排序字段,使用默认排序
if (StringUtils.isBlank(defaultOrderField)) { if (StringUtils.isNotBlank(defaultOrderField)) {
return page; if (isAsc) {
} page.addOrder(OrderItem.asc(defaultOrderField));
} else {
// 默认排序 page.addOrder(OrderItem.desc(defaultOrderField));
if (isAsc) { }
page.addOrder(OrderItem.asc(defaultOrderField));
} else {
page.addOrder(OrderItem.desc(defaultOrderField));
} }
return page; return page;
@@ -39,6 +39,7 @@ import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatSessionDTO; import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
import xiaozhi.modules.agent.dto.AgentCreateDTO; import xiaozhi.modules.agent.dto.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO; import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentTemplateEntity;
@@ -46,6 +47,7 @@ import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService; import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService; import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.security.user.SecurityUser; import xiaozhi.modules.security.user.SecurityUser;
@@ -109,6 +111,8 @@ public class AgentController {
entity.setMemModelId(template.getMemModelId()); entity.setMemModelId(template.getMemModelId());
entity.setIntentModelId(template.getIntentModelId()); entity.setIntentModelId(template.getIntentModelId());
entity.setSystemPrompt(template.getSystemPrompt()); entity.setSystemPrompt(template.getSystemPrompt());
entity.setSummaryMemory(template.getSummaryMemory());
entity.setChatHistoryConf(template.getChatHistoryConf());
entity.setLangCode(template.getLangCode()); entity.setLangCode(template.getLangCode());
entity.setLanguage(template.getLanguage()); entity.setLanguage(template.getLanguage());
} }
@@ -125,10 +129,26 @@ public class AgentController {
return new Result<String>().ok(entity.getId()); return new Result<String>().ok(entity.getId());
} }
@PutMapping("/saveMemory/{macAddress}")
@Operation(summary = "根据设备id更新智能体")
public Result<Void> updateByDeviceId(@PathVariable String macAddress, @RequestBody @Valid AgentMemoryDTO dto) {
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device == null) {
return new Result<>();
}
AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO();
agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory());
return updateAgentById(device.getAgentId(), agentUpdateDTO);
}
@PutMapping("/{id}") @PutMapping("/{id}")
@Operation(summary = "更新智能体") @Operation(summary = "更新智能体")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) { public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) {
return updateAgentById(id, dto);
}
private Result<Void> updateAgentById(String id, AgentUpdateDTO dto) {
// 先查询现有实体 // 先查询现有实体
AgentEntity existingEntity = agentService.getAgentById(id); AgentEntity existingEntity = agentService.getAgentById(id);
if (existingEntity == null) { if (existingEntity == null) {
@@ -166,6 +186,12 @@ public class AgentController {
if (dto.getSystemPrompt() != null) { if (dto.getSystemPrompt() != null) {
existingEntity.setSystemPrompt(dto.getSystemPrompt()); existingEntity.setSystemPrompt(dto.getSystemPrompt());
} }
if (dto.getSummaryMemory() != null) {
existingEntity.setSummaryMemory(dto.getSummaryMemory());
}
if (dto.getChatHistoryConf() != null) {
existingEntity.setChatHistoryConf(dto.getChatHistoryConf());
}
if (dto.getLangCode() != null) { if (dto.getLangCode() != null) {
existingEntity.setLangCode(dto.getLangCode()); existingEntity.setLangCode(dto.getLangCode());
} }
@@ -181,8 +207,16 @@ public class AgentController {
existingEntity.setUpdater(user.getId()); existingEntity.setUpdater(user.getId());
existingEntity.setUpdatedAt(new Date()); existingEntity.setUpdatedAt(new Date());
// 更新记忆策略
if (existingEntity.getMemModelId() == null || existingEntity.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
// 删除所有记录
agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, true);
existingEntity.setSummaryMemory("");
} else if (existingEntity.getChatHistoryConf() != null && existingEntity.getChatHistoryConf() == 1) {
// 删除音频数据
agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, false);
}
agentService.updateById(existingEntity); agentService.updateById(existingEntity);
return new Result<>(); return new Result<>();
} }
@@ -193,7 +227,7 @@ public class AgentController {
// 先删除关联的设备 // 先删除关联的设备
deviceService.deleteByAgentId(id); deviceService.deleteByAgentId(id);
// 删除关联的聊天记录 // 删除关联的聊天记录
agentChatHistoryService.deleteByAgentId(id); agentChatHistoryService.deleteByAgentId(id, true, true);
// 再删除智能体 // 再删除智能体
agentService.deleteById(id); agentService.deleteById(id);
return new Result<>(); return new Result<>();
@@ -28,4 +28,11 @@ public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity
* @param agentId 智能体ID * @param agentId 智能体ID
*/ */
void deleteHistoryByAgentId(String agentId); void deleteHistoryByAgentId(String agentId);
/**
* 根据智能体ID删除音频ID
*
* @param agentId 智能体ID
*/
void deleteAudioIdByAgentId(String agentId);
} }
@@ -27,9 +27,16 @@ public class AgentDTO {
@Schema(description = "大语言模型名称", example = "llm_model_01") @Schema(description = "大语言模型名称", example = "llm_model_01")
private String llmModelName; private String llmModelName;
@Schema(description = "记忆模型ID", example = "mem_model_01")
private String memModelId;
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助") @Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助")
private String systemPrompt; private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
private String summaryMemory;
@Schema(description = "最后连接时间", example = "2024-03-20 10:00:00") @Schema(description = "最后连接时间", example = "2024-03-20 10:00:00")
private Date lastConnectedAt; private Date lastConnectedAt;
@@ -0,0 +1,19 @@
package xiaozhi.modules.agent.dto;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 智能体记忆更新DTO
*/
@Data
@Schema(description = "智能体记忆更新对象")
public class AgentMemoryDTO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
private String summaryMemory;
}
@@ -45,6 +45,13 @@ public class AgentUpdateDTO implements Serializable {
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助", required = false) @Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助", required = false)
private String systemPrompt; private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
private String summaryMemory;
@Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)", example = "3", required = false)
private Integer chatHistoryConf;
@Schema(description = "语言编码", example = "zh_CN", required = false) @Schema(description = "语言编码", example = "zh_CN", required = false)
private String langCode; private String langCode;
@@ -48,9 +48,16 @@ public class AgentEntity {
@Schema(description = "意图模型标识") @Schema(description = "意图模型标识")
private String intentModelId; private String intentModelId;
@Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)")
private Integer chatHistoryConf;
@Schema(description = "角色设定参数") @Schema(description = "角色设定参数")
private String systemPrompt; private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
private String summaryMemory;
@Schema(description = "语言编码") @Schema(description = "语言编码")
private String langCode; private String langCode;
@@ -69,11 +69,20 @@ public class AgentTemplateEntity implements Serializable {
*/ */
private String intentModelId; private String intentModelId;
/**
* 聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)
*/
private Integer chatHistoryConf;
/** /**
* 角色设定参数 * 角色设定参数
*/ */
private String systemPrompt; private String systemPrompt;
/**
* 总结记忆
*/
private String summaryMemory;
/** /**
* 语言编码 * 语言编码
*/ */
@@ -39,7 +39,9 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
/** /**
* 根据智能体ID删除聊天记录 * 根据智能体ID删除聊天记录
* *
* @param agentId 智能体ID * @param agentId 智能体ID
* @param deleteAudio 是否删除音频
* @param deleteText 是否删除文本
*/ */
void deleteByAgentId(String agentId); void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText);
} }
@@ -2,12 +2,14 @@ package xiaozhi.modules.agent.service.biz.impl;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.Objects;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO; import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
@@ -47,8 +49,33 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
Byte chatType = report.getChatType(); Byte chatType = report.getChatType();
log.info("小智设备聊天上报请求: macAddress={}, type={}", macAddress, chatType); log.info("小智设备聊天上报请求: macAddress={}, type={}", macAddress, chatType);
// 1. base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表 // 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
if (agentEntity == null) {
return Boolean.FALSE;
}
Integer chatHistoryConf = agentEntity.getChatHistoryConf();
String agentId = agentEntity.getId();
if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT.getCode())) {
saveChatText(report, agentId, macAddress, null);
} else if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode())) {
String audioId = saveChatAudio(report);
saveChatText(report, agentId, macAddress, audioId);
}
// 更新设备最后对话时间
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
return Boolean.TRUE;
}
/**
* base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表
*/
private String saveChatAudio(AgentChatHistoryReportDTO report) {
String audioId = null; String audioId = null;
if (report.getAudioBase64() != null && !report.getAudioBase64().isEmpty()) { if (report.getAudioBase64() != null && !report.getAudioBase64().isEmpty()) {
try { try {
byte[] audioData = Base64.getDecoder().decode(report.getAudioBase64()); byte[] audioData = Base64.getDecoder().decode(report.getAudioBase64());
@@ -56,20 +83,18 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
log.info("音频数据保存成功,audioId={}", audioId); log.info("音频数据保存成功,audioId={}", audioId);
} catch (Exception e) { } catch (Exception e) {
log.error("音频数据保存失败", e); log.error("音频数据保存失败", e);
return false; return null;
} }
} }
return audioId;
}
// 2. 组装上报数据 /**
// 2.1 根据设备MAC地址查询对应的默认智能体,判断是否需要上报 * 组装上报数据
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress); */
if (agentEntity == null) { private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId) {
return false;
}
String agentId = agentEntity.getId();
log.info("设备 {} 对应智能体 {} 上报", macAddress, agentEntity.getId());
// 2.2 构建聊天记录实体 // 构建聊天记录实体
AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder() AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
.macAddress(macAddress) .macAddress(macAddress)
.agentId(agentId) .agentId(agentId)
@@ -79,10 +104,9 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
.audioId(audioId) .audioId(audioId)
.build(); .build();
// 3. 保存数据 // 保存数据
agentChatHistoryService.save(entity); agentChatHistoryService.save(entity);
// 4. 更新设备最后对话时间
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date()); log.info("设备 {} 对应智能体 {} 上报成功", macAddress, agentId);
return Boolean.TRUE;
} }
} }
@@ -78,8 +78,16 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void deleteByAgentId(String agentId) { public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
baseMapper.deleteAudioByAgentId(agentId); if (deleteAudio) {
baseMapper.deleteHistoryByAgentId(agentId); baseMapper.deleteAudioByAgentId(agentId);
}
if (deleteAudio && !deleteText) {
baseMapper.deleteAudioIdByAgentId(agentId);
}
if (deleteText) {
baseMapper.deleteHistoryByAgentId(agentId);
}
} }
} }
@@ -13,6 +13,7 @@ import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
@@ -46,7 +47,15 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
@Override @Override
public AgentEntity getAgentById(String id) { public AgentEntity getAgentById(String id) {
return agentDao.selectById(id); AgentEntity agent = agentDao.selectById(id);
if (agent != null && agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode());
} else if (agent != null && agent.getMemModelId() != null
&& !agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)
&& agent.getChatHistoryConf() == null) {
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode());
}
return agent;
} }
@Override @Override
@@ -93,6 +102,9 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
// 获取 LLM 模型名称 // 获取 LLM 模型名称
dto.setLlmModelName(modelConfigService.getModelNameById(agent.getLlmModelId())); dto.setLlmModelName(modelConfigService.getModelNameById(agent.getLlmModelId()));
// 获取记忆模型名称
dto.setMemModelId(agent.getMemModelId());
// 获取 TTS 音色名称 // 获取 TTS 音色名称
dto.setTtsVoiceName(timbreModelService.getTimbreNameById(agent.getTtsVoiceId())); dto.setTtsVoiceName(timbreModelService.getTimbreNameById(agent.getTtsVoiceId()));
@@ -9,6 +9,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
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.redis.RedisKeys; import xiaozhi.common.redis.RedisKeys;
@@ -64,6 +65,7 @@ public class ConfigServiceImpl implements ConfigService {
null, null,
null, null,
null, null,
null,
agent.getVadModelId(), agent.getVadModelId(),
agent.getAsrModelId(), agent.getAsrModelId(),
null, null,
@@ -108,6 +110,17 @@ public class ConfigServiceImpl implements ConfigService {
// 获取单台设备每天最多输出字数 // 获取单台设备每天最多输出字数
String deviceMaxOutputSize = sysParamsService.getValue("device_max_output_size", true); String deviceMaxOutputSize = sysParamsService.getValue("device_max_output_size", true);
result.put("device_max_output_size", deviceMaxOutputSize); result.put("device_max_output_size", deviceMaxOutputSize);
// 获取聊天记录配置
Integer chatHistoryConf = agent.getChatHistoryConf();
if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
chatHistoryConf = Constant.ChatHistoryConfEnum.IGNORE.getCode();
} else if (agent.getMemModelId() != null
&& !agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)
&& agent.getChatHistoryConf() == null) {
chatHistoryConf = Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode();
}
result.put("chat_history_conf", chatHistoryConf);
// 如果客户端已实例化模型,则不返回 // 如果客户端已实例化模型,则不返回
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())) {
@@ -122,6 +135,7 @@ public class ConfigServiceImpl implements ConfigService {
buildModuleConfig( buildModuleConfig(
agent.getAgentName(), agent.getAgentName(),
agent.getSystemPrompt(), agent.getSystemPrompt(),
agent.getSummaryMemory(),
voice, voice,
agent.getVadModelId(), agent.getVadModelId(),
agent.getAsrModelId(), agent.getAsrModelId(),
@@ -222,6 +236,7 @@ public class ConfigServiceImpl implements ConfigService {
private void buildModuleConfig( private void buildModuleConfig(
String assistantName, String assistantName,
String prompt, String prompt,
String summaryMemory,
String voice, String voice,
String vadModelId, String vadModelId,
String asrModelId, String asrModelId,
@@ -282,5 +297,6 @@ public class ConfigServiceImpl implements ConfigService {
prompt = prompt.replace("{{assistant_name}}", StringUtils.isBlank(assistantName) ? "小智" : assistantName); prompt = prompt.replace("{{assistant_name}}", StringUtils.isBlank(assistantName) ? "小智" : assistantName);
} }
result.put("prompt", prompt); result.put("prompt", prompt);
result.put("summaryMemory", summaryMemory);
} }
} }
@@ -9,6 +9,8 @@ import java.util.TimeZone;
import java.util.UUID; import java.util.UUID;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.aop.framework.AopContext;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.ServletRequestAttributes;
@@ -54,6 +56,24 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
private final OtaService otaService; private final OtaService otaService;
@Async
public void updateDeviceConnectionInfo(String agentId, String deviceId, String appVersion) {
try {
DeviceEntity device = new DeviceEntity();
device.setId(deviceId);
device.setLastConnectedAt(new Date());
if (StringUtils.isNotBlank(appVersion)) {
device.setAppVersion(appVersion);
}
deviceDao.updateById(device);
if (StringUtils.isNotBlank(agentId)) {
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
}
} catch (Exception e) {
log.error("异步更新设备连接信息失败", e);
}
}
@Override @Override
public Boolean deviceActivation(String agentId, String activationCode) { public Boolean deviceActivation(String agentId, String activationCode) {
if (StringUtils.isBlank(activationCode)) { if (StringUtils.isBlank(activationCode)) {
@@ -118,14 +138,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
DeviceEntity deviceById = getDeviceByMacAddress(macAddress); DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
// 只有在设备绑定且autoUpdate不为0的情况下才返回固件升级信息 // 设备绑定,则返回当前上传的固件信息(不更新)以此兼容旧固件版本
if (deviceById != null && deviceById.getAutoUpdate() != 0) { if (deviceById == null) {
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType(); DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type, firmware.setVersion(deviceReport.getApplication().getVersion());
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion()); firmware.setUrl(Constant.INVALID_FIRMWARE_URL);
response.setFirmware(firmware); response.setFirmware(firmware);
} else { } else {
response.setFirmware(null); // 只有在设备已绑定且autoUpdate不为0的情况下才返回固件升级信息
if (deviceById.getAutoUpdate() != 0) {
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
response.setFirmware(firmware);
}
} }
// 添加WebSocket配置 // 添加WebSocket配置
@@ -150,13 +176,12 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
response.setWebsocket(websocket); response.setWebsocket(websocket);
if (deviceById != null) { if (deviceById != null) {
// 如果设备存在,则更新上次连接时间 // 如果设备存在,则异步更新上次连接时间和版本信息
deviceById.setLastConnectedAt(new Date()); String appVersion = deviceReport.getApplication() != null ? deviceReport.getApplication().getVersion()
if (deviceReport.getApplication() != null : null;
&& StringUtils.isNotBlank(deviceReport.getApplication().getVersion())) { // 通过Spring代理调用异步方法
deviceById.setAppVersion(deviceReport.getApplication().getVersion()); ((DeviceServiceImpl) AopContext.currentProxy()).updateDeviceConnectionInfo(deviceById.getAgentId(),
} deviceById.getId(), appVersion);
deviceDao.updateById(deviceById);
} else { } else {
// 如果设备不存在,则生成激活码 // 如果设备不存在,则生成激活码
DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport); DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
@@ -353,7 +378,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
} }
firmware.setVersion(ota == null ? currentVersion : ota.getVersion()); firmware.setVersion(ota == null ? currentVersion : ota.getVersion());
firmware.setUrl(downloadUrl == null ? "" : downloadUrl); firmware.setUrl(downloadUrl == null ? Constant.INVALID_FIRMWARE_URL : downloadUrl);
return firmware; return firmware;
} }
@@ -0,0 +1,68 @@
package xiaozhi.modules.model.controller;
import java.util.List;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.group.UpdateGroup;
import xiaozhi.modules.model.dto.ModelProviderDTO;
import xiaozhi.modules.model.service.ModelProviderService;
@AllArgsConstructor
@RestController
@RequestMapping("/models/provider")
@Tag(name = "模型供应器")
public class ModelProviderController {
private final ModelProviderService modelProviderService;
@GetMapping
@Operation(summary = "获取模型供应器列表")
@RequiresPermissions("sys:role:superAdmin")
public Result<PageData<ModelProviderDTO>> getListPage(ModelProviderDTO modelProviderDTO,
@RequestParam(required = true, defaultValue = "0") String page,
@RequestParam(required = true, defaultValue = "10") String limit) {
return new Result<PageData<ModelProviderDTO>>()
.ok(modelProviderService.getListPage(modelProviderDTO, page, limit));
}
@PostMapping
@Operation(summary = "新增模型供应器")
@RequiresPermissions("sys:role:superAdmin")
public Result<ModelProviderDTO> add(@RequestBody @Validated ModelProviderDTO modelProviderDTO) {
ModelProviderDTO resp = modelProviderService.add(modelProviderDTO);
return new Result<ModelProviderDTO>().ok(resp);
}
@PutMapping
@Operation(summary = "修改模型供应器")
@RequiresPermissions("sys:role:superAdmin")
public Result<ModelProviderDTO> edit(@RequestBody @Validated(UpdateGroup.class) ModelProviderDTO modelProviderDTO) {
ModelProviderDTO resp = modelProviderService.edit(modelProviderDTO);
return new Result<ModelProviderDTO>().ok(resp);
}
@PostMapping("/delete")
@Operation(summary = "删除模型供应器")
@RequiresPermissions("sys:role:superAdmin")
@Parameter(name = "ids", description = "ID数组", required = true)
public Result<Void> delete(@RequestBody List<String> ids) {
modelProviderService.delete(ids);
return new Result<>();
}
}
@@ -8,29 +8,37 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import xiaozhi.common.validator.group.UpdateGroup;
@Data @Data
@Schema(description = "模型供应器/商") @Schema(description = "模型供应器/商")
public class ModelProviderDTO implements Serializable { public class ModelProviderDTO implements Serializable {
// @Schema(description = "主键")
// @Schema(description = "主键") @NotBlank(message = "id不能为空", groups = UpdateGroup.class)
// private Long id; private String id;
@Schema(description = "模型类型(Memory/ASR/VAD/LLM/TTS)") @Schema(description = "模型类型(Memory/ASR/VAD/LLM/TTS)")
@NotBlank(message = "modelType不能为空")
private String modelType; private String modelType;
@Schema(description = "供应器类型") @Schema(description = "供应器类型")
@NotBlank(message = "providerCode不能为空")
private String providerCode; private String providerCode;
@Schema(description = "供应器名称") @Schema(description = "供应器名称")
@NotBlank(message = "name不能为空")
private String name; private String name;
@Schema(description = "供应器字段列表(JSON格式)") @Schema(description = "供应器字段列表(JSON格式)")
@TableField(typeHandler = JacksonTypeHandler.class) @TableField(typeHandler = JacksonTypeHandler.class)
@NotBlank(message = "fields(JSON格式)不能为空")
private String fields; private String fields;
@Schema(description = "排序") @Schema(description = "排序")
@NotNull(message = "sort不能为空")
private Integer sort; private Integer sort;
@Schema(description = "更新者") @Schema(description = "更新者")
@@ -3,10 +3,8 @@ package xiaozhi.modules.model.entity;
import java.util.Date; import java.util.Date;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -30,7 +28,6 @@ public class ModelProviderEntity {
private String name; private String name;
@Schema(description = "供应器字段列表(JSON格式)") @Schema(description = "供应器字段列表(JSON格式)")
@TableField(typeHandler = JacksonTypeHandler.class)
private String fields; private String fields;
@Schema(description = "排序") @Schema(description = "排序")
@@ -2,8 +2,8 @@ package xiaozhi.modules.model.service;
import java.util.List; import java.util.List;
import xiaozhi.common.page.PageData;
import xiaozhi.modules.model.dto.ModelProviderDTO; import xiaozhi.modules.model.dto.ModelProviderDTO;
import xiaozhi.modules.model.entity.ModelProviderEntity;
public interface ModelProviderService { public interface ModelProviderService {
@@ -11,11 +11,15 @@ public interface ModelProviderService {
List<ModelProviderDTO> getListByModelType(String modelType); List<ModelProviderDTO> getListByModelType(String modelType);
ModelProviderDTO add(ModelProviderEntity modelProviderEntity); ModelProviderDTO add(ModelProviderDTO modelProviderDTO);
ModelProviderDTO edit(ModelProviderEntity modelProviderEntity); ModelProviderDTO edit(ModelProviderDTO modelProviderDTO);
void delete(); void delete(String id);
void delete(List<String> id);
PageData<ModelProviderDTO> getListPage(ModelProviderDTO modelProviderDTO, String page, String limit);
List<ModelProviderDTO> getList(String modelType, String provideCode); List<ModelProviderDTO> getList(String modelType, String provideCode);
} }
@@ -1,19 +1,29 @@
package xiaozhi.modules.model.service.impl; package xiaozhi.modules.model.service.impl;
import java.util.Date;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.hutool.json.JSONArray;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.model.dao.ModelProviderDao; import xiaozhi.modules.model.dao.ModelProviderDao;
import xiaozhi.modules.model.dto.ModelProviderDTO; import xiaozhi.modules.model.dto.ModelProviderDTO;
import xiaozhi.modules.model.entity.ModelProviderEntity; import xiaozhi.modules.model.entity.ModelProviderEntity;
import xiaozhi.modules.model.service.ModelProviderService; import xiaozhi.modules.model.service.ModelProviderService;
import xiaozhi.modules.security.user.SecurityUser;
@Service @Service
@AllArgsConstructor @AllArgsConstructor
@@ -32,18 +42,78 @@ public class ModelProviderServiceImpl extends BaseServiceImpl<ModelProviderDao,
} }
@Override @Override
public ModelProviderDTO add(ModelProviderEntity modelProviderEntity) { public PageData<ModelProviderDTO> getListPage(ModelProviderDTO modelProviderDTO, String page, String limit) {
return null;
Map<String, Object> params = new HashMap<String, Object>();
params.put(Constant.PAGE, page);
params.put(Constant.LIMIT, limit);
params.put(Constant.ORDER_FIELD, List.of("model_type", "sort"));
params.put(Constant.ORDER, "asc");
IPage<ModelProviderEntity> pageParam = getPage(params, null, true);
QueryWrapper<ModelProviderEntity> wrapper = new QueryWrapper<ModelProviderEntity>();
if (StringUtils.isNotBlank(modelProviderDTO.getModelType())) {
wrapper.eq("model_type", modelProviderDTO.getModelType());
}
if (StringUtils.isNotBlank(modelProviderDTO.getName())) {
wrapper.and(w -> w.like("name", modelProviderDTO.getName())
.or()
.like("provider_code", modelProviderDTO.getName()));
}
return getPageData(modelProviderDao.selectPage(pageParam, wrapper), ModelProviderDTO.class);
}
public static void main(String[] args) {
String jsonString = "\"[]\"";
JSONArray jsonArray = new JSONArray(jsonString);
System.out.println("字符串转 JSONArray: " + jsonArray.toString());
} }
@Override @Override
public ModelProviderDTO edit(ModelProviderEntity modelProviderEntity) { public ModelProviderDTO add(ModelProviderDTO modelProviderDTO) {
return null; UserDetail user = SecurityUser.getUser();
modelProviderDTO.setCreator(user.getId());
modelProviderDTO.setUpdater(user.getId());
modelProviderDTO.setCreateDate(new Date());
modelProviderDTO.setUpdateDate(new Date());
// 去除Fields左右的双引号
modelProviderDTO.setFields(modelProviderDTO.getFields());
ModelProviderEntity entity = ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderEntity.class);
if (modelProviderDao.insert(entity) == 0) {
throw new RenException("新增数据失败");
}
return ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderDTO.class);
} }
@Override @Override
public void delete() { public ModelProviderDTO edit(ModelProviderDTO modelProviderDTO) {
UserDetail user = SecurityUser.getUser();
modelProviderDTO.setUpdater(user.getId());
modelProviderDTO.setUpdateDate(new Date());
if (modelProviderDao
.updateById(ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderEntity.class)) == 0) {
throw new RenException("修改数据失败");
}
return ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderDTO.class);
}
@Override
public void delete(String id) {
if (modelProviderDao.deleteById(id) == 0) {
throw new RenException("删除数据失败");
}
}
@Override
public void delete(List<String> ids) {
if (modelProviderDao.deleteBatchIds(ids) == 0) {
throw new RenException("删除数据失败");
}
} }
@Override @Override
@@ -87,6 +87,7 @@ public class ShiroConfig {
// 将config路径使用server服务过滤器 // 将config路径使用server服务过滤器
filterMap.put("/config/**", "server"); filterMap.put("/config/**", "server");
filterMap.put("/agent/chat-history/report", "server"); filterMap.put("/agent/chat-history/report", "server");
filterMap.put("/agent/saveMemory/**", "server");
filterMap.put("/agent/play/**", "anon"); filterMap.put("/agent/play/**", "anon");
filterMap.put("/**", "oauth2"); filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap); shiroFilter.setFilterChainDefinitionMap(filterMap);
@@ -159,7 +159,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
params.put(Constant.LIMIT, dto.getLimit()); params.put(Constant.LIMIT, dto.getLimit());
IPage<SysUserEntity> page = baseDao.selectPage( IPage<SysUserEntity> page = baseDao.selectPage(
getPage(params, "id", true), getPage(params, "id", true),
new QueryWrapper<SysUserEntity>().eq(StringUtils.isNotBlank(dto.getMobile()), "username", new QueryWrapper<SysUserEntity>().like(StringUtils.isNotBlank(dto.getMobile()), "username",
dto.getMobile())); dto.getMobile()));
// 循环处理page获取回来的数据,返回需要的字段 // 循环处理page获取回来的数据,返回需要的字段
List<AdminPageUserVO> list = page.getRecords().stream().map(user -> { List<AdminPageUserVO> list = page.getRecords().stream().map(user -> {
@@ -0,0 +1,25 @@
-- 更新模型供应器表
UPDATE `ai_model_provider` SET fields = '[{"key": "host", "type": "string", "label": "服务地址"}, {"key": "port", "type": "number", "label": "端口号"}, {"key": "type", "type": "string", "label": "服务类型"}, {"key": "is_ssl", "type": "boolean", "label": "是否使用SSL"}, {"key": "api_key", "type": "string", "label": "API密钥"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]' WHERE id = 'SYSTEM_ASR_FunASRServer';
-- 更新模型配置表
UPDATE `ai_model_config` SET
config_json = '{"host": "127.0.0.1", "port": 10096, "type": "fun_server", "is_ssl": true, "api_key": "none", "output_dir": "tmp/"}',
`doc_link` = 'https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md',
`remark` = '独立部署FunASR,使用FunASR的API服务,只需要五句话
第一句:mkdir -p ./funasr-runtime-resources/models
第二句:sudo docker run -p 10096:10095 -it --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12
上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime
不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 &
上一句话执行后会进入到容器,继续第五句:tail -f log.txt
第五句话执行完后,会看到模型下载日志,下载完后就可以连接使用了
以上是使用CPU推理,如果有GPU,详细参考:https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md' WHERE `id` = 'ASR_FunASRServer';
-- FishSpeech配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/fish-speech-integration.md',
`remark` = 'FishSpeech配置说明:
1. 需要本地部署FishSpeech服务
2. 支持自定义音色
3. 本地推理,无需网络连接
4. 输出文件保存在tmp/目录
5. 可参照教程https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/fish-speech-integration.md' WHERE `id` = 'TTS_FishSpeech';
@@ -0,0 +1,6 @@
-- 添加聊天记录配置字段
ALTER TABLE `ai_agent`
ADD COLUMN `chat_history_conf` tinyint NOT NULL DEFAULT 0 COMMENT '聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)' AFTER `system_prompt`;
ALTER TABLE `ai_agent_template`
ADD COLUMN `chat_history_conf` tinyint NOT NULL DEFAULT 0 COMMENT '聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)' AFTER `system_prompt`;
@@ -0,0 +1,6 @@
-- 添加总结记忆字段
ALTER TABLE `ai_agent`
ADD COLUMN `summary_memory` text COMMENT '总结记忆' AFTER `system_prompt`;
ALTER TABLE `ai_agent_template`
ADD COLUMN `summary_memory` text COMMENT '总结记忆' AFTER `system_prompt`;
@@ -0,0 +1,7 @@
update ai_agent_template set system_prompt = replace(system_prompt, '我是', '你是');
delete from sys_params where id in (500,501,402);
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (500, 'end_prompt.enable', 'true', 'boolean', 1, '是否开启结束语');
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (501, 'end_prompt.prompt', '请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!', 'string', 1, '结束提示词');
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (402, 'plugins.get_weather.api_host', 'mj7p3y7naa.re.qweatherapi.com', 'string', 1, '开发者apihost');
@@ -121,3 +121,31 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202505141132.sql path: classpath:db/changelog/202505141132.sql
- changeSet:
id: 202505091555
author: whosmyqueen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505091555.sql
- changeSet:
id: 202505111914
author: hrz
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505111914.sql
- changeSet:
id: 202505122348
author: ljwwd2
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505122348.sql
- changeSet:
id: 202505142037
author: hrz
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505142037.sql
@@ -28,11 +28,17 @@
SELECT audio_id SELECT audio_id
FROM ai_agent_chat_history FROM ai_agent_chat_history
WHERE agent_id = #{agentId} WHERE agent_id = #{agentId}
); )
</delete> </delete>
<update id="deleteAudioIdByAgentId">
UPDATE ai_agent_chat_history
SET audio_id = NULL
WHERE agent_id = #{agentId}
</update>
<delete id="deleteHistoryByAgentId"> <delete id="deleteHistoryByAgentId">
DELETE FROM ai_agent_chat_history DELETE FROM ai_agent_chat_history
WHERE agent_id = #{agentId}; WHERE agent_id = #{agentId}
</delete> </delete>
</mapper> </mapper>
+3 -3
View File
@@ -50,9 +50,9 @@ export default {
}).send(); }).send();
}, },
// 获取智能体配置 // 获取智能体配置
getDeviceConfig(deviceId, callback) { getDeviceConfig(agentId, callback) {
RequestService.sendRequest() RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${deviceId}`) .url(`${getServiceUrl()}/agent/${agentId}`)
.method('GET') .method('GET')
.success((res) => { .success((res) => {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
@@ -61,7 +61,7 @@ export default {
.networkFail((err) => { .networkFail((err) => {
console.error('获取配置失败:', err); console.error('获取配置失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getDeviceConfig(deviceId, callback); this.getDeviceConfig(agentId, callback);
}); });
}).send(); }).send();
}, },
+108 -1
View File
@@ -197,5 +197,112 @@ export default {
this.setDefaultModel(id, callback) this.setDefaultModel(id, callback)
}) })
}).send() }).send()
} },
/**
* 获取模型配置列表(支持查询参数)
* @param {Object} params - 查询参数对象,例如 { name: 'test', modelType: 1 }
* @param {Function} callback - 回调函数
*/
getModelProvidersPage(params, callback) {
// 构建查询参数
const queryParams = new URLSearchParams();
if (params.name) queryParams.append('name', params.name);
if (params.modelType !== undefined) queryParams.append('modelType', params.modelType);
if (params.page !== undefined) queryParams.append('page', params.page);
if (params.limit !== undefined) queryParams.append('limit', params.limit);
RequestService.sendRequest()
.url(`${getServiceUrl()}/models/provider?${queryParams.toString()}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail((err) => {
this.$message.error(err.msg || '获取供应器列表失败');
RequestService.reAjaxFun(() => {
this.getModelProviders(params, callback);
});
}).send();
},
/**
* 新增模型供应器配置
* @param {Object} params - 请求参数对象,例如 { modelType: '1', providerCode: '1', name: '1', fields: '1', sort: 1 }
* @param {Function} callback - 成功回调函数
*/
addModelProvider(params, callback) {
const postData = {
modelType: params.modelType || '',
providerCode: params.providerCode || '',
name: params.name || '',
fields: JSON.stringify(params.fields || []),
sort: params.sort || 0
};
RequestService.sendRequest()
.url(`${getServiceUrl()}/models/provider`)
.method('POST')
.data(postData)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail((err) => {
console.error('新增模型供应器失败:', err)
this.$message.error(err.msg || '新增模型供应器失败')
RequestService.reAjaxFun(() => {
this.addModelProvider(params, callback);
});
}).send();
},
/**
* 更新模型供应器配置
* @param {Object} params - 请求参数对象,例如 { id: '111', modelType: '1', providerCode: '1', name: '1', fields: '1', sort: 1 }
* @param {Function} callback - 成功回调函数
*/
updateModelProvider(params, callback) {
const putData = {
id: params.id || '',
modelType: params.modelType || '',
providerCode: params.providerCode || '',
name: params.name || '',
fields: JSON.stringify(params.fields || []),
sort: params.sort || 0
};
RequestService.sendRequest()
.url(`${getServiceUrl()}/models/provider`)
.method('PUT')
.data(putData)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail((err) => {
this.$message.error(err.msg || '更新模型供应器失败')
RequestService.reAjaxFun(() => {
this.updateModelProvider(params, callback);
});
}).send();
},
// 删除
deleteModelProviderByIds(ids, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/models/provider/delete`)
.method('POST')
.data(ids)
.success((res) => {
RequestService.clearRequestTime()
callback(res);
})
.networkFail((err) => {
this.$message.error(err.msg || '删除模型供应器失败')
RequestService.reAjaxFun(() => {
this.deleteModelProviderByIds(ids, callback)
})
}).send()
},
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 88 KiB

+15 -2
View File
@@ -26,8 +26,12 @@
<div class="settings-btn" @click="handleDeviceManage"> <div class="settings-btn" @click="handleDeviceManage">
设备管理({{ device.deviceCount }}) 设备管理({{ device.deviceCount }})
</div> </div>
<div class="settings-btn" @click="handleChatHistory"> <div class="settings-btn" @click="handleChatHistory"
聊天记录 :class="{ 'disabled-btn': device.memModelId === 'Memory_nomem' }">
<el-tooltip v-if="device.memModelId === 'Memory_nomem'" content="未开启记忆" placement="top">
<span>聊天记录</span>
</el-tooltip>
<span v-else>聊天记录</span>
</div> </div>
</div> </div>
<div class="version-info"> <div class="version-info">
@@ -77,6 +81,9 @@ export default {
this.$router.push({ path: '/device-management', query: { agentId: this.device.agentId } }); this.$router.push({ path: '/device-management', query: { agentId: this.device.agentId } });
}, },
handleChatHistory() { handleChatHistory() {
if (this.device.memModelId === 'Memory_nomem') {
return
}
this.$emit('chat-history', { agentId: this.device.agentId, agentName: this.device.agentName }) this.$emit('chat-history', { agentId: this.device.agentId, agentName: this.device.agentName })
} }
} }
@@ -120,6 +127,12 @@ export default {
color: #979db1; color: #979db1;
font-weight: 400; font-weight: 400;
} }
.disabled-btn {
background: #e6e6e6;
color: #999;
cursor: not-allowed;
}
</style> </style>
<style> <style>
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose" @open="handleOpen"> <el-dialog :title="title" :visible.sync="dialogVisible" @close="handleClose" @open="handleOpen">
<el-form ref="form" :model="form" :rules="rules" label-width="100px"> <el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="固件名称" prop="firmwareName"> <el-form-item label="固件名称" prop="firmwareName">
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input> <el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
@@ -22,7 +22,7 @@
<el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress" <el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress"
:status="uploadStatus"></el-progress> :status="uploadStatus"></el-progress>
<div class="hint-text"> <div class="hint-text">
<span>温馨提示请上传xiaozhi.bin文件而不是merged-binary.bin文件</span> <span>温馨提示请上传合并前的xiaozhi.bin文件而不是合并后的merged-binary.bin文件</span>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="remark"> <el-form-item label="备注" prop="remark">
@@ -198,13 +198,13 @@ export default {
if (!this.form.id) { // 只在新增时重置 if (!this.form.id) { // 只在新增时重置
this.form.firmwarePath = '' this.form.firmwarePath = ''
this.form.size = 0 this.form.size = 0
// 重置上传组件
this.$nextTick(() => {
if (this.$refs.upload) {
this.$refs.upload.clearFiles()
}
})
} }
// 无论是否编辑模式,都重置上传组件
this.$nextTick(() => {
if (this.$refs.upload) {
this.$refs.upload.clearFiles()
}
})
} }
} }
} }
@@ -229,7 +229,6 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: #979db1; font-size: 14px;
font-size: 11px;
} }
</style> </style>
@@ -0,0 +1,410 @@
<template>
<el-drawer :visible.sync="dialogVisible" direction="rtl" size="50%" :wrapperClosable="false" :withHeader="false">
<!-- 自定义标题区域 -->
<div class="custom-header">
<div class="header-left">
<h3 class="bold-title">功能管理</h3>
</div>
<button class="custom-close-btn" @click="closeDialog">×</button>
</div>
<div class="function-manager">
<!-- 左侧未选功能 -->
<div class="function-column">
<div class="column-header">
<h4 class="column-title">未选功能</h4>
<el-button type="text" @click="selectAll" class="select-all-btn">全选</el-button>
</div>
<div class="function-list">
<div v-for="func in unselected" :key="func.name" class="function-item">
<el-checkbox :label="func.name" v-model="selectedNames" @change="(val) => handleCheckboxChange(func, val)" @click.native.stop></el-checkbox>
<div class="func-tag" @click="handleFunctionClick(func)">
<div class="color-dot" :style="{backgroundColor: getFunctionColor(func.name)}"></div>
<span>{{ func.name }}</span>
</div>
<el-tooltip class="item" effect="dark" :content="func.description || '暂无功能描述'" placement="top">
<img src="@/assets/home/info.png" alt="" class="info-icon">
</el-tooltip>
</div>
</div>
</div>
<!-- 中间已选功能 -->
<div class="function-column">
<div class="column-header">
<h4 class="column-title">已选功能</h4>
<el-button type="text" @click="deselectAll" class="select-all-btn">全选</el-button>
</div>
<div class="function-list">
<div v-for="func in selectedList" :key="func.name" class="function-item">
<el-checkbox :label="func.name" v-model="selectedNames" @change="(val) => handleCheckboxChange(func, val)" @click.native.stop></el-checkbox>
<div class="func-tag" @click="handleFunctionClick(func)">
<div class="color-dot" :style="{backgroundColor: getFunctionColor(func.name)}"></div>
<span>{{ func.name }}</span>
</div>
</div>
</div>
</div>
<!-- 右侧参数配置 -->
<div class="params-column">
<h4 v-if="currentFunction" class="column-title">参数配置 - {{ currentFunction.name }}</h4>
<div v-if="currentFunction" class="params-container">
<el-form :model="currentFunction" size="mini" class="param-form" v-loading="loading" element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading" element-loading-background="rgba(255, 255, 255, 0.7)">
<el-form-item v-for="(value, key) in currentFunction.params" :key="key" :label="key" class="param-item">
<el-input v-model="currentFunction.params[key]" size="mini" class="param-input" @change="(val) => handleParamChange(currentFunction, key, val)"/>
</el-form-item>
</el-form>
</div>
<div v-else class="empty-tip">请选择已配置的功能进行参数设置</div>
</div>
</div>
<div class="drawer-footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="saveSelection">保存配置</el-button>
</div>
</el-drawer>
</template>
<script>
export default {
props: {
value: Boolean,
functions: {
type: Array,
default: () => []
}
},
data() {
return {
dialogVisible: this.value,
selectedNames: [],
currentFunction: null,
modifiedFunctions: {},
allFunctions: [
{name: '天气', params: {city: '北京'}, description: '查看指定城市的天气情况'},
{name: '新闻', params: {type: '科技'}, description: '获取最新科技类新闻资讯'},
{name: '工具', params: {category: '常用'}, description: '提供常用工具集合'},
{name: '退出', params: {}, description: '退出当前系统'},
{name: '音乐', params: {genre: '流行'}, description: '播放流行音乐'},
{name: '翻译', params: {from: '中文', to: '英文'}, description: '提供中英文互译功能'},
{name: '计算', params: {precision: '2'}, description: '提供精确计算功能'},
{name: '日历', params: {view: '月'}, description: '查看月历视图'}
],
functionColorMap: [
'#FF6B6B', '#4ECDC4', '#45B7D1',
'#96CEB4', '#FFEEAD', '#D4A5A5', '#A2836E'
],
tempFunctions: {},
// 添加一个标志位来跟踪是否已经保存
hasSaved: false,
loading: false,
}
},
computed: {
selectedList() {
return this.allFunctions.filter(f => this.selectedNames.includes(f.name));
},
unselected() {
return this.allFunctions.filter(f => !this.selectedNames.includes(f.name));
}
},
watch: {
value(newVal) {
this.dialogVisible = newVal;
if (newVal) {
this.selectedNames = this.functions.map(f => f.name);
this.currentFunction = this.selectedList[0] || null;
}
},
dialogVisible(newVal) {
this.$emit('input', newVal);
}
},
methods: {
handleFunctionClick(func) {
if (this.selectedNames.includes(func.name)) {
this.loading = true;
setTimeout(() => {
const tempFunc = this.tempFunctions[func.name];
this.currentFunction = tempFunc ? tempFunc : JSON.parse(JSON.stringify(func));
this.loading = false;
}, 300);
}
},
handleParamChange(func, key, value) {
if (!this.tempFunctions[func.name]) {
this.tempFunctions[func.name] = JSON.parse(JSON.stringify(func));
}
this.tempFunctions[func.name].params[key] = value;
},
handleCheckboxChange(func, checked) {
if (checked) {
if (!this.selectedNames.includes(func.name)) {
this.selectedNames = [...this.selectedNames, func.name];
}
} else {
this.selectedNames = this.selectedNames.filter(name => name !== func.name);
}
if (this.selectedList.length > 0) {
this.currentFunction = this.selectedList[0];
} else {
this.currentFunction = null;
}
},
selectAll() {
this.selectedNames = [...this.allFunctions.map(f => f.name)];
if (this.selectedList.length > 0) {
this.currentFunction = JSON.parse(JSON.stringify(this.selectedList[0]));
}
},
deselectAll() {
this.selectedNames = [];
this.currentFunction = null;
},
closeDialog() {
this.tempFunctions = {};
this.selectedNames = this.functions.map(f => f.name);
this.currentFunction = null;
this.dialogVisible = false;
this.$emit('input', false);
this.$emit('dialog-closed', false);
},
saveSelection() {
Object.keys(this.tempFunctions).forEach(name => {
this.modifiedFunctions[name] = JSON.parse(JSON.stringify(this.tempFunctions[name]));
});
this.tempFunctions = {};
this.hasSaved = true;
const selected = this.selectedList.map(f => {
const modified = this.modifiedFunctions[f.name];
return modified || f;
}).map(f => ({
...f,
params: JSON.parse(JSON.stringify(f.params))
}));
this.$emit('update-functions', selected);
this.dialogVisible = false;
this.$message.success('配置保存成功');
// 通知父组件对话框已关闭且已保存
this.$emit('dialog-closed', true);
},
getFunctionColor(name) {
const hash = [...name].reduce((acc, char) => acc + char.charCodeAt(0), 0);
return this.functionColorMap[hash % 7];
}
}
}
</script>
<style lang="scss" scoped>
.function-manager {
display: grid;
grid-template-columns: minmax(120px, 0.5fr) minmax(120px, 0.5fr) minmax(200px, 2fr);
gap: 12px;
height: calc(70vh - 60px);
}
.custom-header {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #EBEEF5;
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.bold-title {
font-size: 18px;
font-weight: bold;
margin: 0;
}
.select-all-btn {
padding: 0;
height: auto;
font-size: 14px;
}
}
.function-column {
position: relative;
width: auto;
padding: 10px;
overflow-y: auto;
border-right: 1px solid #EBEEF5;
scrollbar-width: none;
}
.function-column::-webkit-scrollbar {
display: none;
}
.function-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.function-item {
padding: 8px 12px;
margin: 4px 0;
width: 100%;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
background-color: #f5f7fa;
}
}
.params-column {
min-width: 280px;
padding: 10px;
overflow-y: auto;
scrollbar-width: none;
}
.params-column::-webkit-scrollbar {
display: none;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.column-title {
text-align: center;
width: 100%;
}
.func-tag {
display: flex;
align-items: center;
cursor: pointer;
flex-grow: 1;
margin-left: 8px;
}
.color-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
margin-right: 8px;
border-radius: 50%;
}
.param-form {
::v-deep .el-form-item {
display: flex;
align-items: center;
margin-bottom: 12px;
.el-form-item__label {
font-size: 14px !important;
color: #606266;
text-align: left;
padding-right: 10px;
flex-shrink: 0;
width: auto !important;
}
.el-form-item__content {
margin-left: 0 !important;
flex-grow: 1;
.el-input__inner {
text-align: left;
padding-left: 8px;
width: 100%;
}
}
}
}
.params-container {
padding: 16px;
border-radius: 4px;
min-width: 280px;
}
.empty-tip {
padding: 20px;
color: #909399;
text-align: center;
}
.param-input {
width: 100%;
}
.drawer-footer {
position: absolute;
bottom: 0;
width: 100%;
border-top: 1px solid #e8e8e8;
padding: 10px 16px;
text-align: center;
background: #fff;
}
.info-icon {
width: 16px;
height: 16px;
margin-right: 1vh;
}
.custom-close-btn {
position: absolute;
top: 50%;
right: 10px;
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;
transition: all 0.3s;
}
.custom-close-btn:hover {
color: #409EFF;
border-color: #409EFF;
}
::v-deep .el-checkbox__label {
display: none;
}
</style>
+57 -63
View File
@@ -35,12 +35,12 @@
OTA管理 OTA管理
</div> </div>
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown" <el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown"
:class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' }"> :class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' }" @visible-change="handleParamDropdownVisibleChange">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
<img loading="lazy" alt="" src="@/assets/header/param_management.png" <img loading="lazy" alt="" src="@/assets/header/param_management.png"
:style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" /> :style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' ? 'brightness(0) invert(1)' : 'None' }" />
参数字典 参数字典
<i class="el-icon-arrow-down el-icon--right"></i> <i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': paramDropdownVisible }"></i>
</span> </span>
<el-dropdown-menu slot="dropdown"> <el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="goParamManagement"> <el-dropdown-item @click.native="goParamManagement">
@@ -49,22 +49,26 @@
<el-dropdown-item @click.native="goDictManagement"> <el-dropdown-item @click.native="goDictManagement">
字典管理 字典管理
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item @click.native="goProviderManagement">
供应器管理
</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</el-dropdown> </el-dropdown>
</div> </div>
<!-- 右侧元素 --> <!-- 右侧元素 -->
<div class="header-right"> <div class="header-right">
<div class="search-container" v-if="$route.path === '/home'"> <div class="search-container" v-if="$route.path === '/home' && !(isSuperAdmin && isSmallScreen)">
<el-input v-model="search" placeholder="输入名称搜索.." class="custom-search-input" <el-input v-model="search" placeholder="输入名称搜索.." class="custom-search-input"
@keyup.enter.native="handleSearch"> @keyup.enter.native="handleSearch">
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i> <i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i>
</el-input> </el-input>
</div> </div>
<img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img" /> <img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img" />
<el-dropdown trigger="click" class="user-dropdown"> <el-dropdown trigger="click" class="user-dropdown" @visible-change="handleUserDropdownVisibleChange">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
{{ userInfo.username || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i> {{ userInfo.username || '加载中...' }}
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': userDropdownVisible }"></i>
</span> </span>
<el-dropdown-menu slot="dropdown"> <el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="showChangePasswordDialog">修改密码</el-dropdown-item> <el-dropdown-item @click.native="showChangePasswordDialog">修改密码</el-dropdown-item>
@@ -84,7 +88,6 @@ import userApi from '@/apis/module/user';
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import ChangePasswordDialog from './ChangePasswordDialog.vue'; // 引入修改密码弹窗组件 import ChangePasswordDialog from './ChangePasswordDialog.vue'; // 引入修改密码弹窗组件
export default { export default {
name: 'HeaderBar', name: 'HeaderBar',
components: { components: {
@@ -98,7 +101,10 @@ export default {
username: '', username: '',
mobile: '' mobile: ''
}, },
isChangePasswordDialogVisible: false // 控制修改密码弹窗的显示 isChangePasswordDialogVisible: false, // 控制修改密码弹窗的显示
userDropdownVisible: false,
paramDropdownVisible: false,
isSmallScreen: false
} }
}, },
computed: { computed: {
@@ -108,7 +114,13 @@ export default {
} }
}, },
mounted() { mounted() {
this.fetchUserInfo() this.fetchUserInfo();
this.checkScreenSize();
window.addEventListener('resize', this.checkScreenSize);
},
//移除事件监听器
beforeDestroy() {
window.removeEventListener('resize', this.checkScreenSize);
}, },
methods: { methods: {
goHome() { goHome() {
@@ -130,6 +142,9 @@ export default {
goDictManagement() { goDictManagement() {
this.$router.push('/dict-management') this.$router.push('/dict-management')
}, },
goProviderManagement() {
this.$router.push('/provider-management')
},
// 获取用户信息 // 获取用户信息
fetchUserInfo() { fetchUserInfo() {
userApi.getUserInfo(({ data }) => { userApi.getUserInfo(({ data }) => {
@@ -139,7 +154,9 @@ export default {
} }
}) })
}, },
checkScreenSize() {
this.isSmallScreen = window.innerWidth <= 1386;
},
// 处理搜索 // 处理搜索
handleSearch() { handleSearch() {
const searchValue = this.search.trim(); const searchValue = this.search.trim();
@@ -184,6 +201,13 @@ export default {
}); });
} }
}, },
handleUserDropdownVisibleChange(visible) {
this.userDropdownVisible = visible;
},
// 监听第二个下拉菜单的可见状态变化
handleParamDropdownVisibleChange(visible) {
this.paramDropdownVisible = visible;
},
// 使用 mapActions 引入 Vuex 的 logout action // 使用 mapActions 引入 Vuex 的 logout action
...mapActions(['logout']) ...mapActions(['logout'])
@@ -191,7 +215,7 @@ export default {
} }
</script> </script>
<style scoped> <style lang="scss" scoped>
.header { .header {
background: #f6fcfe66; background: #f6fcfe66;
border: 1px solid #fff; border: 1px solid #fff;
@@ -243,8 +267,6 @@ export default {
} }
.equipment-management { .equipment-management {
padding: 0 9px;
width: px;
height: 30px; height: 30px;
border-radius: 15px; border-radius: 15px;
background: #deeafe; background: #deeafe;
@@ -260,7 +282,8 @@ export default {
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
/* 防止导航按钮被压缩 */ /* 防止导航按钮被压缩 */
padding: 0px 15px; padding: 0 15px;
position: relative;
} }
.equipment-management.active-tab { .equipment-management.active-tab {
@@ -309,6 +332,25 @@ export default {
flex-shrink: 0; flex-shrink: 0;
} }
.more-dropdown {
padding-right: 20px;
}
.more-dropdown .el-dropdown-link {
display: flex;
align-items: center;
gap: 7px;
}
.rotate-down {
transform: rotate(180deg);
transition: transform 0.3s ease;
}
.el-icon-arrow-down {
transition: transform 0.3s ease;
}
/* 响应式调整 */ /* 响应式调整 */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.header-center { .header-center {
@@ -316,50 +358,11 @@ export default {
} }
.equipment-management { .equipment-management {
width: 70px; width: 79px;
font-size: 9px; font-size: 9px;
} }
} }
@media (max-width: 1024px) {
.search-container {
margin-right: 10px;
max-width: 150px;
}
.header-right {
gap: 5px;
}
}
@media (max-width: 900px) {
.header-left {
margin-right: auto;
}
.search-container {
max-width: 150px;
}
}
@media (max-width: 768px) {
.search-container {
max-width: 145px;
}
.custom-search-input>>>.el-input__inner {
padding-left: 10px;
font-size: 11px;
}
}
@media (max-width: 600px) {
.search-container {
max-width: 120px;
min-width: 100px;
}
}
.equipment-management.more-dropdown { .equipment-management.more-dropdown {
position: relative; position: relative;
} }
@@ -378,13 +381,4 @@ export default {
color: #606266; color: #606266;
white-space: nowrap; white-space: nowrap;
} }
@media (max-width: 768px) {
.equipment-management.more-dropdown .el-dropdown-menu {
position: fixed;
right: 10px;
top: 60px;
z-index: 2000;
}
}
</style> </style>
@@ -0,0 +1,435 @@
<template>
<el-dialog :visible="visible" @update:visible="handleVisibleChange" width="57%" center custom-class="custom-dialog"
:show-close="false" class="center-dialog">
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -15px; margin-bottom: 20px; text-align: center;">
{{ title }}
</div>
<button class="custom-close-btn" @click="handleClose">×</button>
<el-form :model="form" label-width="100px" :rules="rules" ref="form" class="custom-form">
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<el-form-item label="类别" prop="modelType" style="flex: 1;">
<el-select v-model="form.modelType" placeholder="请选择类别" class="custom-input-bg" style="width: 100%;">
<el-option v-for="item in modelTypes" :key="item.value" :label="item.label" :value="item.value">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="供应器编码" prop="providerCode" style="flex: 1;">
<el-input v-model="form.providerCode" placeholder="请输入供应器编码" class="custom-input-bg"></el-input>
</el-form-item>
</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<el-form-item label="名称" prop="name" style="flex: 1;">
<el-input v-model="form.name" placeholder="请输入供应器名称" class="custom-input-bg"></el-input>
</el-form-item>
<el-form-item label="排序" prop="sort" style="flex: 1;">
<el-input-number v-model="form.sort" :min="0" controls-position="right" class="custom-input-bg"
style="width: 100%;"></el-input-number>
</el-form-item>
</div>
<div style="font-size: 20px; font-weight: bold; color: #3d4566; margin-bottom: 15px;">
字段配置
<div style="display: inline-block; float: right;">
<el-button type="primary" @click="addField" size="small" style="background: #5bc98c; border: none;"
:disabled="hasIncompleteFields">
添加
</el-button>
<el-button type="primary" @click="toggleSelectAllFields" size="small"
style="background: #5f70f3; border: none; margin-left: 10px;">
{{ isAllFieldsSelected ? '取消全选' : '全选' }}
</el-button>
<el-button type="danger" @click="batchRemoveFields" size="small"
style="background: red; border: none; margin-left: 10px;">
批量删除
</el-button>
</div>
</div>
<div style="height: 2px; background: #e9e9e9; margin-bottom: 22px;"></div>
<div class="fields-container">
<el-table :data="form.fields" style="width: 100%;" border size="medium" :key="tableKey">
<el-table-column label="选择" align="center" width="50">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected" @change="handleFieldSelectChange"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="字段key">
<template slot-scope="scope">
<template v-if="scope.row.editing">
<el-input v-model="scope.row.key" placeholder="字段key"></el-input>
</template>
<template v-else>
{{ scope.row.key }}
</template>
</template>
</el-table-column>
<el-table-column label="字段标签">
<template slot-scope="scope">
<template v-if="scope.row.editing">
<el-input v-model="scope.row.label" placeholder="字段标签"></el-input>
</template>
<template v-else>
{{ scope.row.label }}
</template>
</template>
</el-table-column>
<el-table-column label="字段类型">
<template slot-scope="scope">
<template v-if="scope.row.editing">
<el-select v-model="scope.row.type" placeholder="类型">
<el-option label="字符串" value="string"></el-option>
<el-option label="数字" value="number"></el-option>
<el-option label="布尔值" value="boolean"></el-option>
<el-option label="字典" value="dict"></el-option>
</el-select>
</template>
<template v-else>
{{ getTypeLabel(scope.row.type) }}
</template>
</template>
</el-table-column>
<el-table-column label="默认值">
<template slot-scope="scope">
<template v-if="scope.row.editing">
<el-input v-model="scope.row.default_value" placeholder="请输入默认值"></el-input>
</template>
<template v-else>
{{ scope.row.default_value }}
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center">
<template slot-scope="scope">
<el-button v-if="!scope.row.editing" type="primary" size="mini" @click="startEditing(scope.row)">
编辑
</el-button>
<el-button v-else type="success" size="mini" @click="stopEditing(scope.row)">
完成
</el-button>
<el-button type="danger" size="mini" @click="removeField(scope.$index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-form>
</div>
<div style="display: flex; justify-content: center;">
<el-button type="primary" @click="submit" class="save-btn" :loading="saving">保存</el-button>
</div>
</el-dialog>
</template>
<script>
export default {
props: {
title: String,
visible: Boolean,
form: Object,
modelTypes: Array
},
data() {
return {
saving: false,
rules: {
modelType: [{ required: true, message: '请选择类别', trigger: 'change' }],
providerCode: [{ required: true, message: '请输入供应器编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入供应器名称', trigger: 'blur' }]
},
isAllFieldsSelected: false,
tableKey: 0 // 用于强制表格重新渲染
};
},
computed: {
hasIncompleteFields() {
return this.form.fields && this.form.fields.some(field =>
!field.key || !field.label || !field.type
);
}
},
methods: {
getTypeLabel(type) {
const typeMap = {
'string': '字符串',
'number': '数字',
'boolean': '布尔值',
'dict': '字典'
};
return typeMap[type];
},
startEditing(row) {
this.$set(row, 'editing', true);
},
stopEditing(row) {
this.$set(row, 'editing', false);
const index = this.form.fields.indexOf(row);
if (index > -1) {
this.form.fields.splice(index, 1);
this.form.fields.push(row);
this.forceTableRerender();
}
},
handleFieldSelectChange() {
this.isAllFieldsSelected = this.form.fields.length > 0 &&
this.form.fields.every(field => field.selected);
},
toggleSelectAllFields() {
this.isAllFieldsSelected = !this.isAllFieldsSelected;
this.form.fields = this.form.fields.map(field => ({
...field,
selected: this.isAllFieldsSelected
}));
},
handleVisibleChange(val) {
this.$emit('update:visible', val);
if (!val) {
this.resetForm();
}
},
handleClose() {
this.resetForm();
this.$emit('update:visible', false);
this.$emit('cancel');
},
addField() {
if (this.hasIncompleteFields) {
this.$message.warning({
message: '请先完成当前字段的编辑',
showClose: true
});
return;
}
this.form.fields.unshift({
key: '',
label: '',
type: 'string',
default_value: '',
selected: false,
editing: true
});
this.forceTableRerender();
},
removeField(index) {
this.$confirm('确定要删除该字段吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.form.fields = this.form.fields.filter((_, i) => i !== index);
this.updateSelectAllStatus();
this.forceTableRerender();
this.$message.success({
message: '删除成功',
showClose: true
});
}).catch(() => {
this.$message.info({
message: '已取消删除',
showClose: true
});
});
},
batchRemoveFields() {
const selectedFields = this.form.fields.filter(field => field.selected);
if (selectedFields.length === 0) {
this.$message.warning({
message: '请先选择要删除的字段',
showClose: true
});
return;
}
this.$confirm(`确定要删除选中的 ${selectedFields.length} 个字段吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.form.fields = this.form.fields.filter(field => !field.selected);
this.isAllFieldsSelected = false;
this.forceTableRerender();
this.$message.success({
message: `成功删除 ${selectedFields.length} 个字段`,
showClose: true
});
}).catch(() => {
this.$message.info({
message: '已取消删除',
showClose: true
});
});
},
updateSelectAllStatus() {
this.isAllFieldsSelected = this.form.fields.length > 0 &&
this.form.fields.every(field => field.selected);
},
forceTableRerender() {
this.tableKey += 1; // 改变key值强制表格重新渲染
},
submit() {
this.$refs.form.validate(valid => {
if (valid) {
const editingField = this.form.fields.find(field => field.editing);
if (editingField) {
this.$message.warning({
message: '请先完成当前字段的编辑',
showClose: true
});
return;
}
this.form.fields = this.form.fields.map(field => ({
...field,
selected: false
}));
this.isAllFieldsSelected = false;
this.saving = true;
this.$emit('submit', {
form: this.form,
done: () => {
this.saving = false;
this.resetForm();
}
});
}
});
},
resetForm() {
this.$refs.form.resetFields();
if (this.form.fields) {
this.form.fields.forEach(field => {
field.selected = false;
field.editing = false;
});
}
this.isAllFieldsSelected = false;
this.forceTableRerender();
},
},
watch: {
visible(val) {
if (!val) {
this.resetForm();
}
}
}
};
</script>
<style lang="scss" scoped>
::v-deep .custom-dialog.el-dialog {
margin-top: 0 !important;
border-radius: 20px !important;
}
::v-deep .custom-dialog .el-dialog__header {
padding: 0;
border-bottom: none;
}
.custom-close-btn {
position: absolute;
top: 20px;
right: 20px;
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;
}
.custom-form .el-form-item {
margin-bottom: 20px;
}
.custom-form .el-form-item__label {
color: #3d4566;
font-weight: normal;
text-align: right;
padding-right: 20px;
}
.custom-input-bg .el-input__inner {
background-color: #f6f8fc;
height: 32px;
}
.custom-input-bg .el-input__inner::-webkit-input-placeholder {
color: #9c9f9e;
}
.fields-container {
margin-bottom: 20px;
}
.save-btn {
background: #e6f0fd;
color: #237ff4;
border: 1px solid #b3d1ff;
width: 150px;
height: 40px;
font-size: 16px;
transition: all 0.3s ease;
}
.save-btn:hover {
background: linear-gradient(to right, #237ff4, #9c40d5);
color: white;
border: none;
}
.el-table {
border-radius: 4px;
}
.el-table::before {
display: none;
}
.el-table th,
.el-table td {
padding: 8px 0;
}
.el-button.is-circle {
border-radius: 2px;
}
</style>
@@ -336,6 +336,14 @@ export default {
}, },
saveEdit(row) { saveEdit(row) {
if (!row.voiceCode || !row.voiceName || !row.languageType) {
this.$message.error({
message: '音色编码、音色名称和语言类型不能为空',
showClose: true
});
return;
}
try { try {
const params = { const params = {
id: row.id, id: row.id,
@@ -408,6 +416,12 @@ export default {
}, },
addNew() { addNew() {
const hasEditing = this.ttsModels.some(row => row.editing);
if (hasEditing) {
this.$message.warning('请先完成当前编辑再新增');
return;
}
const maxSort = this.ttsModels.length > 0 const maxSort = this.ttsModels.length > 0
? Math.max(...this.ttsModels.map(item => Number(item.sort) || 0)) ? Math.max(...this.ttsModels.map(item => Number(item.sort) || 0))
: 0; : 0;
+8 -1
View File
@@ -90,7 +90,14 @@ const routes = [
component: function () { component: function () {
return import('../views/DictManagement.vue') return import('../views/DictManagement.vue')
} }
} },
{
path: '/provider-management',
name: 'ProviderManagement',
component: function () {
return import('../views/ProviderManagement.vue')
}
},
] ]
const router = new VueRouter({ const router = new VueRouter({
base: process.env.VUE_APP_PUBLIC_PATH || '/', base: process.env.VUE_APP_PUBLIC_PATH || '/',
@@ -0,0 +1,876 @@
<template>
<div class="welcome">
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">供应器管理</h2>
<div class="right-operations">
<el-dropdown trigger="click" @command="handleSelectModelType" @visible-change="handleDropdownVisibleChange">
<el-button class="category-btn">
类别筛选 {{ selectedModelTypeLabel }}<i class="el-icon-arrow-down el-icon--right"
:class="{ 'rotate-down': DropdownVisible }"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="">全部</el-dropdown-item>
<el-dropdown-item v-for="item in modelTypes" :key="item.value" :command="item.value">
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-input placeholder="请输入供应器名称查询" v-model="searchName" class="search-input" @keyup.enter.native="handleSearch"
clearable />
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
</div>
</div>
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="provider-card" shadow="never">
<el-table ref="providersTable" :data="filteredProvidersList" 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">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="类别" prop="modelType" align="center" width="200">
<template slot="header" slot-scope="scope">
<el-dropdown trigger="click" @command="handleSelectModelType"
@visible-change="isDropdownOpen = $event">
<span class="dropdown-trigger" :class="{ 'active': isDropdownOpen }">
类别{{ selectedModelTypeLabel }} <i class="dropdown-arrow"
:class="{ 'is-active': isDropdownOpen }"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="">全部</el-dropdown-item>
<el-dropdown-item v-for="item in modelTypes" :key="item.value" :command="item.value">
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<template slot-scope="scope">
<el-tag :type="getModelTypeTag(scope.row.modelType)">
{{ getModelTypeLabel(scope.row.modelType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="供应器编码" prop="providerCode" align="center" width="150"></el-table-column>
<el-table-column label="名称" prop="name" align="center"></el-table-column>
<el-table-column label="字段配置" align="center">
<template slot-scope="scope">
<el-popover placement="top-start" width="400" trigger="hover">
<div v-for="field in scope.row.fields" :key="field.key" class="field-item">
<span class="field-label">{{ field.label }}:</span>
<span class="field-type">{{ field.type }}</span>
<span v-if="isSensitiveField(field.key)" class="sensitive-tag">敏感</span>
</div>
<el-button slot="reference" size="mini" type="text">查看字段</el-button>
</el-popover>
</template>
</el-table-column>
<el-table-column label="排序" prop="sort" align="center" width="80"></el-table-column>
<el-table-column label="操作" align="center" width="180">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="editProvider(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click="deleteProvider(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table_bottom">
<div class="ctrl_btn">
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteSelectedProviders">删除
</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
首页
</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
上一页
</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
下一页
</button>
<span class="total-text">{{ total }}条记录</span>
</div>
</div>
</el-card>
</div>
</div>
</div>
<!-- 新增/编辑供应器对话框 -->
<provider-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="providerForm" :model-types="modelTypes"
@submit="handleSubmit" @cancel="dialogVisible = false" />
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
<script>
import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue";
import ProviderDialog from "@/components/ProviderDialog.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default {
components: { HeaderBar, ProviderDialog, VersionFooter },
data() {
return {
searchName: "",
searchModelType: "",
providersList: [],
modelTypes: [
{ value: "ASR", label: "语音识别" },
{ value: "TTS", label: "语音合成" },
{ value: "LLM", label: "大语言模型" },
{ value: "Intent", label: "意图识别" },
{ value: "Memory", label: "记忆模块" },
{ value: "VAD", label: "语音活动检测" }
],
currentPage: 1,
loading: false,
pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
total: 0,
dialogVisible: false,
dialogTitle: "新增供应器",
isAllSelected: false,
isDropdownOpen: false,
sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key"],
providerForm: {
id: null,
modelType: "",
providerCode: "",
name: "",
fields: [],
sort: 0
},
DropdownVisible: false,
};
},
created() {
this.fetchProviders();
},
computed: {
selectedModelTypeLabel() {
if (!this.searchModelType) return "(全部)";
const selectedType = this.modelTypes.find(item => item.value === this.searchModelType);
return selectedType ? `${selectedType.label}` : "";
},
pageCount() {
return Math.ceil(this.total / this.pageSize);
},
visiblePages() {
const pages = [];
const maxVisible = 3;
let start = Math.max(1, this.currentPage - 1);
let end = Math.min(this.pageCount, start + maxVisible - 1);
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
},
filteredProvidersList() {
return this.providersList;
// let list = this.providersList.filter(item => {
// const nameMatch = item.name.toLowerCase().includes(this.searchName.toLowerCase());
// const typeMatch = !this.searchModelType || item.model_type === this.searchModelType;
// return nameMatch && typeMatch;
// });
// list.sort((a, b) => a.sort - b.sort);
// // 分页处理
// const start = (this.currentPage - 1) * this.pageSize;
// return list.slice(start, start + this.pageSize);
}
},
methods: {
fetchProviders() {
this.loading = true;
Api.model.getModelProvidersPage(
{
page: this.currentPage,
limit: this.pageSize,
name: this.searchName,
modelType: this.searchModelType
},
({ data }) => {
this.loading = false;
if (data.code === 0) {
this.providersList = data.data.list.map(item => {
return {
...item,
selected: false,
fields: JSON.parse(item.fields)
};
});
this.total = data.data.total;
} else {
this.$message.error({
message: data.msg || '获取参数列表失败'
});
}
}
);
},
handleSearch() {
this.currentPage = 1;
this.fetchProviders();
},
handleSelectModelType(value) {
this.isDropdownOpen = false;
this.searchModelType = value;
this.handleSearch();
},
handleSelectAll() {
this.isAllSelected = !this.isAllSelected;
this.providersList.forEach(row => {
row.selected = this.isAllSelected;
});
},
showAddDialog() {
this.dialogTitle = "新增供应器";
this.providerForm = {
id: null,
modelType: "",
providerCode: "",
name: "",
fields: [],
sort: 0
};
this.dialogVisible = true;
},
editProvider(row) {
this.dialogTitle = "编辑供应器";
this.providerForm = {
...row,
fields: JSON.parse(JSON.stringify(row.fields))
};
this.dialogVisible = true;
},
handleSubmit({ form, done }) {
this.loading = true;
if (form.id) {
// 编辑
Api.model.updateModelProvider(form, ({ data }) => {
if (data.code === 0) {
this.fetchProviders(); // 刷新表格
this.$message.success({
message: "修改成功",
showClose: true
});
}
});
} else {
// 新增
Api.model.addModelProvider(form, ({ data }) => {
if (data.code === 0) {
this.fetchProviders(); // 刷新表格
this.$message.success({
message: "新增成功",
showClose: true
});
this.total += 1;
}
});
}
this.loading = false;
this.dialogVisible = false;
done && done();
},
deleteSelectedProviders() {
const selectedRows = this.providersList.filter(row => row.selected);
if (selectedRows.length === 0) {
this.$message.warning({
message: "请先选择需要删除的供应器",
showClose: true
});
return;
}
this.deleteProvider(selectedRows);
},
deleteProvider(row) {
const providers = Array.isArray(row) ? row : [row];
const providerCount = providers.length;
this.$confirm(`确定要删除选中的${providerCount}个供应器吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
const ids = providers.map(provider => provider.id);
Api.model.deleteModelProviderByIds(ids, ({ data }) => {
if (data.code === 0) {
this.isAllSelected = false;
this.fetchProviders(); // 刷新表格
this.$message.success({
message: `成功删除${providerCount}个参数`,
showClose: true
});
} else {
this.$message.error({
message: data.msg || '删除失败,请重试',
showClose: true
});
}
});
}).catch(() => {
this.$message({
type: 'info',
message: '已取消删除',
showClose: true,
duration: 1000
});
});
},
getModelTypeTag(type) {
const typeMap = {
'ASR': 'success',
'TTS': 'warning',
'LLM': 'danger',
'Intent': 'info',
'Memory': '',
'VAD': 'primary'
};
return typeMap[type] || '';
},
getModelTypeLabel(type) {
const typeItem = this.modelTypes.find(item => item.value === type);
return typeItem ? typeItem.label : type;
},
isSensitiveField(fieldKey) {
if (typeof fieldKey !== 'string') return false;
return this.sensitive_keys.some(key =>
fieldKey.toLowerCase().includes(key.toLowerCase())
);
},
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.fetchProviders();
},
headerCellClassName({ columnIndex }) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
},
goFirst() {
this.currentPage = 1;
this.fetchProviders();
},
goPrev() {
if (this.currentPage > 1) {
this.currentPage--;
this.fetchProviders();
}
},
goNext() {
if (this.currentPage < this.pageCount) {
console.log("this.currentPage", this.currentPage);
this.currentPage++;
this.fetchProviders();
}
},
goToPage(page) {
this.currentPage = page;
this.fetchProviders();
},
handleDropdownVisibleChange(visible) {
this.DropdownVisible = visible;
},
},
};
</script>
<style lang="scss" scoped>
.welcome {
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
position: relative;
flex-direction: column;
background-size: cover;
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.right-operations {
display: flex;
gap: 10px;
margin-left: auto;
}
.search-input {
width: 240px;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
.content-panel {
flex: 1;
display: flex;
overflow: hidden;
height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
}
.content-area {
flex: 1;
height: 100%;
min-width: 600px;
overflow: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.el-card {
border: none;
}
.provider-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
box-shadow: none;
overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
}
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding-bottom: 10px;
}
.ctrl_btn {
display: flex;
gap: 8px;
padding-left: 26px;
.el-button {
min-width: 72px;
height: 32px;
padding: 7px 12px 7px 10px;
font-size: 12px;
border-radius: 4px;
line-height: 1;
font-weight: 500;
border: none;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
}
.el-button--primary {
background: #5f70f3;
color: white;
}
.el-button--danger {
background: #fd5b63;
color: white;
}
}
.custom-pagination {
display: flex;
align-items: center;
gap: 10px;
.el-select {
margin-right: 8px;
}
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-last-child(2),
.pagination-btn:nth-child(3) {
min-width: 60px;
height: 32px;
padding: 0 12px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #d7dce6;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(245, 247, 250, 0.3);
}
}
.pagination-btn.active {
background: #5f70f3 !important;
color: #ffffff !important;
border-color: #5f70f3 !important;
&:hover {
background: #6d7cf5 !important;
}
}
.total-text {
color: #909399;
font-size: 14px;
margin-left: 10px;
}
}
:deep(.transparent-table) {
background: white;
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
.el-table__body-wrapper {
flex: 1;
overflow-y: auto;
max-height: none !important;
}
.el-table__header-wrapper {
flex-shrink: 0;
}
.el-table__header th {
background: white !important;
color: black;
}
&::before {
display: none;
}
.el-table__body tr {
background-color: white;
td {
border-top: 1px solid rgba(0, 0, 0, 0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
}
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
border-color: #cccccc !important;
}
:deep(.el-checkbox__inner:hover) {
border-color: #cccccc !important;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #5f70f3 !important;
border-color: #5f70f3 !important;
}
@media (min-width: 1144px) {
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
}
:deep(.transparent-table) {
.el-table__body tr {
td {
padding-top: 16px;
padding-bottom: 16px;
}
&+tr {
margin-top: 10px;
}
}
}
}
:deep(.el-table .el-button--text) {
color: #7079aa;
}
:deep(.el-table .el-button--text:hover) {
color: #5a64b5;
}
.el-button--success {
background: #5bc98c;
color: white;
}
:deep(.el-table .cell) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.page-size-select {
width: 100px;
margin-right: 10px;
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__suffix) {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
}
:deep(.el-table) {
.el-table__body-wrapper {
transition: height 0.3s ease;
}
}
.el-table {
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
.el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px);
}
}
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
.field-item {
padding: 5px 0;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
.field-label {
flex: 1;
font-weight: bold;
}
.field-type {
width: 80px;
color: #666;
}
.sensitive-tag {
margin-left: 10px;
color: #f56c6c;
font-size: 12px;
}
}
.dropdown-trigger {
font-size: 14px;
color: #303133;
cursor: pointer;
display: flex;
align-items: center;
&:hover {
color: #409EFF;
}
}
.dropdown-trigger.active {
color: #409EFF;
}
.dropdown-arrow {
display: inline-block;
margin-left: 5px;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 7px solid black;
position: relative;
transition: transform 0.3s ease;
transform: rotate(0deg);
&.is-active {
transform: rotate(180deg);
border-top-color: #409EFF;
}
}
.rotate-down {
transform: rotate(180deg);
transition: transform 0.3s ease;
}
.el-icon-arrow-down {
transition: transform 0.3s ease;
}
.dropdown-trigger {
font-size: 14px;
color: #303133;
cursor: pointer;
display: flex;
align-items: center;
&:hover {
color: #409EFF;
}
}
.dropdown-trigger.active {
color: #409EFF;
}
</style>
+230 -62
View File
@@ -15,9 +15,17 @@
<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"> <div class="header-actions">
× <div class="hint-text">
</button> <img loading="lazy" src="@/assets/home/info.png" alt="">
<span>保存配置后需要重启设备新的配置才会生效</span>
</div>
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
<el-button class="reset-btn" @click="resetConfig">重置</el-button>
<button class="custom-close-btn" @click="goToHome">
×
</button>
</div>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
@@ -26,7 +34,7 @@
<div class="form-grid"> <div class="form-grid">
<div class="form-column"> <div class="form-column">
<el-form-item label="助手昵称:"> <el-form-item label="助手昵称:">
<el-input v-model="form.agentName" class="form-input" /> <el-input v-model="form.agentName" class="form-input" maxlength="10" />
</el-form-item> </el-form-item>
<el-form-item label="角色模版:"> <el-form-item label="角色模版:">
<div class="template-container"> <div class="template-container">
@@ -37,9 +45,15 @@
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="角色介绍:"> <el-form-item label="角色介绍:">
<el-input type="textarea" rows="12" resize="none" placeholder="请输入内容" v-model="form.systemPrompt" <el-input type="textarea" rows="9" resize="none" placeholder="请输入内容" v-model="form.systemPrompt"
maxlength="2000" show-word-limit class="form-textarea" /> maxlength="2000" show-word-limit class="form-textarea" />
</el-form-item> </el-form-item>
<el-form-item label="记忆:">
<el-input type="textarea" rows="6" resize="none" v-model="form.summaryMemory" maxlength="2000"
show-word-limit class="form-textarea"
:disabled="form.model.memModelId !== 'Memory_mem_local_short'" />
</el-form-item>
<el-form-item label="语言编码:" style="display: none;"> <el-form-item label="语言编码:" style="display: none;">
<el-input v-model="form.langCode" placeholder="请输入语言编码,如:zh_CN" maxlength="10" show-word-limit <el-input v-model="form.langCode" placeholder="请输入语言编码,如:zh_CN" maxlength="10" show-word-limit
class="form-input" /> class="form-input" />
@@ -48,24 +62,48 @@
<el-input v-model="form.language" placeholder="请输入交互语种,如:中文" maxlength="10" show-word-limit <el-input v-model="form.language" placeholder="请输入交互语种,如:中文" maxlength="10" show-word-limit
class="form-input" /> class="form-input" />
</el-form-item> </el-form-item>
<div class="action-bar">
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
<el-button class="reset-btn" @click="resetConfig">重置</el-button>
<div class="hint-text">
<img loading="lazy" src="@/assets/home/red-info.png" alt="">
<span>保存配置后需要重启设备新的配置才会生效</span>
</div>
</div>
</div> </div>
<div class="form-column"> <div class="form-column">
<el-form-item v-for="(model, index) in models" :key="`model-${index}`" :label="model.label" <el-form-item v-for="(model, index) in models" :key="`model-${index}`" :label="model.label"
class="model-item"> class="model-item">
<el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="form-select"> <div class="model-select-wrapper">
<el-option v-for="(item, optionIndex) in modelOptions[model.type]" <el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="form-select"
:key="`option-${index}-${optionIndex}`" :label="item.label" :value="item.value" /> @change="handleModelChange(model.type, $event)">
</el-select> <el-option v-for="(item, optionIndex) in modelOptions[model.type]"
:key="`option-${index}-${optionIndex}`" :label="item.label" :value="item.value" />
</el-select>
<div v-if="showFunctionIcons(model.type)" class="function-icons">
<el-tooltip v-for="func in currentFunctions" :key="func.name" effect="dark" placement="top"
popper-class="custom-tooltip">
<div slot="content">
<div><strong>功能名称:</strong> {{ func.name }}</div>
<div v-if="Object.keys(func.params).length > 0">
<strong>参数配置:</strong>
<div v-for="(value, key) in func.params" :key="key">
{{ key }}: {{ value }}
</div>
</div>
<div v-else>无参数配置</div>
</div>
<div class="icon-dot" :style="{ backgroundColor: getFunctionColor(func.name) }">
{{ func.name.charAt(0) }}
</div>
</el-tooltip>
<el-button class="edit-function-btn" @click="showFunctionDialog = true"
:class="{ 'active-btn': showFunctionDialog }">
编辑功能
</el-button>
</div>
<div v-if="model.type === 'Memory' && form.model.memModelId !== 'Memory_nomem'"
class="chat-history-options">
<el-radio-group v-model="form.chatHistoryConf" @change="updateChatHistoryConf">
<el-radio-button :label="1">上报文字</el-radio-button>
<el-radio-button :label="2">上报文字+语音</el-radio-button>
</el-radio-group>
</div>
</div>
</el-form-item> </el-form-item>
<el-form-item label="角色音色"> <el-form-item label="角色音色">
<el-select v-model="form.ttsVoiceId" placeholder="请选择" class="form-select"> <el-select v-model="form.ttsVoiceId" placeholder="请选择" class="form-select">
<el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label" <el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label"
:value="item.value" /> :value="item.value" />
@@ -75,28 +113,33 @@
</div> </div>
</div> </div>
</el-form> </el-form>
</el-card> </el-card>
</div> </div>
</div> </div>
</div> </div>
<function-dialog v-model="showFunctionDialog" :functions="currentFunctions"
@update-functions="handleUpdateFunctions" @dialog-closed="handleDialogClosed" />
</div> </div>
</template> </template>
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
import FunctionDialog from "@/components/FunctionDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
export default { export default {
name: 'RoleConfigPage', name: 'RoleConfigPage',
components: { HeaderBar }, components: { HeaderBar, FunctionDialog },
data() { data() {
return { return {
form: { form: {
agentCode: "", agentCode: "",
agentName: "", agentName: "",
ttsVoiceId: "", ttsVoiceId: "",
chatHistoryConf: 0,
systemPrompt: "", systemPrompt: "",
summaryMemory: "",
langCode: "", langCode: "",
language: "", language: "",
sort: "", sort: "",
@@ -121,6 +164,18 @@ export default {
templates: [], templates: [],
loadingTemplate: false, loadingTemplate: false,
voiceOptions: [], voiceOptions: [],
showFunctionDialog: false,
currentFunctions: [],
functionColorMap: [
'#FF6B6B', '#4ECDC4', '#45B7D1',
'#96CEB4', '#FFEEAD', '#D4A5A5', '#A2836E'
],
allFunctions: [
{ name: '天气', params: {} },
{ name: '新闻', params: {} },
{ name: '工具', params: {} },
{ name: '退出', params: {} }
],
} }
}, },
methods: { methods: {
@@ -136,12 +191,15 @@ export default {
llmModelId: this.form.model.llmModelId, llmModelId: this.form.model.llmModelId,
ttsModelId: this.form.model.ttsModelId, ttsModelId: this.form.model.ttsModelId,
ttsVoiceId: this.form.ttsVoiceId, ttsVoiceId: this.form.ttsVoiceId,
chatHistoryConf: this.form.chatHistoryConf,
memModelId: this.form.model.memModelId, memModelId: this.form.model.memModelId,
intentModelId: this.form.model.intentModelId, intentModelId: this.form.model.intentModelId,
systemPrompt: this.form.systemPrompt, systemPrompt: this.form.systemPrompt,
summaryMemory: this.form.summaryMemory,
langCode: this.form.langCode, langCode: this.form.langCode,
language: this.form.language, language: this.form.language,
sort: this.form.sort sort: this.form.sort,
functions: this.currentFunctions
}; };
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => { Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
@@ -167,7 +225,9 @@ export default {
agentCode: "", agentCode: "",
agentName: "", agentName: "",
ttsVoiceId: "", ttsVoiceId: "",
chatHistoryConf: 0,
systemPrompt: "", systemPrompt: "",
summaryMemory: "",
langCode: "", langCode: "",
language: "", language: "",
sort: "", sort: "",
@@ -180,12 +240,12 @@ export default {
intentModelId: "", intentModelId: "",
} }
} }
this.currentFunctions = [];
this.$message.success({ this.$message.success({
message: '配置已重置', message: '配置已重置',
showClose: true showClose: true
}) })
}).catch(() => { }).catch(() => { });
});
}, },
fetchTemplates() { fetchTemplates() {
Api.agent.getAgentTemplate(({ data }) => { Api.agent.getAgentTemplate(({ data }) => {
@@ -220,7 +280,9 @@ export default {
...this.form, ...this.form,
agentName: templateData.agentName || this.form.agentName, agentName: templateData.agentName || this.form.agentName,
ttsVoiceId: templateData.ttsVoiceId || this.form.ttsVoiceId, ttsVoiceId: templateData.ttsVoiceId || this.form.ttsVoiceId,
chatHistoryConf: templateData.chatHistoryConf || this.form.chatHistoryConf,
systemPrompt: templateData.systemPrompt || this.form.systemPrompt, systemPrompt: templateData.systemPrompt || this.form.systemPrompt,
summaryMemory: templateData.summaryMemory || this.form.summaryMemory,
langCode: templateData.langCode || this.form.langCode, langCode: templateData.langCode || this.form.langCode,
model: { model: {
ttsModelId: templateData.ttsModelId || this.form.model.ttsModelId, ttsModelId: templateData.ttsModelId || this.form.model.ttsModelId,
@@ -247,6 +309,7 @@ export default {
intentModelId: data.data.intentModelId intentModelId: data.data.intentModelId
} }
}; };
this.currentFunctions = data.data.functions || [];
} else { } else {
this.$message.error(data.msg || '获取配置失败'); this.$message.error(data.msg || '获取配置失败');
} }
@@ -281,7 +344,56 @@ export default {
this.voiceOptions = []; this.voiceOptions = [];
} }
}); });
} },
getFunctionColor(name) {
const hash = [...name].reduce((acc, char) => acc + char.charCodeAt(0), 0);
return this.functionColorMap[hash % 7];
},
showFunctionIcons(type) {
// TODO 暂时不放出来
return false;
// return type === 'Intent' &&
// this.form.model.intentModelId !== 'Intent_nointent';
},
handleModelChange(type, value) {
if (type === 'Intent' && value !== 'Intent_nointent') {
this.fetchFunctionList();
}
if (type === 'Memory' && value === 'Memory_nomem') {
this.form.chatHistoryConf = 0;
}
if (type === 'Memory' && value !== 'Memory_nomem' && (this.form.chatHistoryConf === 0 || this.form.chatHistoryConf === null)) {
this.form.chatHistoryConf = 2;
}
},
fetchFunctionList() {
// 使用假数据代替API调用
return new Promise(resolve => {
setTimeout(() => {
this.currentFunctions = [
{ name: '天气', params: { city: '北京' } },
{ name: '新闻', params: { type: '科技' } }
];
resolve();
}, 500);
});
},
handleUpdateFunctions(selected) {
this.currentFunctions = selected;
console.log('保存的功能列表:', selected);
this.$message.success('功能配置已保存');
},
handleDialogClosed(saved) {
if (!saved) {
// 如果未保存,恢复原始功能列表
this.currentFunctions = JSON.parse(JSON.stringify(this.originalFunctions));
}
},
updateChatHistoryConf() {
if (this.form.model.memModelId === 'Memory_nomem') {
this.form.chatHistoryConf = 0;
}
},
}, },
watch: { watch: {
'form.model.ttsModelId': { 'form.model.ttsModelId': {
@@ -308,6 +420,9 @@ export default {
const agentId = this.$route.query.agentId; const agentId = this.$route.query.agentId;
if (agentId) { if (agentId) {
this.fetchAgentConfig(agentId); this.fetchAgentConfig(agentId);
this.fetchFunctionList().then(() => {
this.originalFunctions = JSON.parse(JSON.stringify(this.currentFunctions));
});
} }
this.fetchModelOptions(); this.fetchModelOptions();
this.fetchTemplates(); this.fetchTemplates();
@@ -466,51 +581,35 @@ export default {
background-color: #d0d8ff; background-color: #d0d8ff;
} }
.action-bar { .model-select-wrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 2vh;
align-items: center;
}
.action-bar {
.el-button.save-btn {
background: #5778ff;
color: white;
border: none;
border-radius: 18px;
padding: 10px 20px;
width: 100px;
height: 35px;
font-size: 14px;
}
.el-button.reset-btn {
background: #e6ebff;
color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 10px 20px;
}
}
.hint-text {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; width: 100%;
color: #979db1;
font-size: 11px;
margin-left: 16px;
} }
.hint-text img { .function-icons {
width: 19px; display: flex;
height: 19px; align-items: center;
margin-left: auto;
padding-left: 10px;
}
.icon-dot {
width: 25px;
height: 25px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 12px;
margin-right: 8px;
position: relative;
} }
::v-deep .el-form-item__label { ::v-deep .el-form-item__label {
font-size: 10px !important; font-size: 12px !important;
color: #3d4566 !important; color: #3d4566 !important;
font-weight: 400; font-weight: 400;
line-height: 22px; line-height: 22px;
@@ -551,4 +650,73 @@ export default {
color: #409EFF; color: #409EFF;
border-color: #409EFF; border-color: #409EFF;
} }
.edit-function-btn {
background: #e6ebff;
color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 10px 20px;
transition: all 0.3s;
}
.edit-function-btn.active-btn {
background: #5778ff;
color: white;
}
.chat-history-options {
display: flex;
gap: 10px;
min-width: 250px;
justify-content: flex-end;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.header-actions .hint-text {
display: flex;
align-items: center;
gap: 4px;
color: #979db1;
font-size: 12px;
margin-right: 8px;
}
.header-actions .hint-text img {
width: 16px;
height: 16px;
}
.header-actions .save-btn {
background: #5778ff;
color: white;
border: none;
border-radius: 18px;
padding: 8px 16px;
height: 32px;
font-size: 14px;
}
.header-actions .reset-btn {
background: #e6ebff;
color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 8px 16px;
height: 32px;
}
.header-actions .custom-close-btn {
position: static;
transform: none;
width: 32px;
height: 32px;
margin-left: 8px;
}
</style> </style>
+15 -13
View File
@@ -101,7 +101,8 @@ plugins:
# 这个密钥是项目共用的key,用多了可能会被限制 # 这个密钥是项目共用的key,用多了可能会被限制
# 想稳定一点就自行申请替换,每天有1000次免费调用 # 想稳定一点就自行申请替换,每天有1000次免费调用
# 申请地址:https://console.qweather.com/#/apps/create-key/over # 申请地址:https://console.qweather.com/#/apps/create-key/over
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" } # 申请后通过这个链接可以找到自己的apihosthttps://console.qweather.com/setting?lang=zh
get_weather: {"api_host":"mj7p3y7naa.re.qweatherapi.com", "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻 # 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/ # 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
get_news_from_chinanews: get_news_from_chinanews:
@@ -129,7 +130,7 @@ plugins:
# ################################以下是角色模型配置###################################### # ################################以下是角色模型配置######################################
prompt: | prompt: |
是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。 是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
[核心特征] [核心特征]
- 讲话像连珠炮,但会突然冒出超温柔语气 - 讲话像连珠炮,但会突然冒出超温柔语气
- 用梗密度高 - 用梗密度高
@@ -143,6 +144,13 @@ prompt: |
- 长篇大论,叽叽歪歪 - 长篇大论,叽叽歪歪
- 长时间严肃对话 - 长时间严肃对话
# 结束语prompt
end_prompt:
enable: true # 是否开启结束语
# 结束语
prompt: |
请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!
# 具体处理时选择的模块(The module selected for specific processing) # 具体处理时选择的模块(The module selected for specific processing)
selected_module: selected_module:
# 语音活动检测模块,默认使用SileroVAD模型 # 语音活动检测模块,默认使用SileroVAD模型
@@ -221,7 +229,7 @@ ASR:
FunASRServer: FunASRServer:
# 独立部署FunASR,使用FunASR的API服务,只需要五句话 # 独立部署FunASR,使用FunASR的API服务,只需要五句话
# 第一句:mkdir -p ./funasr-runtime-resources/models # 第一句:mkdir -p ./funasr-runtime-resources/models
# 第二句:sudo docker run -d -p 10096:10095 --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12 # 第二句:sudo docker run -p 10096:10095 -it --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12
# 上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime # 上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime
# 不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 & # 不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 &
# 上一句话执行后会进入到容器,继续第五句:tail -f log.txt # 上一句话执行后会进入到容器,继续第五句:tail -f log.txt
@@ -231,6 +239,7 @@ ASR:
host: 127.0.0.1 host: 127.0.0.1
port: 10096 port: 10096
is_ssl: true is_ssl: true
api_key: none
output_dir: tmp/ output_dir: tmp/
SherpaASR: SherpaASR:
type: sherpa_onnx_local type: sherpa_onnx_local
@@ -475,20 +484,13 @@ TTS:
speed: 1 speed: 1
output_dir: tmp/ output_dir: tmp/
FishSpeech: FishSpeech:
# 定义TTS API类型 # 参照教程:https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/fish-speech-integration.md
#启动tts方法:
#python -m tools.api_server
#--listen 0.0.0.0:8080
#--llama-checkpoint-path "checkpoints/fish-speech-1.5"
#--decoder-checkpoint-path "checkpoints/fish-speech-1.5/firefly-gan-vq-fsq-8x1024-21hz-generator.pth"
#--decoder-config-name firefly_gan_vq
#--compile
type: fishspeech type: fishspeech
output_dir: tmp/ output_dir: tmp/
response_format: wav response_format: wav
reference_id: null reference_id: null
reference_audio: ["/tmp/test.wav",] reference_audio: ["config/assets/wakeup_words.wav",]
reference_text: ["你弄来这些吟词宴曲来看,还是这些混话来欺负我。",] reference_text: ["哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦",]
normalize: true normalize: true
max_new_tokens: 1024 max_new_tokens: 1024
chunk_length: 200 chunk_length: 200
+1 -1
View File
@@ -4,7 +4,7 @@ from loguru import logger
from config.config_loader import load_config from config.config_loader import load_config
from config.settings import check_config_file from config.settings import check_config_file
SERVER_VERSION = "0.4.2" SERVER_VERSION = "0.4.4"
def get_module_abbreviation(module_name, module_dict): def get_module_abbreviation(module_name, module_dict):
@@ -145,6 +145,20 @@ def get_agent_models(
) )
def save_mem_local_short(mac_address: str, short_momery: str) -> Optional[Dict]:
try:
return ManageApiClient._instance._execute_request(
"PUT",
f"/agent/saveMemory/" + mac_address,
json={
"summaryMemory": short_momery,
},
)
except Exception as e:
print(f"存储短期记忆到服务器失败: {e}")
return None
def report( def report(
mac_address: str, session_id: str, chat_type: int, content: str, audio mac_address: str, session_id: str, chat_type: int, content: str, audio
) -> Optional[Dict]: ) -> Optional[Dict]:
+85 -48
View File
@@ -33,7 +33,7 @@ from core.mcp.manager import MCPManager
from config.config_loader import get_private_config_from_api from config.config_loader import get_private_config_from_api
from config.manage_api_client import DeviceNotFoundException, DeviceBindException from config.manage_api_client import DeviceNotFoundException, DeviceBindException
from core.utils.output_counter import add_device_output from core.utils.output_counter import add_device_output
from core.handle.ttsReportHandle import enqueue_tts_report, report_tts from core.handle.reportHandle import enqueue_tts_report, report
TAG = __name__ TAG = __name__
@@ -75,6 +75,7 @@ class ConnectionHandler:
self.prompt = None self.prompt = None
self.welcome_msg = None self.welcome_msg = None
self.max_output_size = 0 self.max_output_size = 0
self.chat_history_conf = 0
# 客户端状态相关 # 客户端状态相关
self.client_abort = False self.client_abort = False
@@ -88,8 +89,11 @@ class ConnectionHandler:
self.executor = ThreadPoolExecutor(max_workers=10) self.executor = ThreadPoolExecutor(max_workers=10)
# 上报线程 # 上报线程
self.tts_report_queue = queue.Queue() self.report_queue = queue.Queue()
self.tts_report_thread = None self.report_thread = None
# TODO(haotian): 2025/5/12 可以通过修改此处,调节asr的上报和tts的上报
self.report_asr_enable = self.read_config_from_api
self.report_tts_enable = self.read_config_from_api
# 依赖的组件 # 依赖的组件
self.vad = None self.vad = None
@@ -221,10 +225,26 @@ class ConnectionHandler:
"""保存记忆并关闭连接""" """保存记忆并关闭连接"""
try: try:
if self.memory: if self.memory:
await self.memory.save_memory(self.dialogue.dialogue) # 使用线程池异步保存记忆
def save_memory_task():
try:
# 创建新事件循环(避免与主循环冲突)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(
self.memory.save_memory(self.dialogue.dialogue)
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
finally:
loop.close()
# 启动线程保存记忆,不等待完成
threading.Thread(target=save_memory_task, daemon=True).start()
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}") self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
finally: finally:
# 立即关闭连接,不等待记忆保存完成
await self.close(ws) await self.close(ws)
async def reset_timeout(self): async def reset_timeout(self):
@@ -250,11 +270,15 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).info("收到服务器重启指令,准备执行...") self.logger.bind(tag=TAG).info("收到服务器重启指令,准备执行...")
# 发送确认响应 # 发送确认响应
await self.websocket.send(json.dumps({ await self.websocket.send(
"type": "server_response", json.dumps(
"status": "success", {
"message": "服务器重启中..." "type": "server_response",
})) "status": "success",
"message": "服务器重启中...",
}
)
)
# 异步执行重启操作 # 异步执行重启操作
def restart_server(): def restart_server():
@@ -266,7 +290,7 @@ class ConnectionHandler:
stdin=sys.stdin, stdin=sys.stdin,
stdout=sys.stdout, stdout=sys.stdout,
stderr=sys.stderr, stderr=sys.stderr,
start_new_session=True start_new_session=True,
) )
os._exit(0) os._exit(0)
@@ -275,11 +299,15 @@ class ConnectionHandler:
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"重启失败: {str(e)}") self.logger.bind(tag=TAG).error(f"重启失败: {str(e)}")
await self.websocket.send(json.dumps({ await self.websocket.send(
"type": "server_response", json.dumps(
"status": "error", {
"message": f"Restart failed: {str(e)}" "type": "server_response",
})) "status": "error",
"message": f"Restart failed: {str(e)}",
}
)
)
def _initialize_components(self): def _initialize_components(self):
"""初始化组件""" """初始化组件"""
@@ -306,11 +334,13 @@ class ConnectionHandler:
"""初始化ASR和TTS上报线程""" """初始化ASR和TTS上报线程"""
if not self.read_config_from_api or self.need_bind: if not self.read_config_from_api or self.need_bind:
return return
if self.tts_report_thread is None or not self.tts_report_thread.is_alive(): if self.chat_history_conf == 0:
self.tts_report_thread = threading.Thread( return
target=self._tts_report_worker, daemon=True if self.report_thread is None or not self.report_thread.is_alive():
self.report_thread = threading.Thread(
target=self._report_worker, daemon=True
) )
self.tts_report_thread.start() self.report_thread.start()
self.logger.bind(tag=TAG).info("TTS上报线程已启动") self.logger.bind(tag=TAG).info("TTS上报线程已启动")
def _initialize_private_config(self): def _initialize_private_config(self):
@@ -377,9 +407,12 @@ class ConnectionHandler:
]["Intent"] ]["Intent"]
if private_config.get("prompt", None) is not None: if private_config.get("prompt", None) is not None:
self.config["prompt"] = private_config["prompt"] self.config["prompt"] = private_config["prompt"]
if private_config.get("summaryMemory", None) is not None:
self.config["summaryMemory"] = private_config["summaryMemory"]
if private_config.get("device_max_output_size", None) is not None: if private_config.get("device_max_output_size", None) is not None:
self.max_output_size = int(private_config["device_max_output_size"]) self.max_output_size = int(private_config["device_max_output_size"])
if private_config.get("chat_history_conf", None) is not None:
self.chat_history_conf = int(private_config["chat_history_conf"])
try: try:
modules = initialize_modules( modules = initialize_modules(
self.logger, self.logger,
@@ -409,7 +442,12 @@ class ConnectionHandler:
def _initialize_memory(self): def _initialize_memory(self):
"""初始化记忆模块""" """初始化记忆模块"""
self.memory.init_memory(self.device_id, self.llm) self.memory.init_memory(
role_id=self.device_id,
llm=self.llm,
summary_memory=self.config.get("summaryMemory", None),
save_to_file=not self.read_config_from_api,
)
def _initialize_intent(self): def _initialize_intent(self):
self.intent_type = self.config["Intent"][ self.intent_type = self.config["Intent"][
@@ -838,7 +876,7 @@ class ConnectionHandler:
if future is None: if future is None:
continue continue
text = None text = None
opus_datas, tts_file = [], None audio_datas, tts_file = [], None
try: try:
self.logger.bind(tag=TAG).debug("正在处理TTS任务...") self.logger.bind(tag=TAG).debug("正在处理TTS任务...")
tts_timeout = int(self.config.get("tts_timeout", 10)) tts_timeout = int(self.config.get("tts_timeout", 10))
@@ -860,8 +898,8 @@ class ConnectionHandler:
audio_datas, _ = self.tts.audio_to_pcm_data(tts_file) audio_datas, _ = self.tts.audio_to_pcm_data(tts_file)
else: else:
audio_datas, _ = self.tts.audio_to_opus_data(tts_file) audio_datas, _ = self.tts.audio_to_opus_data(tts_file)
# 在这里上报TTS数据(使用文件路径) # 在这里上报TTS数据
enqueue_tts_report(self, 2, text, audio_datas) enqueue_tts_report(self, text, audio_datas)
else: else:
self.logger.bind(tag=TAG).error( self.logger.bind(tag=TAG).error(
f"TTS出错:文件不存在{tts_file}" f"TTS出错:文件不存在{tts_file}"
@@ -917,13 +955,12 @@ class ConnectionHandler:
f"audio_play_priority priority_thread: {text} {e}" f"audio_play_priority priority_thread: {text} {e}"
) )
def _tts_report_worker(self): def _report_worker(self):
"""TTS上报工作线程""" """聊天记录上报工作线程"""
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
# 从队列获取数据,设置超时以便定期检查停止事件 # 从队列获取数据,设置超时以便定期检查停止事件
item = self.tts_report_queue.get(timeout=1) item = self.report_queue.get(timeout=1)
if item is None: # 检测毒丸对象 if item is None: # 检测毒丸对象
break break
@@ -931,18 +968,18 @@ class ConnectionHandler:
try: try:
# 执行上报(传入二进制数据) # 执行上报(传入二进制数据)
report_tts(self, type, text, audio_data) report(self, type, text, audio_data)
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"TTS上报线程异常: {e}") self.logger.bind(tag=TAG).error(f"聊天记录上报线程异常: {e}")
finally: finally:
# 标记任务完成 # 标记任务完成
self.tts_report_queue.task_done() self.report_queue.task_done()
except queue.Empty: except queue.Empty:
continue continue
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"TTS上报工作线程异常: {e}") self.logger.bind(tag=TAG).error(f"聊天记录上报工作线程异常: {e}")
self.logger.bind(tag=TAG).info("TTS上报线程已退出") self.logger.bind(tag=TAG).info("聊天记录上报线程已退出")
def speak_and_play(self, text, text_index=0): def speak_and_play(self, text, text_index=0):
if text is None or len(text) <= 0: if text is None or len(text) <= 0:
@@ -971,6 +1008,7 @@ class ConnectionHandler:
async def close(self, ws=None): async def close(self, ws=None):
"""资源清理方法""" """资源清理方法"""
# 取消超时任务 # 取消超时任务
if self.timeout_task: if self.timeout_task:
self.timeout_task.cancel() self.timeout_task.cancel()
@@ -980,43 +1018,42 @@ class ConnectionHandler:
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()
# 触发停止事件并清理资源 # 触发停止事件
if self.stop_event: if self.stop_event:
self.stop_event.set() self.stop_event.set()
# 立即关闭线程池
if self.executor:
self.executor.shutdown(wait=False, cancel_futures=True)
self.executor = None
# 添加毒丸对象到上报队列确保线程退出
self.tts_report_queue.put(None)
# 清空任务队列 # 清空任务队列
self.clear_queues() self.clear_queues()
# 关闭WebSocket连接
if ws: if ws:
await ws.close() await ws.close()
elif self.websocket: elif self.websocket:
await self.websocket.close() await self.websocket.close()
# 最后关闭线程池(避免阻塞)
if self.executor:
self.executor.shutdown(wait=False)
self.executor = None
self.logger.bind(tag=TAG).info("连接资源已释放") self.logger.bind(tag=TAG).info("连接资源已释放")
def clear_queues(self): def clear_queues(self):
# 清空所有任务队列 """清空所有任务队列"""
self.logger.bind(tag=TAG).debug( self.logger.bind(tag=TAG).debug(
f"开始清理: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}" f"开始清理: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
) )
# 使用非阻塞方式清空队列
for q in [self.tts_queue, self.audio_play_queue]: for q in [self.tts_queue, self.audio_play_queue]:
if not q: if not q:
continue continue
while not q.empty(): while True:
try: try:
q.get_nowait() q.get_nowait()
except queue.Empty: except queue.Empty:
continue break
q.queue.clear()
# 添加毒丸信号到队列,确保线程退出
# q.queue.put(None)
self.logger.bind(tag=TAG).debug( self.logger.bind(tag=TAG).debug(
f"清理结束: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}" f"清理结束: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
) )
@@ -4,7 +4,7 @@ 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 from core.utils.output_counter import check_device_output_limit
from core.handle.ttsReportHandle import enqueue_tts_report from core.handle.reportHandle import enqueue_asr_report
from core.utils.util import audio_to_data from core.utils.util import audio_to_data
TAG = __name__ TAG = __name__
@@ -44,7 +44,7 @@ async def handleAudioMessage(conn, audio):
text_len, _ = remove_punctuation_and_length(text) text_len, _ = remove_punctuation_and_length(text)
if text_len > 0: if text_len > 0:
# 使用自定义模块进行上报 # 使用自定义模块进行上报
enqueue_tts_report(conn, 1, text, copy.deepcopy(conn.asr_audio)) enqueue_asr_report(conn, text, copy.deepcopy(conn.asr_audio))
await startToChat(conn, text) await startToChat(conn, text)
else: else:
@@ -98,9 +98,14 @@ async def no_voice_close_connect(conn):
conn.close_after_chat = True conn.close_after_chat = True
conn.client_abort = False conn.client_abort = False
conn.asr_server_receive = False conn.asr_server_receive = False
prompt = ( end_prompt = conn.config.get("end_prompt", {})
"请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。" if end_prompt and end_prompt.get("enable", True) is False:
) conn.logger.bind(tag=TAG).info("结束对话,无需发送结束提示语")
await conn.close()
return
prompt = end_prompt.get("prompt")
if not prompt:
prompt = "请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。!"
await startToChat(conn, prompt) await startToChat(conn, prompt)
@@ -11,13 +11,13 @@ TTS上报功能已集成到ConnectionHandler类中。
import opuslib_next import opuslib_next
from config.manage_api_client import report from config.manage_api_client import report as manage_report
TAG = __name__ TAG = __name__
def report_tts(conn, type, text, opus_data): def report(conn, type, text, opus_data):
"""执行TTS上报操作 """执行聊天记录上报操作
Args: Args:
conn: 连接对象 conn: 连接对象
@@ -31,7 +31,7 @@ def report_tts(conn, type, text, opus_data):
else: else:
audio_data = None audio_data = None
# 执行上报 # 执行上报
report( manage_report(
mac_address=conn.device_id, mac_address=conn.device_id,
session_id=conn.session_id, session_id=conn.session_id,
chat_type=type, chat_type=type,
@@ -39,7 +39,7 @@ def report_tts(conn, type, text, opus_data):
audio=audio_data, audio=audio_data,
) )
except Exception as e: except Exception as e:
conn.logger.bind(tag=TAG).error(f"TTS上报失败: {e}") conn.logger.bind(tag=TAG).error(f"聊天记录上报失败: {e}")
def opus_to_wav(conn, opus_data): def opus_to_wav(conn, opus_data):
@@ -89,8 +89,10 @@ def opus_to_wav(conn, opus_data):
return bytes(wav_header) + pcm_data_bytes return bytes(wav_header) + pcm_data_bytes
def enqueue_tts_report(conn, type, text, opus_data): def enqueue_tts_report(conn, text, opus_data):
if not conn.read_config_from_api or conn.need_bind: if not conn.read_config_from_api or conn.need_bind or not conn.report_tts_enable:
return
if conn.chat_history_conf == 0:
return return
"""将TTS数据加入上报队列 """将TTS数据加入上报队列
@@ -101,10 +103,43 @@ def enqueue_tts_report(conn, type, text, opus_data):
""" """
try: try:
# 使用连接对象的队列,传入文本和二进制数据而非文件路径 # 使用连接对象的队列,传入文本和二进制数据而非文件路径
conn.tts_report_queue.put((type, text, opus_data)) if conn.chat_history_conf == 2:
conn.report_queue.put((2, text, opus_data))
conn.logger.bind(tag=TAG).debug( conn.logger.bind(tag=TAG).debug(
f"TTS数据已加入上报队列: {conn.device_id}, 音频大小: {len(opus_data)} " f"TTS数据已加入上报队列: {conn.device_id}, 音频大小: {len(opus_data)} "
) )
else:
conn.report_queue.put((2, text, None))
conn.logger.bind(tag=TAG).debug(
f"TTS数据已加入上报队列: {conn.device_id}, 不上报音频"
)
except Exception as e: except Exception as e:
conn.logger.bind(tag=TAG).error(f"加入TTS上报队列失败: {text}, {e}") conn.logger.bind(tag=TAG).error(f"加入TTS上报队列失败: {text}, {e}")
def enqueue_asr_report(conn, text, opus_data):
if not conn.read_config_from_api or conn.need_bind or not conn.report_asr_enable:
return
if conn.chat_history_conf == 0:
return
"""将ASR数据加入上报队列
Args:
conn: 连接对象
text: 合成文本
opus_data: opus音频数据
"""
try:
# 使用连接对象的队列,传入文本和二进制数据而非文件路径
if conn.chat_history_conf == 2:
conn.report_queue.put((1, text, opus_data))
conn.logger.bind(tag=TAG).debug(
f"ASR数据已加入上报队列: {conn.device_id}, 音频大小: {len(opus_data)} "
)
else:
conn.report_queue.put((1, text, None))
conn.logger.bind(tag=TAG).debug(
f"ASR数据已加入上报队列: {conn.device_id}, 不上报音频"
)
except Exception as e:
conn.logger.bind(tag=TAG).error(f"加入ASR上报队列失败: {text}, {e}")
@@ -5,7 +5,7 @@ from core.utils.util import remove_punctuation_and_length
from core.handle.receiveAudioHandle import startToChat, handleAudioMessage from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
from core.handle.sendAudioHandle import send_stt_message, send_tts_message from core.handle.sendAudioHandle import send_stt_message, send_tts_message
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
from core.handle.ttsReportHandle import enqueue_tts_report from core.handle.reportHandle import enqueue_asr_report
import asyncio import asyncio
TAG = __name__ TAG = __name__
@@ -56,11 +56,11 @@ async def handleTextMessage(conn, message):
await send_tts_message(conn, "stop", None) await send_tts_message(conn, "stop", None)
elif is_wakeup_words: elif is_wakeup_words:
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据) # 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_tts_report(conn, 1, "嘿,你好呀", []) enqueue_asr_report(conn, "嘿,你好呀", [])
await startToChat(conn, "嘿,你好呀") await startToChat(conn, "嘿,你好呀")
else: else:
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据) # 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_tts_report(conn, 1, text, []) enqueue_asr_report(conn, text, [])
# 否则需要LLM对文字内容进行答复 # 否则需要LLM对文字内容进行答复
await startToChat(conn, text) await startToChat(conn, text)
elif msg_json["type"] == "iot": elif msg_json["type"] == "iot":
@@ -55,7 +55,7 @@ class ASRProvider(ASRProviderBase):
) -> Tuple[Optional[str], Optional[str]]: ) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本""" """将语音数据转换为文本"""
if not opus_data: if not opus_data:
logger.bind(tag=TAG).warn("音频数据为空!") logger.bind(tag=TAG).warning("音频数据为空!")
return None, None return None, None
file_path = None file_path = None
@@ -9,6 +9,7 @@ import wave
import websockets import websockets
from config.logger import setup_logging from config.logger import setup_logging
import asyncio import asyncio
import re
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -24,7 +25,12 @@ class ASRProvider(ASRProviderBase):
super().__init__() super().__init__()
self.host = config.get("host", "localhost") self.host = config.get("host", "localhost")
self.port = config.get("port", 10095) self.port = config.get("port", 10095)
self.is_ssl = config.get("is_ssl", True) self.api_key = config.get("api_key", "none")
self.is_ssl = str(config.get("is_ssl", True)).lower() in (
"true",
"1",
"yes",
)
self.output_dir = config.get("output_dir") self.output_dir = config.get("output_dir")
self.delete_audio_file = delete_audio_file self.delete_audio_file = delete_audio_file
self.uri = ( self.uri = (
@@ -130,9 +136,13 @@ class ASRProvider(ASRProviderBase):
pass pass
else: else:
file_path = self.save_audio_to_file(pcm_data, session_id) file_path = self.save_audio_to_file(pcm_data, session_id)
auth_header = {"Authorization": "Bearer; {}".format(self.api_key)}
async with websockets.connect( async with websockets.connect(
self.uri, subprotocols=["binary"], ping_interval=None, ssl=self.ssl_context self.uri,
additional_headers=auth_header,
subprotocols=["binary"],
ping_interval=None,
ssl=self.ssl_context,
) as ws: ) as ws:
try: try:
# Use asyncio to handle WebSocket communication # Use asyncio to handle WebSocket communication
@@ -157,6 +167,9 @@ class ASRProvider(ASRProviderBase):
# Get the result from the receive task # Get the result from the receive task
result = receive_task.result() result = receive_task.result()
match = re.match(r"<\|(.*?)\|><\|(.*?)\|><\|(.*?)\|>(.*)", result)
if match:
result = match.group(4).strip()
return ( return (
result, result,
file_path, file_path,
@@ -52,7 +52,7 @@ class ASRProvider(ASRProviderBase):
) -> Tuple[Optional[str], Optional[str]]: ) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本""" """将语音数据转换为文本"""
if not opus_data: if not opus_data:
logger.bind(tag=TAG).warn("音频数据为空!") logger.bind(tag=TAG).warning("音频数据为空!")
return None, None return None, None
file_path = None file_path = None
@@ -230,7 +230,7 @@ class ASRProvider(ASRProviderBase):
if "Response" in response_json and "Result" in response_json["Response"]: if "Response" in response_json and "Result" in response_json["Response"]:
return response_json["Response"]["Result"] return response_json["Response"]["Result"]
else: else:
logger.bind(tag=TAG).warn(f"响应中没有识别结果: {response_json}") logger.bind(tag=TAG).warning(f"响应中没有识别结果: {response_json}")
return "" return ""
except Exception as e: except Exception as e:
@@ -215,9 +215,18 @@ class IntentProvider(IntentProviderBase):
# 记录识别到的function call # 记录识别到的function call
logger.bind(tag=TAG).info( logger.bind(tag=TAG).info(
f"识别到function call: {function_name}, 参数: {function_args}" f"llm 识别到意图: {function_name}, 参数: {function_args}"
) )
# 如果是继续聊天,清理工具调用相关的历史消息
if function_name == "continue_chat":
# 保留非工具相关的消息
clean_history = [
msg for msg in conn.dialogue.dialogue
if msg.role not in ["tool", "function"]
]
conn.dialogue.dialogue = clean_history
# 添加到缓存 # 添加到缓存
self.intent_cache[cache_key] = { self.intent_cache[cache_key] = {
"intent": intent, "intent": intent,
@@ -61,5 +61,6 @@ class LLMProvider(LLMProviderBase):
yield "【LLM服务响应异常】" yield "【LLM服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"阿里百练暂未实现完整的工具调用(function call") logger.bind(tag=TAG).error(
return self.response(session_id, dialogue) f"阿里百练暂未实现完整的工具调用(function call),建议使用其他意图识别"
)
@@ -66,5 +66,6 @@ class LLMProvider(LLMProviderBase):
yield "【服务响应异常】" yield "【服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"fastgpt暂未实现完整的工具调用(function call") logger.bind(tag=TAG).error(
return self.response(session_id, dialogue) f"fastgpt暂未实现完整的工具调用(function call),建议使用其他意图识别"
)
@@ -1,140 +1,205 @@
import google.generativeai as genai import os, json, uuid
from core.utils.util import check_model_key from types import SimpleNamespace
from core.providers.llm.base import LLMProviderBase from typing import Any, Dict, List
from config.logger import setup_logging
import requests
import json
import requests
from google import generativeai as genai
from google.generativeai import types, GenerationConfig
from core.providers.llm.base import LLMProviderBase
from core.utils.util import check_model_key
from config.logger import setup_logging
from google.generativeai.types import GenerateContentResponse
from requests import RequestException
log = setup_logging()
TAG = __name__ TAG = __name__
logger = setup_logging()
def test_proxy(proxy_url: str, test_url: str) -> bool:
try:
resp = requests.get(test_url, proxies={"http": proxy_url, "https": proxy_url})
return 200 <= resp.status_code < 400
except RequestException:
return False
def setup_proxy_env(http_proxy: str | None, https_proxy: str | None):
"""
分别测试 HTTP 和 HTTPS 代理是否可用,并设置环境变量。
如果 HTTPS 代理不可用但 HTTP 可用,会将 HTTPS_PROXY 也指向 HTTP。
"""
test_http_url = "http://www.google.com"
test_https_url = "https://www.google.com"
ok_http = ok_https = False
if http_proxy:
ok_http = test_proxy(http_proxy, test_http_url)
if ok_http:
os.environ["HTTP_PROXY"] = http_proxy
log.bind(tag=TAG).info(f"配置提供的Gemini HTTPS代理连通成功: {http_proxy}")
else:
log.bind(tag=TAG).warning(f"配置提供的Gemini HTTP代理不可用: {http_proxy}")
if https_proxy:
ok_https = test_proxy(https_proxy, test_https_url)
if ok_https:
os.environ["HTTPS_PROXY"] = https_proxy
log.bind(tag=TAG).info(f"配置提供的Gemini HTTPS代理连通成功: {https_proxy}")
else:
log.bind(tag=TAG).warning(
f"配置提供的Gemini HTTPS代理不可用: {https_proxy}"
)
# 如果https_proxy不可用,但http_proxy可用且能走通https,则复用http_proxy作为https_proxy
if ok_http and not ok_https:
if test_proxy(http_proxy, test_https_url):
os.environ["HTTPS_PROXY"] = http_proxy
ok_https = True
log.bind(tag=TAG).info(f"复用HTTP代理作为HTTPS代理: {http_proxy}")
if not ok_http and not ok_https:
log.bind(tag=TAG).error(
f"Gemini 代理设置失败: HTTP 和 HTTPS 代理都不可用,请检查配置"
)
raise RuntimeError("HTTP 和 HTTPS 代理都不可用,请检查配置")
class LLMProvider(LLMProviderBase): class LLMProvider(LLMProviderBase):
def __init__(self, config): def __init__(self, cfg: Dict[str, Any]):
"""初始化Gemini LLM Provider""" self.model_name = cfg.get("model_name", "gemini-2.0-flash")
self.model_name = config.get("model_name", "gemini-1.5-pro") self.api_key = cfg["api_key"]
self.api_key = config.get("api_key") http_proxy = cfg.get("http_proxy")
self.http_proxy = config.get("http_proxy") https_proxy = cfg.get("https_proxy")
self.https_proxy = config.get("https_proxy")
have_key = check_model_key("LLM", self.api_key)
if not have_key: if not check_model_key("LLM", self.api_key):
return raise ValueError("无效的Gemini API Key,请检查是否配置正确")
try: if http_proxy or https_proxy:
# 初始化Gemini客户端 log.bind(tag=TAG).info(
# 配置代理(如果提供了代理配置) f"检测到Gemini代理配置,开始测试代理连通性和设置代理环境..."
self.proxies = None )
if self.http_proxy is not "" or self.https_proxy is not "": setup_proxy_env(http_proxy, https_proxy)
log.bind(tag=TAG).info(
f"Gemini 代理设置成功 - HTTP: {http_proxy}, HTTPS: {https_proxy}"
)
genai.configure(api_key=self.api_key)
self.model = genai.GenerativeModel(self.model_name)
self.proxies = { self.gen_cfg = GenerationConfig(
"http": self.http_proxy, temperature=0.7,
"https": self.https_proxy, top_p=0.9,
} top_k=40,
logger.bind(tag=TAG).info(f"Gemini set proxys:{self.proxies}") max_output_tokens=2048,
# 使用猴子补丁修改 google-generativeai 库的请求会话 )
# 使用 session 对象配置 genai @staticmethod
def _build_tools(funcs: List[Dict[str, Any]] | None):
genai.configure(api_key=self.api_key) if not funcs:
self.model = genai.GenerativeModel(self.model_name) return None
return [
# 设置生成参数 types.Tool(
self.generation_config = { function_declarations=[
"temperature": 0.7, types.FunctionDeclaration(
"top_p": 0.9, name=f["function"]["name"],
"top_k": 40, description=f["function"]["description"],
"max_output_tokens": 2048, parameters=f["function"]["parameters"],
} )
self.chat = None for f in funcs
except Exception as e: ]
logger.bind(tag=TAG).error(f"Gemini初始化失败: {e}") )
self.model = None ]
# Gemini文档提到,无需维护session-id,直接用dialogue拼接而成
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
"""生成Gemini对话响应""" yield from self._generate(dialogue, None)
if not self.model:
yield "【Gemini服务未正确初始化】"
return
try:
# 处理对话历史
chat_history = []
for msg in dialogue[:-1]: # 历史对话
role = "model" if msg["role"] == "assistant" else "user"
content = msg["content"].strip()
if content:
chat_history.append({"role": role, "parts": [{"text": content}]})
# 获取当前消息
current_msg = dialogue[-1]["content"]
# 构建请求体
request_body = {
"contents": chat_history
+ [{"role": "user", "parts": [{"text": current_msg}]}],
"generationConfig": self.generation_config,
}
# 构建请求URL
url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model_name}:generateContent?key={self.api_key}"
# 构建请求头
headers = {
"Content-Type": "application/json",
}
# 发送POST请求,经测试手动 request 无法使用 stream 模式
if self.proxies:
response = requests.post(
url,
headers=headers,
json=request_body,
stream=False,
proxies=self.proxies,
)
try:
data = response.json() # 直接解析JSON
if "candidates" in data and data["candidates"]:
yield data["candidates"][0]["content"]["parts"][0]["text"]
else:
yield "未找到候选回复。"
except json.JSONDecodeError as e:
yield f"JSON解码错误:{e}"
except Exception as e:
yield f"发生错误:{e}"
else:
logger.bind(tag=TAG).info(f"Gemini stream mode ")
chat = self.model.start_chat(history=chat_history)
# 发送消息并获取流式响应
response = chat.send_message(
current_msg, stream=True, generation_config=self.generation_config
)
# 处理流式响应
for chunk in response:
if hasattr(chunk, "text") and chunk.text:
yield chunk.text
except Exception as e:
error_msg = str(e)
logger.bind(tag=TAG).error(f"Gemini响应生成错误: {error_msg}")
# 针对不同错误返回友好提示
if "Rate limit" in error_msg:
yield "【Gemini服务请求太频繁,请稍后再试】"
elif "Invalid API key" in error_msg:
yield "【Gemini API key无效】"
else:
yield f"【Gemini服务响应异常: {error_msg}"
except requests.exceptions.RequestException as e:
yield f"请求失败:{e}"
except json.JSONDecodeError as e:
yield f"JSON解码错误:{e}"
except Exception as e:
yield f"发生错误:{e}"
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"gemini暂未实现完整的工具调用(function call") yield from self._generate(dialogue, self._build_tools(functions))
return self.response(session_id, dialogue)
def _generate(self, dialogue, tools):
role_map = {"assistant": "model", "user": "user"}
contents: list = []
# 拼接对话
for m in dialogue:
r = m["role"]
if r == "assistant" and "tool_calls" in m:
tc = m["tool_calls"][0]
contents.append(
{
"role": "model",
"parts": [
{
"function_call": {
"name": tc["function"]["name"],
"args": json.loads(tc["function"]["arguments"]),
}
}
],
}
)
continue
if r == "tool":
contents.append(
{
"role": "model",
"parts": [{"text": str(m.get("content", ""))}],
}
)
continue
contents.append(
{
"role": role_map.get(r, "user"),
"parts": [{"text": str(m.get("content", ""))}],
}
)
stream: GenerateContentResponse = self.model.generate_content(
contents=contents,
generation_config=self.gen_cfg,
tools=tools,
stream=True,
)
try:
for chunk in stream:
cand = chunk.candidates[0]
for part in cand.content.parts:
# a) 函数调用-通常是最后一段话才是函数调用
if getattr(part, "function_call", None):
fc = part.function_call
yield None, [
SimpleNamespace(
id=uuid.uuid4().hex,
type="function",
function=SimpleNamespace(
name=fc.name,
arguments=json.dumps(
dict(fc.args), ensure_ascii=False
),
),
)
]
return
# b) 普通文本
if getattr(part, "text", None):
yield part.text if tools is None else (part.text, None)
finally:
if tools is not None:
yield None, None # functionmode 结束,返回哑包
# 关闭stream,预留后续打断对话功能的功能方法,官方文档推荐打断对话要关闭上一个流,可以有效减少配额计费和资源占用
@staticmethod
def _safe_finish_stream(stream: GenerateContentResponse):
if hasattr(stream, "resolve"):
stream.resolve() # Gemini SDK version ≥ 0.5.0
elif hasattr(stream, "close"):
stream.close() # Gemini SDK version < 0.5.0
else:
for _ in stream: # 兜底耗尽
pass
@@ -66,7 +66,6 @@ class LLMProvider(LLMProviderBase):
logger.bind(tag=TAG).error(f"生成响应时出错: {e}") logger.bind(tag=TAG).error(f"生成响应时出错: {e}")
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info( logger.bind(tag=TAG).error(
f"homeassistant不支持(function call),建议使用意图识别使用:nointent" f"homeassistant不支持(function call),建议使用其他意图识别"
) )
return self.response(session_id, dialogue)
@@ -21,27 +21,67 @@ class LLMProvider(LLMProviderBase):
api_key="ollama" # Ollama doesn't need an API key but OpenAI client requires one api_key="ollama" # Ollama doesn't need an API key but OpenAI client requires one
) )
# 检查是否是qwen3模型
self.is_qwen3 = self.model_name and self.model_name.lower().startswith("qwen3")
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
try: try:
# 如果是qwen3模型,在用户最后一条消息中添加/no_think指令
if self.is_qwen3:
# 复制对话列表,避免修改原始对话
dialogue_copy = dialogue.copy()
# 找到最后一条用户消息
for i in range(len(dialogue_copy) - 1, -1, -1):
if dialogue_copy[i]["role"] == "user":
# 在用户消息前添加/no_think指令
dialogue_copy[i]["content"] = "/no_think " + dialogue_copy[i]["content"]
logger.bind(tag=TAG).debug(f"为qwen3模型添加/no_think指令")
break
# 使用修改后的对话
dialogue = dialogue_copy
responses = self.client.chat.completions.create( responses = self.client.chat.completions.create(
model=self.model_name, model=self.model_name,
messages=dialogue, messages=dialogue,
stream=True stream=True
) )
is_active=True is_active = True
# 用于处理跨chunk的标签
buffer = ""
for chunk in responses: for chunk in responses:
try: try:
delta = chunk.choices[0].delta if getattr(chunk, 'choices', None) else None delta = chunk.choices[0].delta if getattr(chunk, 'choices', None) else None
content = delta.content if hasattr(delta, 'content') else '' content = delta.content if hasattr(delta, 'content') else ''
if content: if content:
if '<think>' in content: # 将内容添加到缓冲区
buffer += content
# 处理缓冲区中的标签
while '<think>' in buffer and '</think>' in buffer:
# 找到完整的<think></think>标签并移除
pre = buffer.split('<think>', 1)[0]
post = buffer.split('</think>', 1)[1]
buffer = pre + post
# 处理只有开始标签的情况
if '<think>' in buffer:
is_active = False is_active = False
content = content.split('<think>')[0] buffer = buffer.split('<think>', 1)[0]
if '</think>' in content:
# 处理只有结束标签的情况
if '</think>' in buffer:
is_active = True is_active = True
content = content.split('</think>')[-1] buffer = buffer.split('</think>', 1)[1]
if is_active:
yield content # 如果当前处于活动状态且缓冲区有内容,则输出
if is_active and buffer:
yield buffer
buffer = "" # 清空缓冲区
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error processing chunk: {e}") logger.bind(tag=TAG).error(f"Error processing chunk: {e}")
@@ -51,6 +91,22 @@ class LLMProvider(LLMProviderBase):
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
try: try:
# 如果是qwen3模型,在用户最后一条消息中添加/no_think指令
if self.is_qwen3:
# 复制对话列表,避免修改原始对话
dialogue_copy = dialogue.copy()
# 找到最后一条用户消息
for i in range(len(dialogue_copy) - 1, -1, -1):
if dialogue_copy[i]["role"] == "user":
# 在用户消息前添加/no_think指令
dialogue_copy[i]["content"] = "/no_think " + dialogue_copy[i]["content"]
logger.bind(tag=TAG).debug(f"为qwen3模型添加/no_think指令")
break
# 使用修改后的对话
dialogue = dialogue_copy
stream = self.client.chat.completions.create( stream = self.client.chat.completions.create(
model=self.model_name, model=self.model_name,
messages=dialogue, messages=dialogue,
@@ -58,8 +114,49 @@ class LLMProvider(LLMProviderBase):
tools=functions, tools=functions,
) )
is_active = True
buffer = ""
for chunk in stream: for chunk in stream:
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls try:
delta = chunk.choices[0].delta if getattr(chunk, 'choices', None) else None
content = delta.content if hasattr(delta, 'content') else None
tool_calls = delta.tool_calls if hasattr(delta, 'tool_calls') else None
# 如果是工具调用,直接传递
if tool_calls:
yield None, tool_calls
continue
# 处理文本内容
if content:
# 将内容添加到缓冲区
buffer += content
# 处理缓冲区中的标签
while '<think>' in buffer and '</think>' in buffer:
# 找到完整的<think></think>标签并移除
pre = buffer.split('<think>', 1)[0]
post = buffer.split('</think>', 1)[1]
buffer = pre + post
# 处理只有开始标签的情况
if '<think>' in buffer:
is_active = False
buffer = buffer.split('<think>', 1)[0]
# 处理只有结束标签的情况
if '</think>' in buffer:
is_active = True
buffer = buffer.split('</think>', 1)[1]
# 如果当前处于活动状态且缓冲区有内容,则输出
if is_active and buffer:
yield buffer, None
buffer = "" # 清空缓冲区
except Exception as e:
logger.bind(tag=TAG).error(f"Error processing function chunk: {e}")
continue
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}")
@@ -4,6 +4,7 @@ from config.logger import setup_logging
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
class MemoryProviderBase(ABC): class MemoryProviderBase(ABC):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
@@ -20,6 +21,6 @@ class MemoryProviderBase(ABC):
"""Query memories for specific role based on similarity""" """Query memories for specific role based on similarity"""
return "please implement query method" return "please implement query method"
def init_memory(self, role_id, llm): def init_memory(self, role_id, llm, **kwargs):
self.role_id = role_id self.role_id = role_id
self.llm = llm self.llm = llm
@@ -8,7 +8,7 @@ TAG = __name__
class MemoryProvider(MemoryProviderBase): class MemoryProvider(MemoryProviderBase):
def __init__(self, config): def __init__(self, config, summary_memory=None):
super().__init__(config) super().__init__(config)
self.api_key = config.get("api_key", "") self.api_key = config.get("api_key", "")
self.api_version = config.get("api_version", "v1.1") self.api_version = config.get("api_version", "v1.1")
@@ -4,6 +4,7 @@ import json
import os import os
import yaml import yaml
from config.config_loader import get_project_dir from config.config_loader import get_project_dir
from config.manage_api_client import save_mem_local_short
short_term_memory_prompt = """ short_term_memory_prompt = """
@@ -72,6 +73,17 @@ short_term_memory_prompt = """
``` ```
""" """
short_term_memory_prompt_only_content = """
你是一个经验丰富的记忆总结者,擅长将对话内容进行总结摘要,遵循以下规则:
1、总结user的重要信息,以便在未来的对话中提供更个性化的服务
2、不要重复总结,不要遗忘之前记忆,除非原来的记忆超过了1800字内,否则不要遗忘、不要压缩用户的历史记忆
3、用户操控的设备音量、播放音乐、天气、退出、不想对话等和用户本身无关的内容,这些信息不需要加入到总结中
4、不要把设备操控的成果结果和失败结果加入到总结中,也不要把用户的一些废话加入到总结中
5、不要为了总结而总结,如果用户的聊天没有意义,请返回原来的历史记录也是可以的
6、只需要返回总结摘要,严格控制在1800字内
7、不要包含代码、xml,不需要解释、注释和说明,保存记忆时仅从对话提取信息,不要混入示例内容
"""
def extract_json_data(json_code): def extract_json_data(json_code):
start = json_code.find("```json") start = json_code.find("```json")
@@ -93,17 +105,26 @@ TAG = __name__
class MemoryProvider(MemoryProviderBase): class MemoryProvider(MemoryProviderBase):
def __init__(self, config): def __init__(self, config, summary_memory):
super().__init__(config) super().__init__(config)
self.short_momery = "" self.short_momery = ""
self.save_to_file = True
self.memory_path = get_project_dir() + "data/.memory.yaml" self.memory_path = get_project_dir() + "data/.memory.yaml"
self.load_memory() self.load_memory(summary_memory)
def init_memory(self, role_id, llm): def init_memory(
super().init_memory(role_id, llm) self, role_id, llm, summary_memory=None, save_to_file=True, **kwargs
self.load_memory() ):
super().init_memory(role_id, llm, **kwargs)
self.save_to_file = save_to_file
self.load_memory(summary_memory)
def load_memory(self, summary_memory):
# api获取到总结记忆后直接返回
if summary_memory or not self.save_to_file:
self.short_momery = summary_memory
return
def load_memory(self):
all_memory = {} all_memory = {}
if os.path.exists(self.memory_path): if os.path.exists(self.memory_path):
with open(self.memory_path, "r", encoding="utf-8") as f: with open(self.memory_path, "r", encoding="utf-8") as f:
@@ -134,7 +155,7 @@ class MemoryProvider(MemoryProviderBase):
msgStr += f"User: {msg.content}\n" msgStr += f"User: {msg.content}\n"
elif msg.role == "assistant": elif msg.role == "assistant":
msgStr += f"Assistant: {msg.content}\n" msgStr += f"Assistant: {msg.content}\n"
if len(self.short_momery) > 0: if self.short_momery and len(self.short_momery) > 0:
msgStr += "历史记忆:\n" msgStr += "历史记忆:\n"
msgStr += self.short_momery msgStr += self.short_momery
@@ -142,16 +163,20 @@ class MemoryProvider(MemoryProviderBase):
time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
msgStr += f"当前时间:{time_str}" msgStr += f"当前时间:{time_str}"
result = self.llm.response_no_stream(short_term_memory_prompt, msgStr) if self.save_to_file:
result = self.llm.response_no_stream(short_term_memory_prompt, msgStr)
json_str = extract_json_data(result) json_str = extract_json_data(result)
try: try:
json_data = json.loads(json_str) # 检查json格式是否正确 json.loads(json_str) # 检查json格式是否正确
self.short_momery = json_str self.short_momery = json_str
except Exception as e: self.save_memory_to_file()
print("Error:", e) except Exception as e:
print("Error:", e)
self.save_memory_to_file() else:
result = self.llm.response_no_stream(
short_term_memory_prompt_only_content, msgStr
)
save_mem_local_short(self.role_id, result)
logger.bind(tag=TAG).info(f"Save memory successful - Role: {self.role_id}") logger.bind(tag=TAG).info(f"Save memory successful - Role: {self.role_id}")
return self.short_momery return self.short_momery
@@ -1,18 +1,20 @@
''' """
不使用记忆,可以选择此模块 不使用记忆,可以选择此模块
''' """
from ..base import MemoryProviderBase, logger from ..base import MemoryProviderBase, logger
TAG = __name__ TAG = __name__
class MemoryProvider(MemoryProviderBase): class MemoryProvider(MemoryProviderBase):
def __init__(self, config): def __init__(self, config, summary_memory=None):
super().__init__(config) super().__init__(config)
async def save_memory(self, msgs): async def save_memory(self, msgs):
logger.bind(tag=TAG).debug("nomem mode: No memory saving is performed.") logger.bind(tag=TAG).debug("nomem mode: No memory saving is performed.")
return None return None
async def query_memory(self, query: str)-> str: async def query_memory(self, query: str) -> str:
logger.bind(tag=TAG).debug("nomem mode: No memory query is performed.") logger.bind(tag=TAG).debug("nomem mode: No memory query is performed.")
return "" return ""
@@ -85,16 +85,22 @@ 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.reference_id = config.get("reference_id") self.reference_id = (
None if not config.get("reference_id") else config.get("reference_id")
)
self.reference_audio = parse_string_to_list(config.get("reference_audio")) self.reference_audio = parse_string_to_list(config.get("reference_audio"))
self.reference_text = parse_string_to_list(config.get("reference_text")) self.reference_text = parse_string_to_list(config.get("reference_text"))
self.format = config.get("format", "wav") self.format = config.get("response_format", "wav")
self.api_key = config.get("api_key", "YOUR_API_KEY") self.api_key = config.get("api_key", "YOUR_API_KEY")
have_key = check_model_key("FishSpeech TTS", self.api_key) have_key = check_model_key("FishSpeech TTS", self.api_key)
if not have_key: if not have_key:
return return
self.normalize = config.get("normalize", True) self.normalize = str(config.get("normalize", True)).lower() in (
"true",
"1",
"yes",
)
# 处理空字符串的情况 # 处理空字符串的情况
channels = config.get("channels", "1") channels = config.get("channels", "1")
@@ -124,7 +130,7 @@ class TTSProvider(TTSProviderBase):
"yes", "yes",
) )
self.use_memory_cache = config.get("use_memory_cache", "on") self.use_memory_cache = config.get("use_memory_cache", "on")
self.seed = config.get("seed") or None self.seed = int(config.get("seed")) if config.get("seed") else None
self.api_url = config.get("api_url", "http://127.0.0.1:8080/v1/tts") self.api_url = config.get("api_url", "http://127.0.0.1:8080/v1/tts")
def generate_filename(self, extension=".wav"): def generate_filename(self, extension=".wav"):
+2 -1
View File
@@ -75,7 +75,8 @@ class Dialogue:
if system_message: if system_message:
enhanced_system_prompt = ( enhanced_system_prompt = (
f"{system_message.content}\n\n" f"相关记忆:\n{memory_str}" f"{system_message.content}\n\n"
f"以下是用户的历史记忆:\n```\n{memory_str}\n```"
) )
dialogue.append({"role": "system", "content": enhanced_system_prompt}) dialogue.append({"role": "system", "content": enhanced_system_prompt})
+1 -2
View File
@@ -319,6 +319,7 @@ def initialize_modules(
modules["memory"] = memory.create_instance( modules["memory"] = memory.create_instance(
memory_type, memory_type,
config["Memory"][select_memory_module], config["Memory"][select_memory_module],
config.get('summaryMemory', None),
) )
logger.bind(tag=TAG).info(f"初始化组件: memory成功 {select_memory_module}") logger.bind(tag=TAG).info(f"初始化组件: memory成功 {select_memory_module}")
@@ -930,7 +931,6 @@ def check_vad_update(before_config, new_config):
if "type" not in new_config["VAD"][new_vad_module] if "type" not in new_config["VAD"][new_vad_module]
else new_config["VAD"][new_vad_module]["type"] else new_config["VAD"][new_vad_module]["type"]
) )
print(f"前vad:{current_vad_type},后vad:{new_vad_type}")
update_vad = current_vad_type != new_vad_type update_vad = current_vad_type != new_vad_type
return update_vad return update_vad
@@ -954,6 +954,5 @@ def check_asr_update(before_config, new_config):
if "type" not in new_config["ASR"][new_asr_module] if "type" not in new_config["ASR"][new_asr_module]
else new_config["ASR"][new_asr_module]["type"] else new_config["ASR"][new_asr_module]["type"]
) )
print(f"前asr:{current_asr_type},后asr:{new_asr_type}")
update_asr = current_asr_type != new_asr_type update_asr = current_asr_type != new_asr_type
return update_asr return update_asr
@@ -76,6 +76,7 @@ services:
expose: expose:
- 6379 - 6379
container_name: xiaozhi-esp32-server-redis container_name: xiaozhi-esp32-server-redis
restart: always
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 10s interval: 10s
@@ -107,8 +107,8 @@ WEATHER_CODE_MAP = {
} }
def fetch_city_info(location, api_key): def fetch_city_info(location, api_key, api_host):
url = f"https://geoapi.qweather.com/v2/city/lookup?key={api_key}&location={location}&lang=zh" url = f"https://{api_host}/geo/v2/city/lookup?key={api_key}&location={location}&lang=zh"
response = requests.get(url, headers=HEADERS).json() response = requests.get(url, headers=HEADERS).json()
return response.get("location", [])[0] if response.get("location") else None return response.get("location", [])[0] if response.get("location") else None
@@ -151,7 +151,8 @@ def parse_weather_info(soup):
@register_function("get_weather", GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL) @register_function("get_weather", GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL)
def get_weather(conn, location: str = None, lang: str = "zh_CN"): def get_weather(conn, location: str = None, lang: str = "zh_CN"):
api_key = conn.config["plugins"]["get_weather"]["api_key"] api_host = conn.config["plugins"]["get_weather"].get("api_host", "mj7p3y7naa.re.qweatherapi.com")
api_key = conn.config["plugins"]["get_weather"].get("api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da")
default_location = conn.config["plugins"]["get_weather"]["default_location"] default_location = conn.config["plugins"]["get_weather"]["default_location"]
client_ip = conn.client_ip client_ip = conn.client_ip
# 优先使用用户提供的location参数 # 优先使用用户提供的location参数
@@ -164,8 +165,7 @@ def get_weather(conn, location: str = None, lang: str = "zh_CN"):
else: else:
# 若IP解析失败或无IP,使用默认位置 # 若IP解析失败或无IP,使用默认位置
location = default_location location = default_location
city_info = fetch_city_info(location, api_key, api_host)
city_info = fetch_city_info(location, api_key)
if not city_info: if not city_info:
return ActionResponse( return ActionResponse(
Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None
+2 -1
View File
@@ -22,7 +22,7 @@ mem0ai==0.1.62
bs4==0.0.2 bs4==0.0.2
modelscope==1.23.2 modelscope==1.23.2
sherpa_onnx==1.11.0 sherpa_onnx==1.11.0
mcp==1.7.1 mcp==1.8.1
cnlunar==0.2.0 cnlunar==0.2.0
PySocks==1.7.1 PySocks==1.7.1
dashscope==1.23.1 dashscope==1.23.1
@@ -30,3 +30,4 @@ baidu-aip==4.16.13
chardet==5.2.0 chardet==5.2.0
aioconsole==0.8.1 aioconsole==0.8.1
markitdown==0.1.1 markitdown==0.1.1
mcp-proxy==0.6.0