Ver código fonte

虚拟实验室功能

liyanbo 6 meses atrás
pai
commit
e38ed85b24

+ 104 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/virtualdevice/VirtualDeviceController.java

@@ -0,0 +1,104 @@
+package cn.iocoder.byzs.module.ai.controller.admin.virtualdevice;
+
+import org.springframework.web.bind.annotation.*;
+import jakarta.annotation.Resource;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.security.access.prepost.PreAuthorize;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.Operation;
+
+import jakarta.validation.constraints.*;
+import jakarta.validation.*;
+import jakarta.servlet.http.*;
+import java.util.*;
+import java.io.IOException;
+
+import cn.iocoder.byzs.framework.common.pojo.PageParam;
+import cn.iocoder.byzs.framework.common.pojo.PageResult;
+import cn.iocoder.byzs.framework.common.pojo.CommonResult;
+import cn.iocoder.byzs.framework.common.util.object.BeanUtils;
+import static cn.iocoder.byzs.framework.common.pojo.CommonResult.success;
+
+import cn.iocoder.byzs.framework.excel.core.util.ExcelUtils;
+
+import cn.iocoder.byzs.framework.apilog.core.annotation.ApiAccessLog;
+import static cn.iocoder.byzs.framework.apilog.core.enums.OperateTypeEnum.*;
+
+import cn.iocoder.byzs.module.ai.controller.admin.virtualdevice.vo.*;
+import cn.iocoder.byzs.module.ai.dal.dataobject.virtualdevice.VirtualDeviceDO;
+import cn.iocoder.byzs.module.ai.service.virtualdevice.VirtualDeviceService;
+
+@Tag(name = "管理后台 - AI-虚拟实验室")
+@RestController
+@RequestMapping("/ai/virtual-device")
+@Validated
+public class VirtualDeviceController {
+
+    @Resource
+    private VirtualDeviceService virtualDeviceService;
+
+    @PostMapping("/create")
+    @Operation(summary = "创建AI-虚拟实验室")
+    @PreAuthorize("@ss.hasPermission('ai:virtual-device:create')")
+    public CommonResult<Integer> createVirtualDevice(@Valid @RequestBody VirtualDeviceSaveReqVO createReqVO) {
+        return success(virtualDeviceService.createVirtualDevice(createReqVO));
+    }
+
+    @PutMapping("/update")
+    @Operation(summary = "更新AI-虚拟实验室")
+    @PreAuthorize("@ss.hasPermission('ai:virtual-device:update')")
+    public CommonResult<Boolean> updateVirtualDevice(@Valid @RequestBody VirtualDeviceSaveReqVO updateReqVO) {
+        virtualDeviceService.updateVirtualDevice(updateReqVO);
+        return success(true);
+    }
+
+    @DeleteMapping("/delete")
+    @Operation(summary = "删除AI-虚拟实验室")
+    @Parameter(name = "id", description = "编号", required = true)
+    @PreAuthorize("@ss.hasPermission('ai:virtual-device:delete')")
+    public CommonResult<Boolean> deleteVirtualDevice(@RequestParam("id") Integer id) {
+        virtualDeviceService.deleteVirtualDevice(id);
+        return success(true);
+    }
+
+    @DeleteMapping("/delete-list")
+    @Parameter(name = "ids", description = "编号", required = true)
+    @Operation(summary = "批量删除AI-虚拟实验室")
+                @PreAuthorize("@ss.hasPermission('ai:virtual-device:delete')")
+    public CommonResult<Boolean> deleteVirtualDeviceList(@RequestParam("ids") List<Integer> ids) {
+        virtualDeviceService.deleteVirtualDeviceListByIds(ids);
+        return success(true);
+    }
+
+    @GetMapping("/get")
+    @Operation(summary = "获得AI-虚拟实验室")
+    @Parameter(name = "id", description = "编号", required = true, example = "1024")
+    @PreAuthorize("@ss.hasPermission('ai:virtual-device:query')")
+    public CommonResult<VirtualDeviceRespVO> getVirtualDevice(@RequestParam("id") Integer id) {
+        VirtualDeviceDO virtualDevice = virtualDeviceService.getVirtualDevice(id);
+        return success(BeanUtils.toBean(virtualDevice, VirtualDeviceRespVO.class));
+    }
+
+    @GetMapping("/page")
+    @Operation(summary = "获得AI-虚拟实验室分页")
+    @PreAuthorize("@ss.hasPermission('ai:virtual-device:query')")
+    public CommonResult<PageResult<VirtualDeviceRespVO>> getVirtualDevicePage(@Valid VirtualDevicePageReqVO pageReqVO) {
+        PageResult<VirtualDeviceDO> pageResult = virtualDeviceService.getVirtualDevicePage(pageReqVO);
+        return success(BeanUtils.toBean(pageResult, VirtualDeviceRespVO.class));
+    }
+
+    @GetMapping("/export-excel")
+    @Operation(summary = "导出AI-虚拟实验室 Excel")
+    @PreAuthorize("@ss.hasPermission('ai:virtual-device:export')")
+    @ApiAccessLog(operateType = EXPORT)
+    public void exportVirtualDeviceExcel(@Valid VirtualDevicePageReqVO pageReqVO,
+              HttpServletResponse response) throws IOException {
+        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
+        List<VirtualDeviceDO> list = virtualDeviceService.getVirtualDevicePage(pageReqVO).getList();
+        // 导出 Excel
+        ExcelUtils.write(response, "AI-虚拟实验室.xls", "数据", VirtualDeviceRespVO.class,
+                        BeanUtils.toBean(list, VirtualDeviceRespVO.class));
+    }
+
+}

+ 41 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/virtualdevice/vo/VirtualDevicePageReqVO.java

@@ -0,0 +1,41 @@
+package cn.iocoder.byzs.module.ai.controller.admin.virtualdevice.vo;
+
+import lombok.*;
+import java.util.*;
+import io.swagger.v3.oas.annotations.media.Schema;
+import cn.iocoder.byzs.framework.common.pojo.PageParam;
+import org.springframework.format.annotation.DateTimeFormat;
+import java.time.LocalDateTime;
+
+import static cn.iocoder.byzs.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - AI-虚拟实验室分页 Request VO")
+@Data
+public class VirtualDevicePageReqVO extends PageParam {
+
+    @Schema(description = "名称")
+    private String name;
+
+    @Schema(description = "图片")
+    private String image;
+
+    @Schema(description = "路由")
+    private String routePath;
+
+    @Schema(description = "json数据")
+    private String jsonData;
+
+    @Schema(description = "排序")
+    private Integer sort;
+
+    @Schema(description = "状态")
+    private Integer status;
+
+    @Schema(description = "创建时间")
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    private LocalDateTime[] createTime;
+
+    @Schema(description = "租户编号", example = "19253")
+    private Long tenantId;
+
+}

+ 51 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/virtualdevice/vo/VirtualDeviceRespVO.java

@@ -0,0 +1,51 @@
+package cn.iocoder.byzs.module.ai.controller.admin.virtualdevice.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.*;
+import java.util.*;
+import org.springframework.format.annotation.DateTimeFormat;
+import java.time.LocalDateTime;
+import com.alibaba.excel.annotation.*;
+
+@Schema(description = "管理后台 - AI-虚拟实验室 Response VO")
+@Data
+@ExcelIgnoreUnannotated
+public class VirtualDeviceRespVO {
+
+    @Schema(description = "主键", requiredMode = Schema.RequiredMode.REQUIRED)
+    @ExcelProperty("主键")
+    private Integer id;
+
+    @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED)
+    @ExcelProperty("名称")
+    private String name;
+
+    @Schema(description = "图片", requiredMode = Schema.RequiredMode.REQUIRED)
+    @ExcelProperty("图片")
+    private String image;
+
+    @Schema(description = "路由")
+    @ExcelProperty("路由")
+    private String routePath;
+
+    @Schema(description = "json数据")
+    @ExcelProperty("json数据")
+    private String jsonData;
+
+    @Schema(description = "排序")
+    @ExcelProperty("排序")
+    private Integer sort;
+
+    @Schema(description = "状态")
+    @ExcelProperty("状态")
+    private Integer status;
+
+    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
+    @ExcelProperty("创建时间")
+    private LocalDateTime createTime;
+
+    @Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "19253")
+    @ExcelProperty("租户编号")
+    private Long tenantId;
+
+}

+ 36 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/virtualdevice/vo/VirtualDeviceSaveReqVO.java

@@ -0,0 +1,36 @@
+package cn.iocoder.byzs.module.ai.controller.admin.virtualdevice.vo;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.*;
+import java.util.*;
+import jakarta.validation.constraints.*;
+
+@Schema(description = "管理后台 - AI-虚拟实验室新增/修改 Request VO")
+@Data
+public class VirtualDeviceSaveReqVO {
+
+    @Schema(description = "主键", requiredMode = Schema.RequiredMode.REQUIRED)
+    private Integer id;
+
+    @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED)
+    @NotEmpty(message = "名称不能为空")
+    private String name;
+
+    @Schema(description = "图片", requiredMode = Schema.RequiredMode.REQUIRED)
+    @NotEmpty(message = "图片不能为空")
+    private String image;
+
+    @Schema(description = "路由")
+    private String routePath;
+
+    @Schema(description = "json数据")
+    private String jsonData;
+
+    @Schema(description = "排序")
+    private Integer sort;
+
+    @Schema(description = "状态")
+    private Integer status;
+
+}

+ 62 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/dal/dataobject/virtualdevice/VirtualDeviceDO.java

@@ -0,0 +1,62 @@
+package cn.iocoder.byzs.module.ai.dal.dataobject.virtualdevice;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.*;
+import java.util.*;
+import java.time.LocalDateTime;
+import java.time.LocalDateTime;
+import com.baomidou.mybatisplus.annotation.*;
+import cn.iocoder.byzs.framework.mybatis.core.dataobject.BaseDO;
+
+/**
+ * AI-虚拟实验室 DO
+ *
+ * @author lyb
+ */
+@TableName("ai_virtual_device")
+@KeySequence("ai_virtual_device_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class VirtualDeviceDO extends BaseDO {
+
+    /**
+     * 主键
+     */
+    @TableId
+    private Integer id;
+    /**
+     * 名称
+     */
+    private String name;
+    /**
+     * 图片
+     */
+    private String image;
+    /**
+     * 路由
+     */
+    private String routePath;
+    /**
+     * json数据
+     */
+    private String jsonData;
+    /**
+     * 排序
+     */
+    private Integer status;
+    /**
+     * 状态
+     */
+    private Integer sort;
+    /**
+     * 租户id
+     */
+    private Long tenantId;
+
+
+}

+ 31 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/dal/mysql/virtualdevice/VirtualDeviceMapper.java

@@ -0,0 +1,31 @@
+package cn.iocoder.byzs.module.ai.dal.mysql.virtualdevice;
+
+import java.util.*;
+
+import cn.iocoder.byzs.framework.common.pojo.PageResult;
+import cn.iocoder.byzs.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.byzs.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.byzs.module.ai.dal.dataobject.virtualdevice.VirtualDeviceDO;
+import org.apache.ibatis.annotations.Mapper;
+import cn.iocoder.byzs.module.ai.controller.admin.virtualdevice.vo.*;
+
+/**
+ * AI-虚拟实验室 Mapper
+ *
+ * @author lyb
+ */
+@Mapper
+public interface VirtualDeviceMapper extends BaseMapperX<VirtualDeviceDO> {
+
+    default PageResult<VirtualDeviceDO> selectPage(VirtualDevicePageReqVO reqVO) {
+        return selectPage(reqVO, new LambdaQueryWrapperX<VirtualDeviceDO>()
+                .likeIfPresent(VirtualDeviceDO::getName, reqVO.getName())
+                .eqIfPresent(VirtualDeviceDO::getImage, reqVO.getImage())
+                .eqIfPresent(VirtualDeviceDO::getRoutePath, reqVO.getRoutePath())
+                .eqIfPresent(VirtualDeviceDO::getJsonData, reqVO.getJsonData())
+                .betweenIfPresent(VirtualDeviceDO::getCreateTime, reqVO.getCreateTime())
+                .eqIfPresent(VirtualDeviceDO::getTenantId, reqVO.getTenantId())
+                .orderByDesc(VirtualDeviceDO::getId));
+    }
+
+}

+ 3 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/enums/ErrorCodeConstants.java

@@ -72,4 +72,7 @@ public interface ErrorCodeConstants {
     // ========== API 视频 1-040-012-000 ==========
     // ========== API 视频 1-040-012-000 ==========
     ErrorCode VIDEO_NOT_EXISTS = new ErrorCode(1_040_012_000, "视频不存在!");
     ErrorCode VIDEO_NOT_EXISTS = new ErrorCode(1_040_012_000, "视频不存在!");
 
 
+     // ========== API 虚拟实验室 1-050-013-000 ==========
+    ErrorCode VIRTUAL_DEVICE_NOT_EXISTS = new ErrorCode(1_050_001_000, "虚拟实验室不存在!");
+
 }
 }

+ 126 - 74
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/chat/AiChatMessageServiceImpl.java

@@ -174,9 +174,18 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
         AiModelDO model = modalService.validateModel(conversation.getModelId());
         AiModelDO model = modalService.validateModel(conversation.getModelId());
         StreamingChatModel chatModel = modalService.getChatModel(model.getId());
         StreamingChatModel chatModel = modalService.getChatModel(model.getId());
         //角色
         //角色
-        AiChatRoleDO chatRole = chatRoleService.getChatRole(conversation.getRoleId());
+        AiChatRoleDO chatRole = null;
+        if (conversation.getRoleId() != null) {
+            chatRole = chatRoleService.getChatRole(conversation.getRoleId());
+        }
         //发声人
         //发声人
-        AiTtsDO aiTtsDO = ttsMapper.selectById(chatRole.getTtsId());
+        AiTtsDO aiTtsDO = null;
+        if (chatRole != null) {
+            aiTtsDO = ttsMapper.selectById(chatRole.getTtsId());
+        }
+
+        // 添加useTts标志,判断是否使用TTS服务
+        boolean useTts = aiTtsDO != null;
 
 
         // 2. 知识库找回
         // 2. 知识库找回
         List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = recallKnowledgeSegment(sendReqVO.getContent(),
         List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = recallKnowledgeSegment(sendReqVO.getContent(),
@@ -197,27 +206,40 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
         Flux<ChatResponse> streamResponse = chatModel.stream(prompt);
         Flux<ChatResponse> streamResponse = chatModel.stream(prompt);
 
 
         // 4.3 初始化TTS服务 - 创建新的实例而非使用共享实例
         // 4.3 初始化TTS服务 - 创建新的实例而非使用共享实例
-        // 在sendChatMessageStream方法中获取实例
-        StreamTtsService streamTtsService = streamTtsServiceProvider.getObject();
-        streamTtsService.startTts(aiTtsDO);
+        StreamTtsService streamTtsService;
+        ScheduledExecutorService scheduler;
+        AtomicReference<ScheduledFuture<?>> ttsTask;
+        StringBuffer contentTTSBuffer;
+        Pattern sentencePattern;
+
+        if (useTts) {
+            // 只有当需要使用TTS服务时才创建实例
+            streamTtsService = streamTtsServiceProvider.getObject();
+            streamTtsService.startTts(aiTtsDO);
+
+            contentTTSBuffer = new StringBuffer();
+            sentencePattern = Pattern.compile("[。!?;\n\r]"); // 增加换行符支持
+            scheduler = Executors.newSingleThreadScheduledExecutor();
+            ttsTask = new AtomicReference<>();
+        } else {
+            streamTtsService = null;
+            sentencePattern = null;
+            scheduler = null;
+            ttsTask = null;
+            contentTTSBuffer = null;
+        }
 
 
         // 4.4 流式返回并处理TTS
         // 4.4 流式返回并处理TTS
         StringBuffer contentBuffer = new StringBuffer();
         StringBuffer contentBuffer = new StringBuffer();
-        StringBuffer contentTTSBuffer = new StringBuffer();
-        // 添加句子结束符正则表达式
-        Pattern sentencePattern = Pattern.compile("[。!?;\n\r]"); // 增加换行符支持
-
-        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
-        AtomicReference<ScheduledFuture<?>> ttsTask = new AtomicReference<>();
 
 
-        Flux<CommonResult<AiChatMessageSendRespVO>> textStream  = streamResponse.map(chunk -> {
+        Flux<CommonResult<AiChatMessageSendRespVO>> textStream = streamResponse.map(chunk -> {
             // 处理知识库的返回,只有首次才有
             // 处理知识库的返回,只有首次才有
             List<AiChatMessageRespVO.KnowledgeSegment> segments = null;
             List<AiChatMessageRespVO.KnowledgeSegment> segments = null;
             if (StrUtil.isEmpty(contentBuffer)) {
             if (StrUtil.isEmpty(contentBuffer)) {
                 Map<Long, AiKnowledgeDocumentDO> documentMap = TenantUtils.executeIgnore(() ->
                 Map<Long, AiKnowledgeDocumentDO> documentMap = TenantUtils.executeIgnore(() ->
                         knowledgeDocumentService.getKnowledgeDocumentMap(
                         knowledgeDocumentService.getKnowledgeDocumentMap(
                                 convertSet(knowledgeSegments, AiKnowledgeSegmentSearchRespBO::getDocumentId)));
                                 convertSet(knowledgeSegments, AiKnowledgeSegmentSearchRespBO::getDocumentId)));
-                segments = BeanUtils.toBean(knowledgeSegments, AiChatMessageRespVO.KnowledgeSegment.class, segment ->  {
+                segments = BeanUtils.toBean(knowledgeSegments, AiChatMessageRespVO.KnowledgeSegment.class, segment -> {
                     AiKnowledgeDocumentDO document = documentMap.get(segment.getDocumentId());
                     AiKnowledgeDocumentDO document = documentMap.get(segment.getDocumentId());
                     segment.setDocumentName(document != null ? document.getName() : null);
                     segment.setDocumentName(document != null ? document.getName() : null);
                 });
                 });
@@ -228,22 +250,26 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
             newContent = StrUtil.nullToDefault(newContent, ""); // 避免 null 的情况
             newContent = StrUtil.nullToDefault(newContent, ""); // 避免 null 的情况
 
 
             contentBuffer.append(newContent);
             contentBuffer.append(newContent);
-            contentTTSBuffer.append(newContent);
-//            System.out.println("==============newContent: " + newContent);
 
 
-            // 发送新内容到TTS服务进行语音合成
-            if (ttsTask.get() != null) {
-                ttsTask.get().cancel(false); // 取消之前的延迟任务
-            }
-            // 延迟500ms执行,合并短时间内到达的文本片段
-            ttsTask.set(scheduler.schedule(() -> {
-                Matcher matcher = sentencePattern.matcher(contentTTSBuffer);
-                if (matcher.find()) {
-                    processCompleteSentence(streamTtsService, contentTTSBuffer, matcher);
-                } else if (contentTTSBuffer.length() > 50) { // 最长50字未结束也处理
-                    processCompleteSentence(streamTtsService, contentTTSBuffer, contentTTSBuffer.length());
+            // 只有当需要使用TTS服务时才处理TTS相关逻辑
+            if (useTts) {
+                contentTTSBuffer.append(newContent);
+                log.debug("TTS新内容: {}", newContent);
+
+                // 发送新内容到TTS服务进行语音合成
+                if (ttsTask.get() != null) {
+                    ttsTask.get().cancel(false); // 取消之前的延迟任务
                 }
                 }
-            }, 500, TimeUnit.MILLISECONDS));
+                // 延迟500ms执行,合并短时间内到达的文本片段
+                ttsTask.set(scheduler.schedule(() -> {
+                    Matcher matcher = sentencePattern.matcher(contentTTSBuffer);
+                    if (matcher.find()) {
+                        processCompleteSentence(streamTtsService, contentTTSBuffer, matcher);
+                    } else if (contentTTSBuffer.length() > 50) { // 最长50字未结束也处理
+                        processCompleteSentence(streamTtsService, contentTTSBuffer, contentTTSBuffer.length());
+                    }
+                }, 500, TimeUnit.MILLISECONDS));
+            }
 
 
             CommonResult<AiChatMessageSendRespVO> result = success(new AiChatMessageSendRespVO()
             CommonResult<AiChatMessageSendRespVO> result = success(new AiChatMessageSendRespVO()
                     .setEventType("TEXT")
                     .setEventType("TEXT")
@@ -253,95 +279,121 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
             return result;
             return result;
         }).doOnComplete(() -> {
         }).doOnComplete(() -> {
 
 
-            processRemainingText(streamTtsService, contentTTSBuffer); // 处理剩余文本
+            // 只有当需要使用TTS服务时才处理TTS相关逻辑
+            if (useTts) {
+                processRemainingText(streamTtsService, contentTTSBuffer); // 处理剩余文本
 
 
-            // 忽略租户,因为 Flux 异步无法透传租户
-            TenantUtils.executeIgnore(() -> chatMessageMapper.updateById(
-                    new AiChatMessageDO().setId(assistantMessage.getId()).setContent(contentBuffer.toString())));
+                if (ttsTask.get() != null) {
+                    ttsTask.get().cancel(false);
+                }
+                scheduler.shutdown(); // 关闭调度器
 
 
-            if (ttsTask.get() != null) {
-                ttsTask.get().cancel(false);
+                // 通知TTS服务文本发送完成
+                streamTtsService.stopTts();
             }
             }
-            scheduler.shutdown(); // 关闭调度器
 
 
-            // 通知TTS服务文本发送完成
-            streamTtsService.stopTts();
+            // 忽略租户,因为 Flux 异步无法透传租户
+            TenantUtils.executeIgnore(() -> chatMessageMapper.updateById(
+                    new AiChatMessageDO().setId(assistantMessage.getId()).setContent(contentBuffer.toString())));
         }).doOnError(throwable -> {
         }).doOnError(throwable -> {
             log.error("[sendChatMessageStream][userId({}) sendReqVO({}) 发生异常]", userId, sendReqVO, throwable);
             log.error("[sendChatMessageStream][userId({}) sendReqVO({}) 发生异常]", userId, sendReqVO, throwable);
             // 忽略租户,因为 Flux 异步无法透传租户
             // 忽略租户,因为 Flux 异步无法透传租户
             TenantUtils.executeIgnore(() -> chatMessageMapper.updateById(
             TenantUtils.executeIgnore(() -> chatMessageMapper.updateById(
                     new AiChatMessageDO().setId(assistantMessage.getId()).setContent(throwable.getMessage())));
                     new AiChatMessageDO().setId(assistantMessage.getId()).setContent(throwable.getMessage())));
             // 发生错误时停止TTS服务
             // 发生错误时停止TTS服务
-            streamTtsService.stopTts();
+            if (useTts && streamTtsService != null) {
+                streamTtsService.stopTts();
+            }
         })
         })
         // ==== 添加finally块清理 ====
         // ==== 添加finally块清理 ====
         .doFinally(signalType -> {
         .doFinally(signalType -> {
             // 通知TTS服务文本发送完成
             // 通知TTS服务文本发送完成
-            streamTtsService.stopTts();
+            if (useTts && streamTtsService != null) {
+                streamTtsService.stopTts();
+            }
         }).onErrorResume(error -> Flux.just(error(ErrorCodeConstants.CHAT_STREAM_ERROR)));
         }).onErrorResume(error -> Flux.just(error(ErrorCodeConstants.CHAT_STREAM_ERROR)));
 
 
-        // 创建音频流
-        Flux<CommonResult<AiChatMessageSendRespVO>> audioStream = Flux.create(sink2 -> {
-            AtomicBoolean isFirstChunk = new AtomicBoolean(true); // 首包标志位
-            streamTtsService.setAudioDataCallback(audioBytes -> {
-                try {
-                    byte[] processedAudio;
-                    if (isFirstChunk.getAndSet(false)) {
-                        // 仅首包添加WAV头
-                        processedAudio = WavHeader.addWavHeader(audioBytes, 16000, 16, 1);
-                        log.info("首包音频带WAV头,长度={} bytes", processedAudio.length);
-                    } else {
-                        // 后续包直接使用原始PCM数据
-                        processedAudio = audioBytes;
+        // 创建音频流 - 只有当需要使用TTS服务时才创建音频流
+        Flux<CommonResult<AiChatMessageSendRespVO>> audioStream = Flux.empty();
+
+        if (useTts) {
+            audioStream = Flux.create(sink2 -> {
+                AtomicBoolean isFirstChunk = new AtomicBoolean(true); // 首包标志位
+                streamTtsService.setAudioDataCallback(audioBytes -> {
+                    try {
+                        byte[] processedAudio;
+                        if (isFirstChunk.getAndSet(false)) {
+                            // 仅首包添加WAV头
+                            processedAudio = WavHeader.addWavHeader(audioBytes, 16000, 16, 1);
+                            log.info("首包音频带WAV头,长度={} bytes", processedAudio.length);
+                        } else {
+                            // 后续包直接使用原始PCM数据
+                            processedAudio = audioBytes;
+                        }
+                        String base64Audio = Base64.getEncoder().encodeToString(processedAudio);
+                        AiChatMessageSendRespVO audioResp = new AiChatMessageSendRespVO();
+                        audioResp.setEventType("AUDIO");
+                        audioResp.setAudioData(base64Audio);
+                        sink2.next(success(audioResp));
+                    } catch (Exception e) {
+                        log.error("[TTS处理异常] 音频编码失败", e);
+                        sink2.error(new RuntimeException("TTS音频处理失败: " + e.getMessage(), e));
                     }
                     }
-//                    byte[] processedAudio = addWavHeader(audioBytes, 24000, 16, 1);
-                    String base64Audio = Base64.getEncoder().encodeToString(processedAudio);
-                    AiChatMessageSendRespVO audioResp = new AiChatMessageSendRespVO();
-                    audioResp.setEventType("AUDIO");
-                    audioResp.setAudioData(base64Audio);
-                    sink2.next(success(audioResp));
-                } catch (Exception e) {
-                    log.error("[TTS处理异常] 音频编码失败", e);
-                    sink2.error(new RuntimeException("TTS音频处理失败: " + e.getMessage(), e));
-                }
+                });
+                streamTtsService.setOnCompleteCallback(sink2::complete);
             });
             });
-            streamTtsService.setOnCompleteCallback(sink2::complete);
-        });
+        }
 
 
         // 使用merge而非mergeSequential,确保任一流完成不阻塞其他流
         // 使用merge而非mergeSequential,确保任一流完成不阻塞其他流
         return Flux.merge(textStream, audioStream)
         return Flux.merge(textStream, audioStream)
                 .doFinally(signalType -> {
                 .doFinally(signalType -> {
                     // 双重保险:无论哪个流先完成,最终都清理资源
                     // 双重保险:无论哪个流先完成,最终都清理资源
-                    streamTtsService.setAudioDataCallback(null);
-                    streamTtsService.setOnCompleteCallback(null);
+                    if (useTts && streamTtsService != null) {
+                        streamTtsService.setAudioDataCallback(null);
+                        streamTtsService.setOnCompleteCallback(null);
+                    }
+                    // 确保调度器被关闭
+                    if (useTts && scheduler != null && !scheduler.isShutdown()) {
+                        scheduler.shutdownNow();
+                    }
                 });
                 });
     }
     }
 
 
     // 处理完整句子
     // 处理完整句子
     private void processCompleteSentence(StreamTtsService streamTtsService, StringBuffer buffer, Matcher matcher) {
     private void processCompleteSentence(StreamTtsService streamTtsService, StringBuffer buffer, Matcher matcher) {
+        if (streamTtsService == null || buffer == null || matcher == null) {
+            return;
+        }
+
         String sentence = buffer.substring(0, matcher.end());
         String sentence = buffer.substring(0, matcher.end());
-        System.out.println("==============[处理完整句子[[buffer: " + sentence);
+        log.debug("[处理完整句子][buffer: {}", sentence);
         streamTtsService.sendText(sentence);
         streamTtsService.sendText(sentence);
         buffer.delete(0, matcher.end());
         buffer.delete(0, matcher.end());
-        log.info("TTS合成完整句: {}", sentence); // 替换System.out为日志
+        log.info("TTS合成完整句: {}", sentence);
     }
     }
 
 
     // 处理指定长度文本
     // 处理指定长度文本
     private void processCompleteSentence(StreamTtsService streamTtsService, StringBuffer buffer, int length) {
     private void processCompleteSentence(StreamTtsService streamTtsService, StringBuffer buffer, int length) {
+        if (streamTtsService == null || buffer == null || length <= 0) {
+            return;
+        }
+
         String sentence = buffer.substring(0, length);
         String sentence = buffer.substring(0, length);
-        System.out.println("==============[处理指定长度文本[[buffer: " + sentence);
+        log.debug("[处理指定长度文本][buffer: {}", sentence);
         streamTtsService.sendText(sentence);
         streamTtsService.sendText(sentence);
         buffer.delete(0, length);
         buffer.delete(0, length);
-        log.info("TTS合成长文本: {}", sentence); // 替换System.out为日志
+        log.info("TTS合成长文本: {}", sentence);
     }
     }
 
 
     // 处理剩余文本
     // 处理剩余文本
     private void processRemainingText(StreamTtsService streamTtsService, StringBuffer buffer) {
     private void processRemainingText(StreamTtsService streamTtsService, StringBuffer buffer) {
-        if (!buffer.isEmpty()) {
-            System.out.println("TTS合成剩余文本: " + buffer);
-            streamTtsService.sendText(buffer.toString());
-            buffer.setLength(0);
+        if (streamTtsService == null || buffer == null || buffer.isEmpty()) {
+            return;
         }
         }
+
+        log.info("TTS合成剩余文本: {}", buffer);
+        streamTtsService.sendText(buffer.toString());
+        buffer.setLength(0);
     }
     }
 
 
     private List<AiKnowledgeSegmentSearchRespBO> recallKnowledgeSegment(String content,
     private List<AiKnowledgeSegmentSearchRespBO> recallKnowledgeSegment(String content,
@@ -507,4 +559,4 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
         return chatMessageMapper.selectPage(pageReqVO);
         return chatMessageMapper.selectPage(pageReqVO);
     }
     }
 
 
-}
+}

+ 62 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/virtualdevice/VirtualDeviceService.java

@@ -0,0 +1,62 @@
+package cn.iocoder.byzs.module.ai.service.virtualdevice;
+
+import java.util.*;
+import jakarta.validation.*;
+import cn.iocoder.byzs.module.ai.controller.admin.virtualdevice.vo.*;
+import cn.iocoder.byzs.module.ai.dal.dataobject.virtualdevice.VirtualDeviceDO;
+import cn.iocoder.byzs.framework.common.pojo.PageResult;
+import cn.iocoder.byzs.framework.common.pojo.PageParam;
+
+/**
+ * AI-虚拟实验室 Service 接口
+ *
+ * @author lyb
+ */
+public interface VirtualDeviceService {
+
+    /**
+     * 创建AI-虚拟实验室
+     *
+     * @param createReqVO 创建信息
+     * @return 编号
+     */
+    Integer createVirtualDevice(@Valid VirtualDeviceSaveReqVO createReqVO);
+
+    /**
+     * 更新AI-虚拟实验室
+     *
+     * @param updateReqVO 更新信息
+     */
+    void updateVirtualDevice(@Valid VirtualDeviceSaveReqVO updateReqVO);
+
+    /**
+     * 删除AI-虚拟实验室
+     *
+     * @param id 编号
+     */
+    void deleteVirtualDevice(Integer id);
+
+    /**
+    * 批量删除AI-虚拟实验室
+    *
+    * @param ids 编号
+    */
+    void deleteVirtualDeviceListByIds(List<Integer> ids);
+
+    /**
+     * 获得AI-虚拟实验室
+     *
+     * @param id 编号
+     * @return AI-虚拟实验室
+     */
+    VirtualDeviceDO getVirtualDevice(Integer id);
+
+    /**
+     * 获得AI-虚拟实验室分页
+     *
+     * @param pageReqVO 分页查询
+     * @return AI-虚拟实验室分页
+     */
+    PageResult<VirtualDeviceDO> getVirtualDevicePage(VirtualDevicePageReqVO pageReqVO);
+
+}

+ 92 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/virtualdevice/VirtualDeviceServiceImpl.java

@@ -0,0 +1,92 @@
+package cn.iocoder.byzs.module.ai.service.virtualdevice;
+
+import cn.hutool.core.collection.CollUtil;
+import org.springframework.stereotype.Service;
+import jakarta.annotation.Resource;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import cn.iocoder.byzs.module.ai.controller.admin.virtualdevice.vo.*;
+import cn.iocoder.byzs.module.ai.dal.dataobject.virtualdevice.VirtualDeviceDO;
+import cn.iocoder.byzs.framework.common.pojo.PageResult;
+import cn.iocoder.byzs.framework.common.pojo.PageParam;
+import cn.iocoder.byzs.framework.common.util.object.BeanUtils;
+
+import cn.iocoder.byzs.module.ai.dal.mysql.virtualdevice.VirtualDeviceMapper;
+
+import static cn.iocoder.byzs.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.byzs.framework.common.util.collection.CollectionUtils.convertList;
+import static cn.iocoder.byzs.framework.common.util.collection.CollectionUtils.diffList;
+import static cn.iocoder.byzs.module.ai.enums.ErrorCodeConstants.*;
+
+/**
+ * AI-虚拟实验室 Service 实现类
+ *
+ * @author lyb
+ */
+@Service
+@Validated
+public class VirtualDeviceServiceImpl implements VirtualDeviceService {
+
+    @Resource
+    private VirtualDeviceMapper virtualDeviceMapper;
+
+    @Override
+    public Integer createVirtualDevice(VirtualDeviceSaveReqVO createReqVO) {
+        // 插入
+        VirtualDeviceDO virtualDevice = BeanUtils.toBean(createReqVO, VirtualDeviceDO.class);
+        virtualDeviceMapper.insert(virtualDevice);
+        // 返回
+        return virtualDevice.getId();
+    }
+
+    @Override
+    public void updateVirtualDevice(VirtualDeviceSaveReqVO updateReqVO) {
+        // 校验存在
+        validateVirtualDeviceExists(updateReqVO.getId());
+        // 更新
+        VirtualDeviceDO updateObj = BeanUtils.toBean(updateReqVO, VirtualDeviceDO.class);
+        virtualDeviceMapper.updateById(updateObj);
+    }
+
+    @Override
+    public void deleteVirtualDevice(Integer id) {
+        // 校验存在
+        validateVirtualDeviceExists(id);
+        // 删除
+        virtualDeviceMapper.deleteById(id);
+    }
+
+    @Override
+        public void deleteVirtualDeviceListByIds(List<Integer> ids) {
+        // 校验存在
+        validateVirtualDeviceExists(ids);
+        // 删除
+        virtualDeviceMapper.deleteByIds(ids);
+        }
+
+    private void validateVirtualDeviceExists(List<Integer> ids) {
+        List<VirtualDeviceDO> list = virtualDeviceMapper.selectByIds(ids);
+        if (CollUtil.isEmpty(list) || list.size() != ids.size()) {
+            throw exception(VIRTUAL_DEVICE_NOT_EXISTS);
+        }
+    }
+
+    private void validateVirtualDeviceExists(Integer id) {
+        if (virtualDeviceMapper.selectById(id) == null) {
+            throw exception(VIRTUAL_DEVICE_NOT_EXISTS);
+        }
+    }
+
+    @Override
+    public VirtualDeviceDO getVirtualDevice(Integer id) {
+        return virtualDeviceMapper.selectById(id);
+    }
+
+    @Override
+    public PageResult<VirtualDeviceDO> getVirtualDevicePage(VirtualDevicePageReqVO pageReqVO) {
+        return virtualDeviceMapper.selectPage(pageReqVO);
+    }
+
+}

+ 7 - 0
byzs-module-ai/src/main/resources/mapper/virtualdevice/VirtualDeviceMapper.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="cn.iocoder.byzs.module.ai.dal.mysql.virtualdevice.VirtualDeviceMapper">
+
+
+
+</mapper>

+ 17 - 0
byzs-web/src/main/java/cn/iocoder/byzs/module/web/controller/admin/ai/WebAiController.java

@@ -15,16 +15,20 @@ import cn.iocoder.byzs.module.ai.controller.admin.image.vo.AiImageRespVO;
 import cn.iocoder.byzs.module.ai.controller.admin.model.vo.chatRole.AiChatRolePageReqVO;
 import cn.iocoder.byzs.module.ai.controller.admin.model.vo.chatRole.AiChatRolePageReqVO;
 import cn.iocoder.byzs.module.ai.controller.admin.video.vo.AiVideoDrawReqVO;
 import cn.iocoder.byzs.module.ai.controller.admin.video.vo.AiVideoDrawReqVO;
 import cn.iocoder.byzs.module.ai.controller.admin.video.vo.AiVideoRespVO;
 import cn.iocoder.byzs.module.ai.controller.admin.video.vo.AiVideoRespVO;
+import cn.iocoder.byzs.module.ai.controller.admin.virtualdevice.vo.VirtualDevicePageReqVO;
+import cn.iocoder.byzs.module.ai.controller.admin.virtualdevice.vo.VirtualDeviceRespVO;
 import cn.iocoder.byzs.module.ai.dal.dataobject.image.AiImageDO;
 import cn.iocoder.byzs.module.ai.dal.dataobject.image.AiImageDO;
 import cn.iocoder.byzs.module.ai.dal.dataobject.model.AiChatRoleDO;
 import cn.iocoder.byzs.module.ai.dal.dataobject.model.AiChatRoleDO;
 import cn.iocoder.byzs.module.ai.dal.dataobject.model.AiModelDO;
 import cn.iocoder.byzs.module.ai.dal.dataobject.model.AiModelDO;
 import cn.iocoder.byzs.module.ai.dal.dataobject.video.AiVideoDO;
 import cn.iocoder.byzs.module.ai.dal.dataobject.video.AiVideoDO;
+import cn.iocoder.byzs.module.ai.dal.dataobject.virtualdevice.VirtualDeviceDO;
 import cn.iocoder.byzs.module.ai.service.chat.AiChatConversationService;
 import cn.iocoder.byzs.module.ai.service.chat.AiChatConversationService;
 import cn.iocoder.byzs.module.ai.service.chat.AiChatMessageService;
 import cn.iocoder.byzs.module.ai.service.chat.AiChatMessageService;
 import cn.iocoder.byzs.module.ai.service.image.AiImageService;
 import cn.iocoder.byzs.module.ai.service.image.AiImageService;
 import cn.iocoder.byzs.module.ai.service.model.AiChatRoleService;
 import cn.iocoder.byzs.module.ai.service.model.AiChatRoleService;
 import cn.iocoder.byzs.module.ai.service.model.AiModelService;
 import cn.iocoder.byzs.module.ai.service.model.AiModelService;
 import cn.iocoder.byzs.module.ai.service.video.AiVideoService;
 import cn.iocoder.byzs.module.ai.service.video.AiVideoService;
+import cn.iocoder.byzs.module.ai.service.virtualdevice.VirtualDeviceService;
 import cn.iocoder.byzs.module.web.controller.admin.ai.vo.WebAiChatRoleVO;
 import cn.iocoder.byzs.module.web.controller.admin.ai.vo.WebAiChatRoleVO;
 import cn.iocoder.byzs.module.web.service.ai.WebAiServiceImpl;
 import cn.iocoder.byzs.module.web.service.ai.WebAiServiceImpl;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
@@ -64,6 +68,8 @@ public class WebAiController {
     private AiModelService modelService;
     private AiModelService modelService;
     @Resource
     @Resource
     private WebAiServiceImpl webAiService;
     private WebAiServiceImpl webAiService;
+    @Resource
+    private VirtualDeviceService virtualDeviceService;
 
 
 
 
     // ================ 模型数据 ================
     // ================ 模型数据 ================
@@ -77,6 +83,17 @@ public class WebAiController {
         return success(BeanUtils.toBean(pageResult, WebAiChatRoleVO.class));
         return success(BeanUtils.toBean(pageResult, WebAiChatRoleVO.class));
     }
     }
 
 
+    @GetMapping("/selectVirtualDevice")
+    @Operation(summary = "获得聊天角色分页")
+    @PermitAll
+    @TenantIgnore
+    public CommonResult<PageResult<VirtualDeviceRespVO>> selectVirtualDevice(@Valid VirtualDevicePageReqVO pageReqVO) {
+        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
+        pageReqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
+        PageResult<VirtualDeviceDO> pageResult = virtualDeviceService.getVirtualDevicePage(pageReqVO);
+        return success(BeanUtils.toBean(pageResult, VirtualDeviceRespVO.class));
+    }
+
     @PermitAll
     @PermitAll
     @TenantIgnore
     @TenantIgnore
     @GetMapping("/getModelIdByType")
     @GetMapping("/getModelIdByType")