feat: 新增短剧音频/编码模块、MCP 文件工具与 WebSocket 支持

- 短剧: 新增 ShortDramaAudio 实体/Mapper/BO/VO,扩展 ShortDramaServiceImpl
  合成逻辑与 FfmpegFilterGraphBuilder 滤镜图
- MCP: 新增 WriteFileTool/DeleteFileTool/ExecuteCommandTool,重构
  EditFileTool/ReadFileTool/ListDirectoryTool
- 编码: 新增 coding 模块(CodingAgent/WorkspaceService/SSE 事件通道)
- Atlas: 新增音频生成实现,扩展音视频/媒体实体与预测服务
- WebSocket: 新增公众号聊天 WebSocket 处理器与握手拦截
- 忽略 logs/ 目录,避免合成产物入库

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ageerle@163.com
2026-07-17 11:52:53 +08:00
parent 06d110ed16
commit fece90b307
52 changed files with 3110 additions and 103 deletions

View File

@@ -24,6 +24,14 @@
<artifactId>ruoyi-common-sse</artifactId>
</dependency>
<!-- WebSocket 基础模块:提供 spring-websocket 依赖,用于小程序对话 WS 端点 /chat/ws。
注意common-websocket 自带的 PlusWebSocketHandler/WebSocketConfig 受 websocket.enabled 控制,
当前 enabled=false 不激活,与本模块独立注册的 /chat/ws 互不干扰。 -->
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-sensitive</artifactId>

View File

@@ -0,0 +1,92 @@
package org.ruoyi.controller.coding;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.satoken.utils.LoginHelper;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
import org.ruoyi.common.chat.service.chat.IChatModelService;
import org.ruoyi.domain.bo.coding.CodingRequestBo;
import org.ruoyi.service.coding.CodingWorkspaceService;
import org.ruoyi.service.coding.ICodingService;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
/**
* 编程能力接口B 路径,不走 Supervisor 调度)
*
* <p>第一阶段 {@code /coding/**} 在 {@code application.yml} 的 security.excludes 中,
* 免鉴权直连。Controller 只做参数绑定 + 同步取 userIdSa-Token 异步上下文丢失,
* 见 SecurityConfig 注释)+ 转发 Service。
*
* @author ageerle
*/
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/coding")
public class CodingController {
private final ICodingService codingService;
private final CodingWorkspaceService workspaceService;
private final IChatModelService chatModelService;
/**
* 编程对话SSE 流式)
*
* @param bo 请求参数prompt / model / workspacePath
* @return SseEmitter
*/
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@Valid @RequestBody CodingRequestBo bo) {
// 同步线程取 userId第一阶段免鉴权可能为 null
Long userId = LoginHelper.getUserId();
return codingService.chat(bo, userId);
}
@GetMapping("/workspace")
public R<CodingWorkspaceService.WorkspaceResult> workspace(
@RequestParam(required = false) String workspacePath) throws Exception {
return R.ok(workspaceService.list(workspacePath));
}
@GetMapping("/models")
public R<List<ModelOption>> models() {
List<ModelOption> models = chatModelService.queryList(new ChatModelBo()).stream()
.filter(model -> "1".equals(model.getModelShow()))
.map(model -> new ModelOption(model.getId(), model.getModelName(), model.getProviderCode()))
.toList();
return R.ok(models);
}
@GetMapping("/file")
public R<CodingWorkspaceService.FileContent> file(
@RequestParam(required = false) String workspacePath,
@RequestParam String path) throws Exception {
return R.ok(workspaceService.read(workspacePath, path));
}
@PutMapping("/file")
public R<CodingWorkspaceService.FileContent> saveFile(@RequestBody FileWriteRequest request) throws Exception {
return R.ok(workspaceService.write(request.workspacePath(), request.path(), request.content()));
}
@PostMapping("/command")
public R<CodingWorkspaceService.CommandResult> command(@RequestBody CommandRequest request) {
return R.ok(workspaceService.execute(request.workspacePath(), request.command()));
}
public record FileWriteRequest(String workspacePath, String path, String content) { }
public record CommandRequest(String workspacePath, String command) { }
public record ModelOption(Long id, String name, String provider) { }
}

View File

@@ -14,6 +14,7 @@ import org.ruoyi.common.satoken.utils.LoginHelper;
import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaComposeVideoBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaAudioBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo;
@@ -23,6 +24,7 @@ import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaAudioVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaLocationVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaProjectVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo;
@@ -340,6 +342,35 @@ public class ShortDramaController {
return R.ok(shortDramaService.undoLocationImage(locationId, LoginHelper.getUserId()));
}
// ==================== 语音资产管理 ====================
@PostMapping("/audio")
public R<ShortDramaAudioVo> saveAudio(@Valid @RequestBody ShortDramaAudioBo bo) {
return R.ok(shortDramaService.saveAudio(bo, LoginHelper.getUserId()));
}
@PutMapping("/audio")
public R<ShortDramaAudioVo> updateAudio(@Valid @RequestBody ShortDramaAudioBo bo) {
return R.ok(shortDramaService.saveAudio(bo, LoginHelper.getUserId()));
}
@DeleteMapping("/audio/{audioId}")
public R<Void> deleteAudio(@NotNull @PathVariable Long audioId) {
shortDramaService.deleteAudio(audioId, LoginHelper.getUserId());
return R.ok();
}
@GetMapping("/audio/list")
public R<List<ShortDramaAudioVo>> listAudios(@NotNull @RequestParam Long projectId) {
return R.ok(shortDramaService.listAudios(projectId, LoginHelper.getUserId()));
}
@PostMapping("/audio/{audioId}/generate-speech")
public R<ShortDramaAudioVo> generateAudio(@NotNull @PathVariable Long audioId,
@NotBlank @RequestParam String model) {
return R.ok(shortDramaService.generateAudio(audioId, model, LoginHelper.getUserId()));
}
// ==================== 异步图片生成(轮询进度) ====================
/** 上传本地照片到图片供应商,返回当前生成会话使用的临时 URL。 */

View File

@@ -0,0 +1,30 @@
package org.ruoyi.domain.bo.coding;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 编程能力对话请求
*
* @author ageerle
*/
@Data
public class CodingRequestBo {
/**
* 用户指令
*/
@NotBlank(message = "prompt 不能为空")
private String prompt;
/**
* 模型名称(走 IChatModelService.selectModelByName
*/
@NotBlank(message = "model 不能为空")
private String model;
/**
* 工作目录,可选;为空时默认指向 ruoyi-copilot 前端项目
*/
private String workspacePath;
}

View File

@@ -0,0 +1,37 @@
package org.ruoyi.domain.bo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import org.ruoyi.domain.entity.shortdrama.ShortDramaAudio;
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = ShortDramaAudio.class, reverseConvertGenerate = false)
public class ShortDramaAudioBo extends BaseEntity {
private Long id;
@NotNull(message = "项目ID不能为空")
private Long projectId;
@NotBlank(message = "语音资产名称不能为空")
private String name;
@NotBlank(message = "语音类型不能为空")
@Pattern(regexp = "narration|dialogue", message = "语音类型只能是 narration 或 dialogue")
private String audioType;
@NotBlank(message = "语音文案不能为空")
private String text;
/** 音色(生成语音时使用,可空,空则用模型默认) */
private String voice;
/** 对白关联的分镜ID旁白类型留空 */
private Long linkedStoryboardId;
}

View File

@@ -32,4 +32,6 @@ public class ShortDramaCharacterAppearanceBo extends BaseEntity {
private String previousImageUrls;
private String previousDescriptions;
private String voice;
}

View File

@@ -15,15 +15,21 @@ public class ShortDramaComposeVideoBo {
@NotBlank(message = "转场类型不能为空")
@Pattern(regexp = "none|dissolve|fade|slide", message = "不支持的转场类型")
private String transitionType = "dissolve";
private String transitionType = "fade";
@NotNull(message = "转场时长不能为空")
@DecimalMin(value = "0.0", message = "转场时长不能小于0秒")
private BigDecimal transitionDurationSeconds = new BigDecimal("0.5");
private BigDecimal transitionDurationSeconds = new BigDecimal("0.3");
@NotBlank(message = "成片画幅不能为空")
@Pattern(regexp = "9:16|16:9|1:1", message = "不支持的成片画幅")
@Pattern(regexp = "9:16|16:9|4:3|3:4|1:1|21:9", message = "不支持的成片画幅")
private String aspectRatio = "9:16";
@Size(min = 2, message = "至少选择2个分镜视频")
private List<Long> storyboardIds;
/** 旁白语音资产ID可选未传则不混入旁白 */
private Long narrationAudioId;
/** 是否加水印null 时用后端默认配置 ruoyi-ai */
private Boolean watermark;
}

View File

@@ -0,0 +1,47 @@
package org.ruoyi.domain.entity.shortdrama;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import java.io.Serial;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("short_drama_audio")
public class ShortDramaAudio extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
private Long projectId;
/** 语音资产名称 */
private String name;
/** 语音类型narration(旁白)/dialogue(对白) */
private String audioType;
/** 语音文案(生成语音用的文本) */
private String text;
/** 音色(如 alloy/onyx */
private String voice;
/** 音频文件OSS ID */
private Long audioOssId;
/** 音频文件URL */
private String audioUrl;
/** 对白关联的分镜IDNULL=全局旁白) */
private Long linkedStoryboardId;
/** 音频时长(秒) */
private Integer durationSeconds;
}

View File

@@ -43,4 +43,7 @@ public class ShortDramaCharacterAppearance extends BaseEntity {
/** 上一轮提示词列表撤销用JSON数组 */
private String previousDescriptions;
/** 音色名(如 zh_male_taocheng_uranus_bigtts用于该形象的对白配音 */
private String voice;
}

View File

@@ -58,4 +58,7 @@ public class ShortDramaStoryboard extends BaseEntity {
private String videoId;
private String videoStatus;
/** 上一镜末帧URL同场景连续镜头首帧承接用 */
private String lastFrameUrl;
}

View File

@@ -0,0 +1,41 @@
package org.ruoyi.domain.vo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.domain.entity.shortdrama.ShortDramaAudio;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
@Data
@AutoMapper(target = ShortDramaAudio.class)
public class ShortDramaAudioVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long id;
private Long projectId;
private String name;
private String audioType;
private String text;
private String voice;
private Long audioOssId;
private String audioUrl;
private Long linkedStoryboardId;
private Integer durationSeconds;
private Date createTime;
private Date updateTime;
}

View File

@@ -37,6 +37,8 @@ public class ShortDramaCharacterAppearanceVo implements Serializable {
private String previousDescriptions;
private String voice;
private Date createTime;
private Date updateTime;

View File

@@ -20,5 +20,7 @@ public class ShortDramaDetailVo implements Serializable {
private List<ShortDramaLocationVo> locations;
private List<ShortDramaAudioVo> audios;
private List<ShortDramaStoryboardVo> storyboards;
}

View File

@@ -57,6 +57,8 @@ public class ShortDramaStoryboardVo implements Serializable {
private String videoStatus;
private String lastFrameUrl;
private Date createTime;
private Date updateTime;

View File

@@ -0,0 +1,8 @@
package org.ruoyi.mapper.shortdrama;
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
import org.ruoyi.domain.entity.shortdrama.ShortDramaAudio;
import org.ruoyi.domain.vo.shortdrama.ShortDramaAudioVo;
public interface ShortDramaAudioMapper extends BaseMapperPlus<ShortDramaAudio, ShortDramaAudioVo> {
}

View File

@@ -0,0 +1,166 @@
package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.ruoyi.service.coding.CodingEventChannel;
import org.ruoyi.service.coding.CodingSseEvent;
import org.ruoyi.service.coding.WorkspaceGuard;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.concurrent.atomic.AtomicLong;
/**
* 删除文件/目录工具
*
* <p>编程能力专用:通过构造注入工作目录与 SSE 事件通道,操作前后推送 delete-start/delete-end 事件。
*
* @author ageerle
*/
@Component
public class DeleteFileTool implements BuiltinToolProvider {
public static final String DESCRIPTION = "Deletes a file or directory. " +
"Set recursive=true to delete a non-empty directory. " +
"Use absolute paths within the workspace directory.";
private final String rootDirectory;
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
private final CodingEventChannel channel;
public DeleteFileTool() {
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
this.channel = null;
}
/**
* 编程能力专用构造。
*/
public DeleteFileTool(Path root, CodingEventChannel channel) {
this.rootDirectory = root.toAbsolutePath().normalize().toString();
this.channel = channel;
}
/**
* 删除文件或目录
*
* @param filePath 路径绝对路径
* @param recursive 是否递归删除非空目录(可选,默认 false
* @return 操作结果
*/
@Tool(DESCRIPTION)
public String deleteFile(String filePath, Boolean recursive) {
try {
if (filePath == null || filePath.trim().isEmpty()) {
return "Error: File path cannot be empty";
}
Path path = Paths.get(filePath);
boolean rec = recursive != null && recursive;
if (!path.isAbsolute()) {
return "Error: File path must be absolute: " + filePath;
}
if (!WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), path)) {
return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath;
}
if (!Files.exists(path)) {
return "Error: Path not found: " + filePath;
}
if (channel != null) {
channel.send(CodingSseEvent.of("delete-start", filePath, null, null, "running"));
}
String relativePath = getRelativePath(path);
if (Files.isDirectory(path)) {
if (rec) {
AtomicLong count = new AtomicLong(0);
Files.walkFileTree(path, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
count.incrementAndGet();
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
if (channel != null) {
channel.send(CodingSseEvent.of("delete-end", filePath, null,
"删除目录及 " + count.get() + " 个文件", "done"));
}
return String.format("Successfully deleted directory: %s (%d files)", relativePath, count.get());
} else {
try {
Files.delete(path);
} catch (IOException e) {
if (channel != null) {
channel.send(CodingSseEvent.of("delete-end", filePath, null,
"Error: 非空目录需 recursive=true", "done"));
}
return "Error: Directory not empty, set recursive=true: " + filePath;
}
if (channel != null) {
channel.send(CodingSseEvent.of("delete-end", filePath, null, null, "done"));
}
return String.format("Successfully deleted directory: %s", relativePath);
}
} else {
Files.delete(path);
if (channel != null) {
channel.send(CodingSseEvent.of("delete-end", filePath, null, null, "done"));
}
return String.format("Successfully deleted file: %s", relativePath);
}
} catch (IOException e) {
logger.error("Error deleting file: {}", filePath, e);
if (channel != null) {
channel.send(CodingSseEvent.of("delete-end", filePath, null,
"Error: " + e.getMessage(), "done"));
}
return "Error: " + e.getMessage();
} catch (Exception e) {
logger.error("Unexpected error deleting file: {}", filePath, e);
return "Error: Unexpected error: " + e.getMessage();
}
}
private String getRelativePath(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(filePath).toString();
} catch (Exception e) {
return filePath.toString();
}
}
@Override
public String getToolName() {
return "delete_file";
}
@Override
public String getDisplayName() {
return "删除文件";
}
@Override
public String getDescription() {
return DESCRIPTION;
}
}

View File

@@ -2,6 +2,9 @@ package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.ruoyi.service.coding.CodingEventChannel;
import org.ruoyi.service.coding.CodingSseEvent;
import org.ruoyi.service.coding.WorkspaceGuard;
import org.springframework.stereotype.Component;
import java.io.IOException;
@@ -10,8 +13,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.List;
/**
* 编辑文件工具
@@ -20,16 +21,26 @@ import java.util.List;
@Component
public class EditFileTool implements BuiltinToolProvider {
public static final String DESCRIPTION = "Edits a file by applying a diff. " +
"Use this tool when you need to make specific changes to a file. " +
"The tool will show the diff before applying changes. " +
public static final String DESCRIPTION = "Edits an existing file by replacing its full content. " +
"ALWAYS read the file first with read_file, then provide the COMPLETE new content here. " +
"Use absolute paths within the workspace directory.";
private final String rootDirectory;
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
/** 编程能力 SSE 事件通道,可为 null兼容无参构造的老调用方 */
private final CodingEventChannel channel;
public EditFileTool() {
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
this.channel = null;
}
/**
* 编程能力专用构造:注入会话工作目录与事件通道。
*/
public EditFileTool(Path root, CodingEventChannel channel) {
this.rootDirectory = root.toAbsolutePath().normalize().toString();
this.channel = channel;
}
/**
@@ -59,7 +70,7 @@ public class EditFileTool implements BuiltinToolProvider {
}
// 验证是否在工作目录内
if (!isWithinWorkspace(path)) {
if (!WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), path)) {
return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath;
}
@@ -73,30 +84,31 @@ public class EditFileTool implements BuiltinToolProvider {
return "Error: Path is a directory, not a file: " + filePath;
}
// 读取原始内容
String originalContent = Files.readString(path, StandardCharsets.UTF_8);
List<String> originalLines = Arrays.asList(originalContent.split("\n"));
// 推送编辑开始事件
String relativePath = getRelativePath(path);
if (channel != null) {
channel.send(CodingSseEvent.of("edit-start", filePath, null, null, "running"));
}
// 应用diff
// 应用diff(简化:整体替换为新内容)
try {
// 这里简化处理,直接用新内容替换
// 在实际应用中可能需要更复杂的diff解析
String newContent = applyDiff(originalContent, diff);
String newContent = applyDiff(null, diff);
// 写入文件
Files.writeString(path, newContent, StandardCharsets.UTF_8,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
String relativePath = getRelativePath(path);
if (channel != null) {
channel.send(CodingSseEvent.of("edit-end", filePath, null, null, "done"));
}
return String.format("Successfully edited file: %s", relativePath);
} catch (Exception e) {
if (channel != null) {
channel.send(CodingSseEvent.of("edit-end", filePath, null, "Error: " + e.getMessage(), "done"));
}
return "Error: Failed to apply diff: " + e.getMessage();
}
} catch (IOException e) {
logger.error("Error editing file: {}", filePath, e);
return "Error: " + e.getMessage();
} catch (Exception e) {
logger.error("Unexpected error editing file: {}", filePath, e);
return "Error: Unexpected error: " + e.getMessage();
@@ -115,14 +127,7 @@ public class EditFileTool implements BuiltinToolProvider {
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
return WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), filePath);
}
private String getRelativePath(Path filePath) {

View File

@@ -0,0 +1,266 @@
package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.ruoyi.service.coding.CodingEventChannel;
import org.ruoyi.service.coding.CodingSseEvent;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* 命令执行工具
*
* <p>在会话工作目录内执行白名单命令,返回 stdout+stderr 尾部8KB
* 安全四层防御:
* <ol>
* <li>命令白名单:首段必须是允许的命令名</li>
* <li>元字符黑名单:含 shell 元字符 {@code &|;`$<>\n} 直接拒绝(虽走 ProcessBuilder 不经 shell仍双保险</li>
* <li>工作目录锁定ProcessBuilder.directory 锁在会话 workspace</li>
* <li>超时 + 输出截断30s 超时 destroyForcibly输出重定向临时文件只读尾部 8KB</li>
* </ol>
*
* <p>实现借鉴 {@code FfmpegProcessRunner}ProcessBuilder(List) 不走 shell 防注入,
* redirectErrorStream+redirectOutput 到临时文件waitFor 超时readTail 截断。
*
* @author ageerle
*/
@Component
public class ExecuteCommandTool implements BuiltinToolProvider {
public static final String DESCRIPTION = "Executes a shell command in the workspace directory. " +
"Command must be in the allowed whitelist (npm/pnpm/yarn/git/mvn/gradle/java/javac/" +
"python/pip/node/tsc/eslint/prettier/cat/ls/dir/echo). " +
"Returns combined stdout+stderr tail (8KB). 30s timeout.";
/** 允许的命令白名单(首段) */
private static final Set<String> ALLOWED_COMMANDS = Set.of(
"npm", "pnpm", "yarn", "git", "mvn", "gradle", "java", "javac",
"python", "python3", "pip", "node", "tsc", "eslint", "prettier",
"cat", "ls", "dir", "echo"
);
/** 禁止的 shell 元字符(防注入) */
private static final String FORBIDDEN_CHARS = "&|;`$<>\n\r";
/** 输出截断上限 */
private static final int MAX_OUTPUT_BYTES = 8 * 1024;
/** 命令超时(秒) */
private static final long TIMEOUT_SECONDS = 30;
private final Path rootDirectory;
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
private final CodingEventChannel channel;
public ExecuteCommandTool() {
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace");
this.channel = null;
}
/**
* 编程能力专用构造。
*/
public ExecuteCommandTool(Path root, CodingEventChannel channel) {
this.rootDirectory = root.toAbsolutePath().normalize();
this.channel = channel;
}
/**
* 执行命令
*
* @param command 完整命令行(如 "npm install" 或 "node -v"
* @return 命令输出尾部,失败返回 "Error: ..."
*/
@Tool(DESCRIPTION)
public String executeCommand(String command) {
if (command == null || command.trim().isEmpty()) {
return "Error: Command cannot be empty";
}
// 元字符黑名单校验
for (int i = 0; i < command.length(); i++) {
if (FORBIDDEN_CHARS.indexOf(command.charAt(i)) >= 0) {
return "Error: Command contains forbidden shell character: '" + command.charAt(i) + "'";
}
}
// 按空白拆分(多个空格也兼容)
List<String> parts = splitCommand(command);
if (parts.isEmpty()) {
return "Error: Command is empty after split";
}
String cmdName = parts.get(0);
// 白名单校验Windows 下尝试追加 .cmd/.exe/.bat 后缀
String resolvedCmd = resolveCommand(cmdName);
if (resolvedCmd == null) {
return "Error: Command not in whitelist: " + cmdName +
". Allowed: " + ALLOWED_COMMANDS;
}
List<String> cmdList = new ArrayList<>(parts);
cmdList.set(0, resolvedCmd);
if (channel != null) {
channel.send(CodingSseEvent.of("cmd", null, command, null, "running"));
}
Path logFile = null;
Process process = null;
try {
logFile = Files.createTempFile("coding-cmd-", ".log");
ProcessBuilder builder = new ProcessBuilder(cmdList);
builder.directory(rootDirectory.toFile());
builder.redirectErrorStream(true);
builder.redirectOutput(logFile.toFile());
process = builder.start();
String result;
if (!process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
stop(process);
String tail = readTail(logFile, MAX_OUTPUT_BYTES);
if (channel != null) {
channel.send(CodingSseEvent.of("cmd", null, command,
"超时(" + TIMEOUT_SECONDS + "s\n" + tail, "done"));
}
return "Error: command timed out after " + TIMEOUT_SECONDS + "s\n" + tail;
}
int exitCode = process.exitValue();
String output = readTail(logFile, MAX_OUTPUT_BYTES);
if (channel != null) {
channel.send(CodingSseEvent.of("cmd", null, command,
output, "done"));
}
result = exitCode == 0 ? output : "Error: exit " + exitCode + "\n" + output;
return result;
} catch (InterruptedException ex) {
if (process != null) {
process.destroyForcibly();
}
Thread.currentThread().interrupt();
return "Error: command interrupted";
} catch (IOException ex) {
logger.error("Error executing command: {}", command, ex);
return "Error: " + ex.getMessage();
} finally {
if (logFile != null) {
try {
Files.deleteIfExists(logFile);
} catch (IOException ignored) {
// 临时文件清理失败不影响主流程
}
}
}
}
/**
* 解析命令名白名单匹配Windows 下追加后缀重试。
*/
private String resolveCommand(String cmdName) {
if (ALLOWED_COMMANDS.contains(cmdName)) {
return cmdName;
}
// Windows 下 npm/pnpm 等可能是 .cmd
if (isWindows()) {
for (String suffix : new String[]{".cmd", ".exe", ".bat"}) {
String candidate = cmdName + suffix;
String baseName = stripSuffix(cmdName);
if (ALLOWED_COMMANDS.contains(baseName)) {
return candidate;
}
}
}
return null;
}
private String stripSuffix(String name) {
for (String suffix : new String[]{".cmd", ".exe", ".bat"}) {
if (name.endsWith(suffix)) {
return name.substring(0, name.length() - suffix.length());
}
}
return name;
}
private boolean isWindows() {
return System.getProperty("os.name", "").toLowerCase().contains("win");
}
/**
* 按空白拆分命令行(不处理引号,保持简单;元字符已在上游拦截)。
*/
private List<String> splitCommand(String command) {
List<String> parts = new ArrayList<>();
StringBuilder cur = new StringBuilder();
for (int i = 0; i < command.length(); i++) {
char c = command.charAt(i);
if (Character.isWhitespace(c)) {
if (cur.length() > 0) {
parts.add(cur.toString());
cur.setLength(0);
}
} else {
cur.append(c);
}
}
if (cur.length() > 0) {
parts.add(cur.toString());
}
return parts;
}
private static void stop(Process process) throws InterruptedException {
process.destroy();
if (!process.waitFor(2, TimeUnit.SECONDS)) {
process.destroyForcibly();
process.waitFor(2, TimeUnit.SECONDS);
}
}
/**
* 读取文件尾部(抄自 FfmpegProcessRunner.readTail
*/
static String readTail(Path path, int maxBytes) throws IOException {
if (!Files.exists(path)) {
return "";
}
long size = Files.size(path);
int bytesToRead = (int) Math.min(size, maxBytes);
ByteBuffer buffer = ByteBuffer.allocate(bytesToRead);
try (SeekableByteChannel channel = Files.newByteChannel(path, StandardOpenOption.READ)) {
channel.position(Math.max(0, size - bytesToRead));
while (buffer.hasRemaining() && channel.read(buffer) >= 0) {
// 读取直到尾部
}
}
return new String(buffer.array(), 0, buffer.position(), StandardCharsets.UTF_8).trim();
}
@Override
public String getToolName() {
return "execute_command";
}
@Override
public String getDisplayName() {
return "执行命令";
}
@Override
public String getDescription() {
return DESCRIPTION;
}
}

View File

@@ -2,6 +2,9 @@ package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.ruoyi.service.coding.CodingEventChannel;
import org.ruoyi.service.coding.CodingSseEvent;
import org.ruoyi.service.coding.WorkspaceGuard;
import org.springframework.stereotype.Component;
import java.io.IOException;
@@ -30,9 +33,20 @@ public class ListDirectoryTool implements BuiltinToolProvider {
private final String rootDirectory;
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
/** 编程能力 SSE 事件通道,可为 null兼容无参构造的老调用方 */
private final CodingEventChannel channel;
public ListDirectoryTool() {
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
this.channel = null;
}
/**
* 编程能力专用构造:注入会话工作目录与事件通道。
*/
public ListDirectoryTool(Path root, CodingEventChannel channel) {
this.rootDirectory = root.toAbsolutePath().normalize().toString();
this.channel = channel;
}
/**
@@ -74,11 +88,23 @@ public class ListDirectoryTool implements BuiltinToolProvider {
return "Error: Path is not a directory: " + params.filePath;
}
// 推送列目录开始事件
if (channel != null) {
channel.send(CodingSseEvent.of("list-progress", null, null,
"扫描 " + getRelativePath(dirPath), "running"));
}
// 列出文件和目录
List<FileInfo> fileInfos = listFiles(dirPath, params);
// 生成输出
return formatFileList(fileInfos, params);
String output = formatFileList(fileInfos, params);
if (channel != null) {
channel.send(CodingSseEvent.of("list-progress", null, null,
"" + fileInfos.size() + "", "done"));
}
return output;
} catch (IOException e) {
logger.error("Error listing directory: {}", params.filePath, e);
@@ -233,14 +259,7 @@ public class ListDirectoryTool implements BuiltinToolProvider {
}
private boolean isWithinWorkspace(Path dirPath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = dirPath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
return WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), dirPath);
}
private String getRelativePath(Path dirPath) {

View File

@@ -2,6 +2,9 @@ package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.ruoyi.service.coding.CodingEventChannel;
import org.ruoyi.service.coding.CodingSseEvent;
import org.ruoyi.service.coding.WorkspaceGuard;
import org.springframework.stereotype.Component;
import java.io.IOException;
@@ -22,10 +25,26 @@ public class ReadFileTool implements BuiltinToolProvider {
"Returns the complete file content as a string.";
private final String rootDirectory;
/** 读取内容截断上限,避免大文件撑爆 LLM 上下文32KB */
private static final int MAX_BYTES = 32 * 1024;
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
/** 编程能力 SSE 事件通道,可为 null兼容 BuiltinToolRegistry 无参构造的老调用方) */
private final CodingEventChannel channel;
public ReadFileTool() {
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
this.channel = null;
}
/**
* 编程能力专用构造:注入会话工作目录与事件通道。
*
* @param root 工作目录根(绝对路径)
* @param channel SSE 事件通道,工具执行前后推送 read 进度
*/
public ReadFileTool(Path root, CodingEventChannel channel) {
this.rootDirectory = root.toAbsolutePath().normalize().toString();
this.channel = channel;
}
/**
@@ -50,7 +69,7 @@ public class ReadFileTool implements BuiltinToolProvider {
}
// 验证是否在工作目录内
if (!isWithinWorkspace(path)) {
if (!WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), path)) {
return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath;
}
@@ -64,16 +83,31 @@ public class ReadFileTool implements BuiltinToolProvider {
return "Error: Path is a directory, not a file: " + filePath;
}
// 读取文件内容
String content = Files.readString(path, StandardCharsets.UTF_8);
// 获取相对路径
// 推送读取开始事件(前端展示为正在读取)
String relativePath = getRelativePath(path);
if (channel != null) {
channel.send(CodingSseEvent.of("edit-start", filePath, null, null, "running"));
}
// 读取文件内容(截断超大文件,避免撑爆上下文)
String content = Files.readString(path, StandardCharsets.UTF_8);
boolean truncated = false;
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) {
content = new String(bytes, 0, MAX_BYTES, StandardCharsets.UTF_8);
truncated = true;
}
long sizeBytes = content.getBytes(StandardCharsets.UTF_8).length;
long lineCount = content.lines().count();
String header = String.format("File: %s (%d lines, %d bytes)%s\n\n",
relativePath, lineCount, sizeBytes, truncated ? " [truncated]" : "");
return String.format("File: %s (%d lines, %d bytes)\n\n%s",
relativePath, lineCount, sizeBytes, content);
if (channel != null) {
channel.send(CodingSseEvent.of("edit-end", filePath, null, null, "done"));
}
return header + content;
} catch (IOException e) {
logger.error("Error reading file: {}", filePath, e);
@@ -85,14 +119,7 @@ public class ReadFileTool implements BuiltinToolProvider {
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
return WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), filePath);
}
private String getRelativePath(Path filePath) {

View File

@@ -0,0 +1,136 @@
package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.ruoyi.service.coding.CodingEventChannel;
import org.ruoyi.service.coding.CodingSseEvent;
import org.ruoyi.service.coding.WorkspaceGuard;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
/**
* 写文件工具
* 新建或覆盖文件,自动创建父目录
*
* <p>编程能力专用:通过构造注入工作目录与 SSE 事件通道,操作前后推送 add-start/add-end 事件。
* 不注册为 BuiltinToolProvider无需进 BuiltinToolRegistry仅由 CodingServiceImpl 按会话 new。
*
* @author ageerle
*/
@Component
public class WriteFileTool implements BuiltinToolProvider {
public static final String DESCRIPTION = "Creates or overwrites a file with the given content. " +
"Creates parent directories if missing. Overwrites existing file. " +
"Use absolute paths within the workspace directory.";
private final String rootDirectory;
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
private final CodingEventChannel channel;
public WriteFileTool() {
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
this.channel = null;
}
/**
* 编程能力专用构造。
*/
public WriteFileTool(Path root, CodingEventChannel channel) {
this.rootDirectory = root.toAbsolutePath().normalize().toString();
this.channel = channel;
}
/**
* 写文件
*
* @param filePath 文件绝对路径
* @param content 文件内容
* @return 操作结果
*/
@Tool(DESCRIPTION)
public String writeFile(String filePath, String content) {
try {
if (filePath == null || filePath.trim().isEmpty()) {
return "Error: File path cannot be empty";
}
if (content == null) {
content = "";
}
Path path = Paths.get(filePath);
if (!path.isAbsolute()) {
return "Error: File path must be absolute: " + filePath;
}
if (!WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), path)) {
return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath;
}
if (channel != null) {
channel.send(CodingSseEvent.of("add-start", filePath, null, null, "running"));
}
// 创建父目录
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
// 写入文件
Files.writeString(path, content, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
String relativePath = getRelativePath(path);
int bytes = content.getBytes(StandardCharsets.UTF_8).length;
if (channel != null) {
channel.send(CodingSseEvent.of("add-end", filePath, null,
"写入 " + bytes + " 字节", "done"));
}
return String.format("Successfully wrote %d bytes to %s", bytes, relativePath);
} catch (IOException e) {
logger.error("Error writing file: {}", filePath, e);
if (channel != null) {
channel.send(CodingSseEvent.of("add-end", filePath, null,
"Error: " + e.getMessage(), "done"));
}
return "Error: " + e.getMessage();
} catch (Exception e) {
logger.error("Unexpected error writing file: {}", filePath, e);
return "Error: Unexpected error: " + e.getMessage();
}
}
private String getRelativePath(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(filePath).toString();
} catch (Exception e) {
return filePath.toString();
}
}
@Override
public String getToolName() {
return "write_file";
}
@Override
public String getDisplayName() {
return "写入文件";
}
@Override
public String getDescription() {
return DESCRIPTION;
}
}

View File

@@ -0,0 +1,98 @@
package org.ruoyi.service.audio.provider;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.chat.entity.audio.AudioContext;
import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
import org.ruoyi.enums.ChatModeType;
import org.ruoyi.service.audio.AbstractAudioGenerationService;
import org.ruoyi.service.media.AtlasMediaSupport;
import org.ruoyi.service.media.AtlasPredictionService;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* Atlas Cloud 音频生成bytedance/seed-audio-1.0)。异步:提交 /model/generateAudio 返回 predictionId
* 轮询 /model/prediction/{id} 拿音频 URL。支持 referencesspeaker 音色 + 参考音频)做多角色对白配音。
*/
@Slf4j
@Component("atlasAudio")
@RequiredArgsConstructor
public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationService {
private final AtlasPredictionService atlasPredictionService;
private final OkHttpClient okHttpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(180, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
@Override
protected MediaGenerationResponse doGenerateSpeech(AudioContext audioContext) {
ChatModelVo model = audioContext.getChatModelVo();
ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
payload.put("model", model.getModelName());
payload.put("text", audioContext.getInput());
String format = StrUtil.blankToDefault(audioContext.getResponseFormat(), "mp3");
payload.put("format", format);
// 参考资源:多角色音色 + 参考音频
List<Map<String, String>> refs = audioContext.getReferences();
if (refs != null && !refs.isEmpty()) {
ArrayNode arr = payload.putArray("references");
for (Map<String, String> ref : refs) {
ObjectNode r = arr.addObject();
if (StrUtil.isNotBlank(ref.get("speaker"))) r.put("speaker", ref.get("speaker"));
if (StrUtil.isNotBlank(ref.get("audioUrl"))) r.put("audio_url", ref.get("audioUrl"));
if (StrUtil.isNotBlank(ref.get("audioData"))) r.put("audio_data", ref.get("audioData"));
if (StrUtil.isNotBlank(ref.get("imageData"))) r.put("image_data", ref.get("imageData"));
}
} else if (StrUtil.isNotBlank(audioContext.getVoice())) {
// 没有显式 references 但指定了 voice 音色名:作为单一 speaker
ArrayNode arr = payload.putArray("references");
ObjectNode r = arr.addObject();
r.put("speaker", audioContext.getVoice());
}
if (audioContext.getSampleRate() != null) payload.put("sample_rate", audioContext.getSampleRate());
if (audioContext.getPitchRate() != null) payload.put("pitch_rate", audioContext.getPitchRate());
if (audioContext.getSpeechRate() != null) payload.put("speech_rate", audioContext.getSpeechRate());
if (audioContext.getLoudnessRate() != null) payload.put("loudness_rate", audioContext.getLoudnessRate());
Request request = new Request.Builder()
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateAudio"))
.addHeader("Authorization", "Bearer " + model.getApiKey())
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON))
.build();
try (Response response = okHttpClient.newCall(request).execute()) {
ResponseBody body = response.body();
String responseText = body == null ? "" : body.string();
if (!response.isSuccessful()) {
throw new IllegalArgumentException("Atlas Cloud 音频生成任务创建失败: " + response.code() + " - " + responseText);
}
return atlasPredictionService.toResponse(responseText, "audio");
} catch (IOException e) {
throw new RuntimeException("Atlas Cloud 音频生成任务创建失败: " + e.getMessage(), e);
}
}
@Override
public String getProviderName() {
return ChatModeType.ATLAS.getCode();
}
}

View File

@@ -0,0 +1,29 @@
package org.ruoyi.service.coding;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
/**
* 编程智能体 AiServices 接口。
*
* <p>配合 {@code AiServices.builder(CodingAgent.class).chatModel(...).tools(...)} 构建。
* 同步 {@code String chat(...)} 方案(方案 B工具执行过程中的 add/edit/delete/cmd 事件
* 由工具内部通过 {@link CodingEventChannel} 实时推送,最终回复文本在 chat() 返回后一次性推 text。
*
* <p>{@code @SystemMessage} 约束 LLM 只能操作 workspace 内文件,并明确每个工具的用途,
* 提升工具调用命中率。
*
* @author ageerle
*/
public interface CodingAgent {
@SystemMessage("""
你是一个编程助手,直接操作用户工作目录内的文件与命令。规则:
1. 所有文件操作必须在 workspace 目录内,使用绝对路径;不要越界访问外部目录。
2. 读取文件用 read_file新建/覆盖文件用 write_file修改已存在文件用 edit_file
删除文件或目录用 delete_file查看目录结构用 list_directory执行构建/运行命令用 execute_command。
3. 修改文件前,先用 read_file 读取当前内容,避免覆盖丢失代码。
4. 执行命令前说明意图,命令失败时读取输出排查。
5. 完成任务后用一两句话总结做了什么。""")
String chat(@UserMessage String userMessage);
}

View File

@@ -0,0 +1,93 @@
package org.ruoyi.service.coding;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
/**
* 编程能力跨线程事件通道。
*
* <p>结构抄自 {@code OutputChannel},但队列元素是结构化 {@link CodingSseEvent} 而非 String。
* OutputChannel 的 {@code send(String)} 塞 JSON 字符串再让 drain 端解析是反模式;
* 这里直接传结构化对象drain 时再由 Service 层序列化。
*
* <p>调用链路:
* <pre>
* 异步线程工具执行、LLM 回调)-> channel.send(event)
* drain 线程 -> channel.drain(emitter::send)
* </pre>
*
* @author ageerle
*/
public class CodingEventChannel {
/** DONE 哨兵drain 遇到即退出 */
private static final CodingSseEvent DONE = new CodingSseEvent("__done__", null, null, null, null);
private final BlockingQueue<CodingSseEvent> queue = new LinkedBlockingQueue<>(4096);
private final AtomicReference<Throwable> error = new AtomicReference<>();
private final CountDownLatch completed = new CountDownLatch(1);
/**
* 写入一个事件:线程安全,队列满时 100ms 超时丢弃。
*/
public void send(CodingSseEvent event) {
if (event == null) {
return;
}
try {
if (!queue.offer(event, 100, TimeUnit.MILLISECONDS)) {
// 队列满,丢弃但不中断流程
System.err.println("[CodingEventChannel] 队列满,丢弃事件: " + event.eventType());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/**
* 标记正常完成。
*/
public void complete() {
queue.offer(DONE);
completed.countDown();
}
/**
* 标记错误完成,附带一条 error 事件。
*/
public void completeWithError(Throwable t) {
error.set(t);
if (t != null && t.getMessage() != null) {
queue.offer(CodingSseEvent.error(t.getMessage()));
}
queue.offer(DONE);
completed.countDown();
}
/**
* 阻塞读取事件并逐个回调;遇 DONE 退出。
*
* @param emitter 事件消费回调
*/
public void drain(Consumer<CodingSseEvent> emitter) throws InterruptedException {
while (true) {
CodingSseEvent msg = queue.poll(200, TimeUnit.MILLISECONDS);
if (msg != null) {
if (DONE == msg || "__done__".equals(msg.eventType())) {
break;
}
emitter.accept(msg);
} else if (completed.getCount() == 0 && queue.isEmpty()) {
break;
}
}
}
public boolean isCompleted() {
return completed.getCount() == 0;
}
}

View File

@@ -0,0 +1,43 @@
package org.ruoyi.service.coding;
/**
* 编程能力 SSE 事件 DTO。
*
* <p>事件名与前端 {@code ruoyi-copilot/src/App.vue} 的 {@code applyStreamEvent} 卡片契约对齐:
* <ul>
* <li>{@code thinking} / {@code text} —— LLM 思考/回复文本增量</li>
* <li>{@code add|edit|delete}-{start|progress|end} —— 文件写入/编辑/删除</li>
* <li>{@code cmd} —— 命令执行</li>
* <li>{@code list-progress} —— 列目录</li>
* <li>{@code done} / {@code error} —— 流结束</li>
* </ul>
*
* <p>约束add/edit/delete 必须带 filePath前端用 operation.filePath 存在性区分
* code-change 卡 vs activity-row 卡cmd/list-progress 不带 filePath。
*
* @author ageerle
*/
public record CodingSseEvent(String eventType, String filePath, String command,
String content, String status) {
public static CodingSseEvent of(String eventType, String filePath, String command,
String content, String status) {
return new CodingSseEvent(eventType, filePath, command, content, status);
}
public static CodingSseEvent text(String content) {
return new CodingSseEvent("text", null, null, content, null);
}
public static CodingSseEvent thinking(String content) {
return new CodingSseEvent("thinking", null, null, content, null);
}
public static CodingSseEvent done() {
return new CodingSseEvent("done", null, null, null, null);
}
public static CodingSseEvent error(String message) {
return new CodingSseEvent("error", null, null, message, null);
}
}

View File

@@ -0,0 +1,134 @@
package org.ruoyi.service.coding;
import org.ruoyi.mcp.tools.ExecuteCommandTool;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Comparator;
import java.util.List;
/** Basic, workspace-scoped file and command operations for the Copilot UI. */
@Service
public class CodingWorkspaceService {
public static final String DEFAULT_WORKSPACE = "D:/Project/github/ruoyi-copilot";
private static final long MAX_FILE_BYTES = 1024 * 1024;
private static final int MAX_ENTRIES = 500;
public WorkspaceResult list(String workspacePath) throws IOException {
Path root = resolveRoot(workspacePath);
Files.createDirectories(root);
try (var stream = Files.walk(root, 8)) {
List<FileEntry> files = stream
.filter(path -> !path.equals(root))
.filter(path -> !isIgnored(root, path))
.sorted(Comparator.comparing(path -> root.relativize(path).toString()))
.limit(MAX_ENTRIES)
.map(path -> toEntry(root, path))
.toList();
return new WorkspaceResult(root.toString(), files.size(), files);
}
}
public FileContent read(String workspacePath, String relativePath) throws IOException {
Path root = resolveRoot(workspacePath);
Path file = resolveFile(root, relativePath);
if (!Files.isRegularFile(file)) {
throw new IllegalArgumentException("File does not exist: " + relativePath);
}
long size = Files.size(file);
if (size > MAX_FILE_BYTES) {
throw new IllegalArgumentException("File is larger than 1 MB: " + relativePath);
}
if (isBinary(file)) {
throw new IllegalArgumentException("Binary files cannot be edited: " + relativePath);
}
return new FileContent(normalizeRelative(root, file), Files.readString(file, StandardCharsets.UTF_8), size);
}
public FileContent write(String workspacePath, String relativePath, String content) throws IOException {
Path root = resolveRoot(workspacePath);
Path file = resolveFile(root, relativePath);
byte[] bytes = (content == null ? "" : content).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_FILE_BYTES) {
throw new IllegalArgumentException("File content is larger than 1 MB");
}
if (file.getParent() != null) Files.createDirectories(file.getParent());
Files.writeString(file, content == null ? "" : content, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
return new FileContent(normalizeRelative(root, file), content == null ? "" : content, bytes.length);
}
public CommandResult execute(String workspacePath, String command) {
Path root = resolveRoot(workspacePath);
String output = new ExecuteCommandTool(root, null).executeCommand(command);
boolean success = !output.startsWith("Error:");
return new CommandResult(command, output, success);
}
public Path resolveRoot(String workspacePath) {
Path configured = Paths.get(DEFAULT_WORKSPACE).toAbsolutePath().normalize();
if (workspacePath == null || workspacePath.isBlank()) return configured;
Path requested = Paths.get(workspacePath).toAbsolutePath().normalize();
if (!requested.equals(configured)) {
throw new IllegalArgumentException("Workspace is not allowed: " + requested);
}
return configured;
}
private Path resolveFile(Path root, String relativePath) {
if (relativePath == null || relativePath.isBlank()) {
throw new IllegalArgumentException("File path cannot be empty");
}
Path supplied = Paths.get(relativePath);
Path target = (supplied.isAbsolute() ? supplied : root.resolve(supplied)).normalize();
if (!WorkspaceGuard.isWithinWorkspace(root, target)) {
throw new IllegalArgumentException("File must be inside the workspace");
}
return target;
}
private boolean isIgnored(Path root, Path path) {
Path relative = root.relativize(path);
for (Path part : relative) {
String name = part.toString();
if (name.equals(".git") || name.equals("node_modules") || name.equals("dist") || name.equals("target")) {
return true;
}
}
return false;
}
private FileEntry toEntry(Path root, Path path) {
try {
return new FileEntry(normalizeRelative(root, path), path.getFileName().toString(),
Files.isDirectory(path), Files.isDirectory(path) ? 0 : Files.size(path));
} catch (IOException e) {
return new FileEntry(normalizeRelative(root, path), path.getFileName().toString(),
Files.isDirectory(path), 0);
}
}
private String normalizeRelative(Path root, Path path) {
return root.relativize(path).toString().replace('\\', '/');
}
private boolean isBinary(Path file) throws IOException {
byte[] sample;
try (var input = Files.newInputStream(file)) {
sample = input.readNBytes(4096);
}
for (byte value : sample) if (value == 0) return true;
return false;
}
public record FileEntry(String path, String name, boolean directory, long size) { }
public record WorkspaceResult(String root, int fileCount, List<FileEntry> files) { }
public record FileContent(String path, String content, long size) { }
public record CommandResult(String command, String output, boolean success) { }
}

View File

@@ -0,0 +1,21 @@
package org.ruoyi.service.coding;
import org.ruoyi.domain.bo.coding.CodingRequestBo;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* 编程能力 Service
*
* @author ageerle
*/
public interface ICodingService {
/**
* 编程对话SSE 流式)
*
* @param bo 请求参数
* @param userId 用户 ID可为 null第一阶段免鉴权
* @return SseEmitter
*/
SseEmitter chat(CodingRequestBo bo, Long userId);
}

View File

@@ -0,0 +1,50 @@
package org.ruoyi.service.coding;
import java.io.IOException;
import java.nio.file.Path;
/**
* 工作目录安全守卫。
*
* <p>抽取自 {@code ReadFileTool/EditFileTool/ListDirectoryTool} 中重复的 {@code isWithinWorkspace}
* 五个文件工具共用。强制所有操作路径必须落在工作目录内,防止路径穿越与软链接逃逸。
*
* <p>实现要点:
* <ul>
* <li>{@code root.toRealPath()} 解析符号链接(防软链接逃逸)</li>
* <li>{@code target.normalize()} 消除 {@code ..} 穿越段</li>
* <li>{@code startsWith} 是 Path 段前缀匹配,非字符串前缀({@code /workspace/abc} 不会误判成 {@code /workspace-evil}</li>
* </ul>
*
* @author ageerle
*/
public final class WorkspaceGuard {
private WorkspaceGuard() {
}
/**
* 判断目标路径是否在工作目录内。
*
* @param root 工作目录根(绝对路径)
* @param target 待校验路径
* @return true 表示在 workspace 内,安全
*/
public static boolean isWithinWorkspace(Path root, Path target) {
try {
Path realRoot = root.toRealPath().normalize();
Path realTarget = target.normalize();
return realTarget.startsWith(realRoot);
} catch (IOException e) {
// 目标路径不存在或无法解析(如新建文件前其父目录链中有不存在的段)
// 退化为 normalize 后做段前缀匹配,仍能拦住明显的越界
try {
Path realRoot = root.toRealPath().normalize();
Path normalizedTarget = target.normalize();
return normalizedTarget.startsWith(realRoot);
} catch (IOException ignore) {
return false;
}
}
}
}

View File

@@ -0,0 +1,198 @@
package org.ruoyi.service.coding.impl;
import cn.hutool.core.util.StrUtil;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.service.AiServices;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.chat.service.chat.IChatModelService;
import org.ruoyi.common.json.utils.JsonUtils;
import org.ruoyi.domain.bo.coding.CodingRequestBo;
import org.ruoyi.factory.ChatServiceFactory;
import org.ruoyi.mcp.tools.DeleteFileTool;
import org.ruoyi.mcp.tools.EditFileTool;
import org.ruoyi.mcp.tools.ExecuteCommandTool;
import org.ruoyi.mcp.tools.ListDirectoryTool;
import org.ruoyi.mcp.tools.ReadFileTool;
import org.ruoyi.mcp.tools.WriteFileTool;
import org.ruoyi.service.chat.AbstractChatService;
import org.ruoyi.service.coding.CodingAgent;
import org.ruoyi.service.coding.CodingEventChannel;
import org.ruoyi.service.coding.CodingSseEvent;
import org.ruoyi.service.coding.ICodingService;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 编程能力 Service 实现。
*
* <p>B 路径:自建 SseEmitter不进 SseEmitterManager 全局注册表),照 ShortDramaServiceImpl 骨架。
* 拿模型三步skill 铁律)→ 解析工作目录 → new 工具实例注入 channel+root → AiServices 构建 →
* 异步执行,工具内部通过 channel 实时推事件drain 线程把事件写到 emitter。
*
* @author ageerle
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CodingServiceImpl implements ICodingService {
/** 默认工作目录:直接指向 ruoyi-copilot 前端项目 */
private static final String DEFAULT_WORKSPACE = "D:/Project/github/ruoyi-copilot";
private final IChatModelService chatModelService;
private final ChatServiceFactory chatServiceFactory;
private final Map<SseEmitter, AtomicBoolean> activeEmitters = new ConcurrentHashMap<>();
@Override
public SseEmitter chat(CodingRequestBo bo, Long userId) {
SseEmitter emitter = new SseEmitter(1_800_000L);
AtomicBoolean emitterActive = new AtomicBoolean(true);
activeEmitters.put(emitter, emitterActive);
emitter.onCompletion(() -> closeEmitter(emitter));
emitter.onTimeout(() -> closeEmitter(emitter));
emitter.onError(error -> closeEmitter(emitter));
CompletableFuture.runAsync(() -> {
CodingEventChannel channel = new CodingEventChannel();
Thread drainThread = new Thread(() -> {
try {
channel.drain(event -> sendEmitterEvent(emitter, toSseEvent(event)));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Throwable t) {
log.error("编程 SSE drain 线程异常", t);
}
}, "coding-sse-drain");
drainThread.start();
try {
// 推送思考开始
channel.send(CodingSseEvent.thinking("正在分析指令..."));
// 1. 拿模型三步(不硬编码配置)
ChatModelVo modelVo = chatModelService.selectModelByName(bo.getModel());
if (modelVo == null) {
throw new IllegalStateException("模型未找到: " + bo.getModel()
+ ",请在 chat_model 表配置该模型名称");
}
AbstractChatService chatService = chatServiceFactory.getOriginalService(modelVo.getProviderCode());
ChatModel chatModel = chatService.buildChatModel(modelVo);
// 2. 解析工作目录
Path root = resolveWorkspace(bo.getWorkspacePath());
Files.createDirectories(root);
// 3. new 工具实例(不走 BuiltinToolRegistry注入会话工作目录与 channel
ReadFileTool read = new ReadFileTool(root, channel);
EditFileTool edit = new EditFileTool(root, channel);
ListDirectoryTool list = new ListDirectoryTool(root, channel);
WriteFileTool write = new WriteFileTool(root, channel);
DeleteFileTool delete = new DeleteFileTool(root, channel);
ExecuteCommandTool exec = new ExecuteCommandTool(root, channel);
// 4. 构建 AiServices
CodingAgent agent = AiServices.builder(CodingAgent.class)
.chatModel(chatModel)
.tools(read, edit, list, write, delete, exec)
.build();
// 5. 同步调用(方案 B工具执行过程中事件通过 channel 实时推送
String result = agent.chat(bo.getPrompt());
// 6. 推送最终文本
if (StrUtil.isNotBlank(result)) {
channel.send(CodingSseEvent.text(result));
}
channel.send(CodingSseEvent.done());
channel.complete();
drainThread.join(5_000);
completeEmitter(emitter);
} catch (Exception e) {
log.error("编程对话失败", e);
String msg = e.getMessage() == null ? e.toString() : e.getMessage();
channel.send(CodingSseEvent.error(msg));
channel.completeWithError(e);
try {
drainThread.join(2_000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
sendEmitterEvent(emitter, SseEmitter.event().name("error")
.data(JsonUtils.toJsonString(Map.of("message", msg))));
completeEmitterWithError(emitter, e);
}
});
return emitter;
}
/**
* 解析工作目录:前端显式传则用前端的,否则默认 ruoyi-copilot。
*/
private Path resolveWorkspace(String workspacePath) {
if (StrUtil.isNotBlank(workspacePath)) {
return Paths.get(workspacePath).toAbsolutePath().normalize();
}
return Paths.get(DEFAULT_WORKSPACE).toAbsolutePath().normalize();
}
/**
* 把结构化事件转成 SseEmitter 事件。
*/
private SseEmitter.SseEventBuilder toSseEvent(CodingSseEvent event) {
Map<String, Object> payload = new LinkedHashMap<>();
if (event.filePath() != null) payload.put("filePath", event.filePath());
if (event.command() != null) payload.put("command", event.command());
if (event.content() != null) payload.put("content", event.content());
if (event.status() != null) payload.put("status", event.status());
return SseEmitter.event()
.name(event.eventType())
.data(JsonUtils.toJsonString(payload));
}
// ==================== SSE 发送封装(抄自 ShortDramaServiceImpl ====================
private boolean sendEmitterEvent(SseEmitter emitter, SseEmitter.SseEventBuilder event) {
AtomicBoolean active = activeEmitters.get(emitter);
if (active == null || !active.get()) return false;
try {
emitter.send(event);
return true;
} catch (IOException | IllegalStateException e) {
closeEmitter(emitter);
return false;
}
}
private void closeEmitter(SseEmitter emitter) {
AtomicBoolean active = activeEmitters.remove(emitter);
if (active != null) active.set(false);
}
private void completeEmitter(SseEmitter emitter) {
AtomicBoolean active = activeEmitters.get(emitter);
if (active == null || !active.compareAndSet(true, false)) return;
activeEmitters.remove(emitter);
try { emitter.complete(); } catch (IllegalStateException ignored) { }
}
private void completeEmitterWithError(SseEmitter emitter, Throwable error) {
AtomicBoolean active = activeEmitters.get(emitter);
if (active == null || !active.compareAndSet(true, false)) return;
activeEmitters.remove(emitter);
try { emitter.completeWithError(error); } catch (IllegalStateException ignored) { }
}
}

View File

@@ -35,4 +35,12 @@ public final class AtlasMediaSupport {
JsonNode value = node == null ? null : node.get(field);
return value == null || value.isNull() ? null : value.asText();
}
/**
* 截断超长文本,用于日志输出原始响应时防止刷屏。
*/
public static String truncate(String text, int max) {
if (text == null) return null;
return text.length() <= max ? text : text.substring(0, max) + "...(truncated " + (text.length() - max) + " chars)";
}
}

View File

@@ -59,16 +59,56 @@ public class AtlasPredictionService {
public MediaGenerationResponse toResponse(String raw, String type) throws IOException {
JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(raw);
JsonNode data = root.path("data");
String status = AtlasMediaSupport.text(data, "status");
String lastFrameUrl = firstLastFrame(data);
if ("video".equals(type)) {
// 终态打完整原始响应(确认 Atlas 末帧字段名/结构),轮询中间态只打摘要,避免刷屏
if ("succeeded".equals(status) || "completed".equals(status) || "failed".equals(status)) {
log.info("Atlas 视频结果[{}]原始响应: {}", status, AtlasMediaSupport.truncate(raw, 2000));
}
log.info("Atlas 视频结果解析: status={}, lastFrameUrl={}", status, lastFrameUrl);
}
return MediaGenerationResponse.builder()
.type(type)
.mimeType("image".equals(type) ? "image/png" : "video/mp4")
.id(AtlasMediaSupport.text(data, "id"))
.status(AtlasMediaSupport.text(data, "status"))
.status(status)
.url(firstOutput(data))
.lastFrameUrl(lastFrameUrl)
.rawResponse(raw)
.build();
}
/**
* 提取末帧 URL。Atlas return_last_frame=true 时会在 outputs 或顶层节点返回末帧图片,
* 字段名兼容 last_frame_url / end_frame_url / last_frame / last_frame_image。
*/
private String firstLastFrame(JsonNode data) {
if (data == null || data.isMissingNode()) return null;
String[] keys = {"last_frame_url", "end_frame_url", "last_frame", "last_frame_image"};
for (String key : keys) {
String val = AtlasMediaSupport.text(data, key);
if (val != null) return val;
}
JsonNode outputs = data.path("outputs");
if (outputs.isObject()) {
for (String key : keys) {
String val = AtlasMediaSupport.text(outputs, key);
if (val != null) return val;
}
}
if (outputs.isArray() && !outputs.isEmpty()) {
JsonNode first = outputs.get(0);
if (first.isObject()) {
for (String key : keys) {
String val = AtlasMediaSupport.text(first, key);
if (val != null) return val;
}
}
}
return null;
}
private String firstOutput(JsonNode data) {
JsonNode outputs = data.path("outputs");
if (outputs.isArray() && !outputs.isEmpty()) {

View File

@@ -1,6 +1,7 @@
package org.ruoyi.service.shortdrama;
import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
import org.ruoyi.domain.bo.shortdrama.ShortDramaAudioBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo;
@@ -8,6 +9,7 @@ import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaAudioVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo;
@@ -113,4 +115,15 @@ public interface IShortDramaService {
ShortDramaLocationVo confirmLocationImage(Long locationId, String predictionId, String model, Long userId);
Boolean deleteProject(Long projectId, Long userId);
// ==================== 语音资产 ====================
ShortDramaAudioVo saveAudio(ShortDramaAudioBo bo, Long userId);
Boolean deleteAudio(Long audioId, Long userId);
List<ShortDramaAudioVo> listAudios(Long projectId, Long userId);
/** 生成语音TTS 合成音频,上传 OSS回写 audioUrl/audioOssId */
ShortDramaAudioVo generateAudio(Long audioId, String audioModel, Long userId);
}

View File

@@ -6,7 +6,10 @@ import com.fasterxml.jackson.annotation.JsonValue;
public enum AspectRatio {
PORTRAIT("9:16", 1080, 1920),
LANDSCAPE("16:9", 1920, 1080),
SQUARE("1:1", 1080, 1080);
LANDSCAPE_CLASSIC("4:3", 1440, 1080),
SQUARE("1:1", 1080, 1080),
PORTRAIT_CLASSIC("3:4", 1080, 1440),
ULTRAWIDE("21:9", 2520, 1080);
private final String value;
private final int width;

View File

@@ -1,5 +1,6 @@
package org.ruoyi.service.shortdrama.composition;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
@@ -7,7 +8,9 @@ public record CompositionSpec(
List<CompositionSource> sources,
TransitionType transitionType,
double transitionDurationSeconds,
AspectRatio aspectRatio
AspectRatio aspectRatio,
Path narrationAudioPath,
boolean watermark
) {
public CompositionSpec {

View File

@@ -32,6 +32,11 @@ public class FfmpegCommandBuilder {
command.add("-i");
command.add(source.path().toString());
}
// 旁白音轨作为额外输入流index = sources.size()
if (spec.narrationAudioPath() != null) {
command.add("-i");
command.add(spec.narrationAudioPath().toString());
}
command.add("-filter_complex_script");
command.add(filterScript.toAbsolutePath().normalize().toString());
command.add("-map");

View File

@@ -36,6 +36,17 @@ public class FfmpegCompositionProperties {
private String storageMode = "local";
private String localOutputDirectory = "logs/short-drama-compositions";
/** 成片是否默认加水印(前端开关未传时使用该默认值) */
private boolean watermarkEnabled = true;
/** 水印文字 */
private String watermarkText = "视频由ruoyi-drama生成";
/** 水印字体大小 */
private int watermarkFontSize = 28;
/** 水印透明度 0.0-1.0 */
private double watermarkAlpha = 0.6;
/** 水印字体文件路径(为空则用系统默认字体) */
private String watermarkFontFile;
public BigDecimal normalizeTransitionDuration(TransitionType type, BigDecimal requested) {
if (type == null || type == TransitionType.NONE) {
return BigDecimal.ZERO;

View File

@@ -4,6 +4,8 @@ import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@@ -42,10 +44,32 @@ public class FfmpegFilterGraphBuilder {
filters.add(normalizeAudio(index, duration, info.hasAudio()));
}
FfmpegFilterGraph base;
if (spec.transitionType() == TransitionType.NONE) {
return hardCut(filters, media, canvas);
base = hardCut(filters, media, canvas);
} else {
base = transition(filters, spec, media, canvas, frameSeconds);
}
return transition(filters, spec, media, canvas, frameSeconds);
// 旁白音轨混入:在最终音轨上 amix 旁白输入(输入 index = sources.size()
String audioLabel = base.audioLabel();
if (spec.narrationAudioPath() != null) {
audioLabel = mixNarration(filters, base, media, audioLabel);
}
// 水印:在最终视频流上 drawtext
String videoLabel = base.videoLabel();
if (spec.watermark()) {
videoLabel = applyWatermark(filters, videoLabel, canvas);
}
return new FfmpegFilterGraph(
String.join(";", filters),
videoLabel,
audioLabel,
base.expectedDurationSeconds(),
canvas
);
}
private FfmpegFilterGraph hardCut(List<String> filters, List<MediaInfo> media, VideoCanvas canvas) {
@@ -134,6 +158,84 @@ public class FfmpegFilterGraphBuilder {
);
}
/**
* 将旁白音轨混入最终音轨。旁白作为额外输入流index = sources.size()
* 先归一化再与主音轨 amixnormalize=0 防止原片音被自动压低duration=first 以原片长度为准。
*/
private String mixNarration(List<String> filters, FfmpegFilterGraph base,
List<MediaInfo> media, String audioLabel) {
int narrationIndex = media.size();
double expectedDuration = base.expectedDurationSeconds();
String format = "aformat=sample_fmts=fltp:sample_rates=" + properties.getAudioSampleRate()
+ ":channel_layouts=stereo";
filters.add("[" + narrationIndex + ":a:0]"
+ "aresample=" + properties.getAudioSampleRate() + ":async=1:first_pts=0,"
+ format + ","
+ "atrim=start=0:duration=" + seconds(expectedDuration) + ","
+ "asetpts=PTS-STARTPTS[narr]");
String mixed = "anarr";
filters.add("[" + audioLabel + "][narr]amix=inputs=2:duration=first:normalize=0[" + mixed + "]");
return mixed;
}
/**
* 在最终视频流右下角叠加水印文字。
* 优先使用显式配置的字体;未配置时探测各平台的常见字体,避免 Windows 版 FFmpeg
* 在 Fontconfig 配置缺失时因 drawtext 发生原生崩溃。
*/
private String applyWatermark(List<String> filters, String videoLabel, VideoCanvas canvas) {
String text = properties.getWatermarkText();
if (text == null || text.isBlank()) {
return videoLabel;
}
String alpha = formatAlpha(properties.getWatermarkAlpha());
StringBuilder expr = new StringBuilder();
expr.append("[").append(videoLabel).append("]drawtext=text='").append(escape(text)).append("'");
String fontFile = resolveWatermarkFontFile();
expr.append(":fontfile='").append(escape(fontFile)).append("'");
expr.append(":fontcolor=white@").append(alpha)
.append(":fontsize=").append(properties.getWatermarkFontSize())
.append(":x=w-tw-20:y=h-th-20[wmark]");
filters.add(expr.toString());
return "wmark";
}
private String resolveWatermarkFontFile() {
String configured = properties.getWatermarkFontFile();
if (configured != null && !configured.isBlank()) {
Path configuredPath = Path.of(configured).toAbsolutePath().normalize();
if (!Files.isRegularFile(configuredPath) || !Files.isReadable(configuredPath)) {
throw new IllegalStateException("Configured watermark font is not a readable file: " + configuredPath);
}
return configuredPath.toString();
}
List<Path> candidates = List.of(
Path.of("C:/Windows/Fonts/simhei.ttf"),
Path.of("C:/Windows/Fonts/msyh.ttc"),
Path.of("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
Path.of("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
Path.of("/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf")
);
return candidates.stream()
.filter(path -> Files.isRegularFile(path) && Files.isReadable(path))
.findFirst()
.map(path -> path.toAbsolutePath().normalize().toString())
.orElseThrow(() -> new IllegalStateException(
"No readable watermark font was found; configure short-drama.composition.watermark-font-file"
));
}
private static String escape(String value) {
return value.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'");
}
private static String formatAlpha(double alpha) {
if (alpha <= 0) return "0";
if (alpha >= 1) return "1";
return BigDecimal.valueOf(alpha).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
}
private String normalizeVideo(int inputIndex, int videoStreamIndex, String duration, VideoCanvas canvas) {
return "[" + inputIndex + ":" + videoStreamIndex + "]"
+ "scale=" + canvas.width() + ":" + canvas.height()

View File

@@ -4,6 +4,7 @@ import org.ruoyi.service.shortdrama.composition.AspectRatio;
import org.ruoyi.service.shortdrama.composition.TransitionType;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
@@ -14,7 +15,10 @@ record ShortDramaVideoComposeJob(
TransitionType transitionType,
BigDecimal transitionDurationSeconds,
AspectRatio aspectRatio,
List<Long> storyboardIds
List<Long> storyboardIds,
Long narrationAudioId,
Path narrationAudioPath,
boolean watermark
) {
ShortDramaVideoComposeJob {

View File

@@ -92,7 +92,10 @@ public class ShortDramaVideoComposeServiceImpl implements IShortDramaVideoCompos
transitionType,
transitionDuration,
aspectRatio,
bo.getStoryboardIds()
bo.getStoryboardIds(),
bo.getNarrationAudioId(),
null,
resolveWatermark(bo.getWatermark())
);
try {
composeWorker.composeAsync(job);
@@ -218,6 +221,11 @@ public class ShortDramaVideoComposeServiceImpl implements IShortDramaVideoCompos
}
}
/** 前端 watermark 为空时回退到配置默认值(默认开启 ruoyi-ai */
private boolean resolveWatermark(Boolean requested) {
return requested == null ? compositionProperties.isWatermarkEnabled() : requested;
}
private Date staleBefore(Date now) {
Duration staleAfter = compositionProperties.getJobStaleAfter();
if (staleAfter == null || staleAfter.isZero() || staleAfter.isNegative()) {

View File

@@ -10,8 +10,10 @@ import org.ruoyi.common.core.exception.ServiceException;
import org.ruoyi.common.core.service.OssService;
import org.ruoyi.common.core.utils.file.FileUtils;
import org.ruoyi.common.tenant.helper.TenantHelper;
import org.ruoyi.domain.entity.shortdrama.ShortDramaAudio;
import org.ruoyi.domain.entity.shortdrama.ShortDramaProject;
import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard;
import org.ruoyi.mapper.shortdrama.ShortDramaAudioMapper;
import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper;
import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper;
import org.ruoyi.service.shortdrama.composition.CompositionArtifact;
@@ -41,6 +43,7 @@ public class ShortDramaVideoComposeWorker {
private final ShortDramaProjectMapper projectMapper;
private final ShortDramaStoryboardMapper storyboardMapper;
private final ShortDramaAudioMapper audioMapper;
private final FfmpegVideoComposer videoComposer;
private final FfmpegCompositionProperties compositionProperties;
private final SafeVideoSourceDownloader sourceDownloader;
@@ -65,11 +68,16 @@ public class ShortDramaVideoComposeWorker {
return;
}
// 旁白语音资产下载到工作目录ossId → 本地文件)
Path narrationAudioPath = downloadNarration(job, workDirectory);
CompositionArtifact artifact = videoComposer.compose(new CompositionSpec(
sources,
job.transitionType(),
job.transitionDurationSeconds().doubleValue(),
job.aspectRatio()
job.aspectRatio(),
narrationAudioPath,
job.watermark()
), workDirectory);
if (!updateProgress(job, 85)) {
return;
@@ -198,6 +206,24 @@ public class ShortDramaVideoComposeWorker {
.set(ShortDramaProject::getUpdateTime, new Date())) > 0;
}
/**
* 将旁白语音资产下载为本地文件。语音资产未指定或不存在时返回 null不混入旁白
*/
private Path downloadNarration(ShortDramaVideoComposeJob job, Path workDirectory) throws IOException {
if (job.narrationAudioId() == null) {
return null;
}
ShortDramaAudio audio = audioMapper.selectById(job.narrationAudioId());
if (audio == null || StrUtil.isBlank(audio.getAudioUrl())) {
log.warn("旁白语音资产不存在或无音频URL, audioId={}", job.narrationAudioId());
return null;
}
Path target = workDirectory.resolve("narration.mp3");
long maxSourceBytes = compositionProperties.getMaxSourceBytes();
sourceDownloader.download(audio.getAudioUrl(), target, maxSourceBytes, maxSourceBytes);
return target;
}
private boolean isActive(ShortDramaVideoComposeJob job) {
return selectActiveProject(job) != null;
}

View File

@@ -0,0 +1,116 @@
package org.ruoyi.service.shortdrama.support;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
/**
* 增量 JSON 数组解析器:随 LLM 流式输出累积 buffer逐个提取已闭合的顶层对象
* 每完成一个就解析成目标类型并返回(已返回的不再重复)。
* 用于分镜规划流式推送——第一个 panel 解析出来即可展示,不等整个数组完成。
* <p>
* 仅依赖 brace matching + 字符串/转义状态机,对未闭合的尾对象不解析,保证稳定性。
*/
@Slf4j
public final class IncrementalJsonArrayExtractor<T> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Class<T> type;
private final StringBuilder buf = new StringBuilder();
private int emittedCount = 0;
/** 数组起始 '[' 在 buffer 中的位置,-1 表示尚未找到 */
private int arrayStart = -1;
public IncrementalJsonArrayExtractor(Class<T> type) {
this.type = type;
}
/**
* 喂入新的累积文本,返回本次新解析出的完整对象(去重)。
* 调用方应每次把"完整 buffer"传入(实现内部不重复追加,而是以最新 buffer 为准)。
*/
public List<T> feed(String fullBuffer) {
List<T> newly = new ArrayList<>();
if (StrUtil.isBlank(fullBuffer)) return newly;
buf.setLength(0);
buf.append(fullBuffer);
if (arrayStart < 0) {
arrayStart = findArrayStart(fullBuffer);
if (arrayStart < 0) return newly;
}
int scanFrom = arrayStart + 1;
int objIdx = 0;
int i = scanFrom;
int len = buf.length();
while (i < len) {
char c = buf.charAt(i);
if (c == '{') {
int end = findObjectEnd(i);
if (end < 0) break; // 对象未闭合,等后续 token
if (objIdx >= emittedCount) {
String objJson = buf.substring(i, end + 1);
T parsed = tryParse(objJson);
if (parsed != null) {
newly.add(parsed);
emittedCount++;
}
}
i = end + 1;
objIdx++;
} else if (c == ']') {
break; // 数组结束
} else if (Character.isWhitespace(c)) {
i++;
} else {
i++;
}
}
return newly;
}
/** 定位第一个 '['跳过思考文字、markdown 代码块围栏 ```json 等)。 */
private int findArrayStart(String s) {
return s.indexOf('[');
}
/**
* 从 start指向 '{')开始,找到该对象的闭合 '}',正确处理字符串、转义、嵌套。
* 返回闭合 '}' 的索引;若未闭合返回 -1。
*/
private int findObjectEnd(int start) {
int depth = 0;
boolean inString = false;
boolean escape = false;
for (int i = start; i < buf.length(); i++) {
char c = buf.charAt(i);
if (escape) { escape = false; continue; }
if (inString) {
if (c == '\\') { escape = true; }
else if (c == '"') { inString = false; }
continue;
}
if (c == '"') { inString = true; }
else if (c == '{') { depth++; }
else if (c == '}') {
depth--;
if (depth == 0) return i;
}
}
return -1;
}
private T tryParse(String json) {
try {
return MAPPER.readValue(json, type);
} catch (Exception e) {
log.debug("增量解析 panel 失败,跳过: {}", e.getMessage());
return null;
}
}
}

View File

@@ -59,6 +59,23 @@ public class AtlasVideoGenerationServiceImpl extends AbstractVideoGenerationServ
payload.put("image_url", videoContext.getImageUrl());
}
// 同步音频生成(环境音/动效)
if (videoContext.getGenerateAudio() != null) {
payload.put("generate_audio", videoContext.getGenerateAudio());
}
// 参考音频(对白口型对齐)
java.util.List<String> refAudios = videoContext.getReferenceAudios();
if (refAudios != null && !refAudios.isEmpty()) {
com.fasterxml.jackson.databind.node.ArrayNode arr = payload.putArray("reference_audios");
for (String url : refAudios) {
arr.add(url);
}
}
// 返回末帧(同场景连续镜头首帧承接用)
if (videoContext.getReturnLastFrame() != null) {
payload.put("return_last_frame", videoContext.getReturnLastFrame());
}
Request request = new Request.Builder()
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateVideo"))
.addHeader("Authorization", "Bearer " + model.getApiKey())

View File

@@ -0,0 +1,81 @@
package org.ruoyi.websocket.chat;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.core.domain.model.LoginUser;
import org.ruoyi.common.satoken.utils.LoginHelper;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* 小程序对话 WS 握手拦截器。
* <p>
* 无权限:握手始终放行。仅尝试从握手 URL 的 Authorization 参数解析登录用户,
* 解析成功则把 userId 放入 session attributes 供 handler 落库使用;解析失败按匿名处理。
* <p>
* 注意:与公共 {@code PlusWebSocketInterceptor} 不同,这里不做 clientid 一致性校验,
* 也不抛出认证异常——对话端点对未登录用户同样开放。
*
* @author ruoyi team
*/
@Slf4j
@Component
public class MpChatHandshakeInterceptor implements HandshakeInterceptor {
public static final String USER_ID_KEY = "mpChatUserId";
public static final String LOGIN_USER_KEY = "mpChatLoginUser";
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
// 无权限:握手始终放行,且不调用 sa-tokenLoginHelper.getLoginUser 会触发 getTokenSessionByToken
// 在 is-share:false 下有冻结当前 token 的副作用,导致随后 mvc 请求 401 token 已被冻结。
// 对话端点本就不依赖登录态userId 留空,落库跳过)。
log.info("[mp-chat connect] 匿名对话连接");
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
// no-op
}
/**
* 从握手 URL query 中解析 token。
* 前端约定以 Authorization=Bearer xxx 形式透传,去掉 Bearer 前缀取真实 token。
*/
private String resolveToken(URI uri) {
String query = uri.getRawQuery();
if (query == null || query.isEmpty()) {
return null;
}
for (String pair : query.split("&")) {
int idx = pair.indexOf('=');
if (idx <= 0) {
continue;
}
String key = pair.substring(0, idx);
if (!"Authorization".equalsIgnoreCase(key)) {
continue;
}
String value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8);
if (value == null) {
return null;
}
value = value.trim();
if (value.startsWith("Bearer ")) {
value = value.substring(7).trim();
}
return value.isEmpty() ? null : value;
}
return null;
}
}

View File

@@ -0,0 +1,33 @@
package org.ruoyi.websocket.chat;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
/**
* 小程序对话 WebSocket 端点配置。
* <p>
* 独立注册 /chat/ws无权限握手拦截器仅做 token 解析、不拦截),
* 与公共 ruoyi-common-websocket 的 /resource/websocket 互不干扰(后者受 websocket.enabled 控制,默认关闭)。
*
* @author ruoyi team
*/
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class MpChatWebSocketConfig {
private final MpChatWebSocketHandler mpChatWebSocketHandler;
private final MpChatHandshakeInterceptor mpChatHandshakeInterceptor;
@Bean
public WebSocketConfigurer mpChatWebSocketConfigurer() {
return registry -> registry
.addHandler(mpChatWebSocketHandler, "/chat/ws")
.addInterceptors(mpChatHandshakeInterceptor)
.setAllowedOrigins("*");
}
}

View File

@@ -0,0 +1,378 @@
package org.ruoyi.websocket.chat;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.StreamingChatModel;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import dev.langchain4j.rag.AugmentationRequest;
import dev.langchain4j.rag.AugmentationResult;
import dev.langchain4j.rag.DefaultRetrievalAugmentor;
import dev.langchain4j.rag.RetrievalAugmentor;
import dev.langchain4j.rag.content.Content;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.rag.query.Metadata;
import dev.langchain4j.rag.query.Query;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.chat.enums.RoleType;
import org.ruoyi.common.chat.service.chat.IChatModelService;
import org.ruoyi.common.core.utils.StringUtils;
import org.ruoyi.domain.vo.agent.AgentVo;
import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo;
import org.ruoyi.factory.ChatServiceFactory;
import org.ruoyi.service.agent.IAgentService;
import org.ruoyi.service.chat.IChatMessageService;
import org.ruoyi.service.knowledge.IKnowledgeInfoService;
import org.ruoyi.service.knowledge.retriever.CustomVectorRetriever;
import org.ruoyi.service.retrieval.KnowledgeRetrievalService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.AbstractWebSocketHandler;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* 小程序对话 WebSocket 处理器。
* <p>
* 收到前端 JSON 消息后:解析模型(智能体绑定 / 前端传入 / 默认兜底)→
* 拼装 systemPrompt 与 RAG 增强后的 content → 调用 StreamingChatModel 流式生成 →
* 将增量 token 通过当前 WS session 回推前端。
* <p>
* 输出协议(与前端 index.vue 现有接收逻辑兼容):
* <ul>
* <li>增量:<code>{"content":"token片段"}</code></li>
* <li>结束:<code>[DONE]</code></li>
* <li>错误:<code>{"data":"错误:xxx"}</code></li>
* </ul>
*
* @author ruoyi team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MpChatWebSocketHandler extends AbstractWebSocketHandler {
private final ChatServiceFactory chatServiceFactory;
private final IChatModelService chatModelService;
private final IAgentService agentService;
private final IKnowledgeInfoService knowledgeInfoService;
private final KnowledgeRetrievalService knowledgeRetrievalService;
private final IChatMessageService chatMessageService;
private final ObjectMapper objectMapper;
@Value("${chat.default-model:}")
private String defaultModel;
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
Map<String, Object> payload;
try {
payload = objectMapper.readValue(message.getPayload(), Map.class);
} catch (Exception e) {
sendError(session, "错误:消息格式不正确");
return;
}
String content = asString(payload.get("content"));
String agentIdRaw = asString(payload.get("agentId"));
String model = asString(payload.get("model"));
String systemPrompt = asString(payload.get("systemPrompt"));
String knowledgeId = asString(payload.get("knowledgeId"));
String sessionIdRaw = asString(payload.get("sessionId"));
if (StringUtils.isBlank(content)) {
sendError(session, "错误:对话消息不能为空");
return;
}
Long userId = (Long) session.getAttributes().get(MpChatHandshakeInterceptor.USER_ID_KEY);
Long sessionId = parseLong(sessionIdRaw);
try {
// 1. 解析智能体(若传了 agentId取其绑定模型与 systemPrompt、知识库
AgentVo agentVo = null;
if (StringUtils.isNotBlank(agentIdRaw)) {
Long agentId = parseLong(agentIdRaw);
if (agentId != null) {
agentVo = agentService.queryById(agentId);
}
}
// 2. 解析模型:智能体绑定 > 前端传入 > 默认配置 > 表内首个 chat 模型
ChatModelVo modelVo = null;
if (agentVo != null && agentVo.getModelId() != null) {
modelVo = chatModelService.queryById(agentVo.getModelId());
}
if (modelVo == null && StringUtils.isNotBlank(model)) {
modelVo = chatModelService.selectModelByName(model);
}
if (modelVo == null) {
modelVo = resolveDefaultModel();
}
if (modelVo == null) {
sendError(session, "错误:未找到可用对话模型,请联系管理员配置");
return;
}
// 3. 拼装最终输入RAG 增强 + systemPrompt 前置
String finalSystemPrompt = (agentVo != null && StringUtils.isNotBlank(agentVo.getSystemPrompt()))
? agentVo.getSystemPrompt() : systemPrompt;
String augmentedContent = augmentWithKnowledge(content, agentVo, knowledgeId);
String finalContent = StringUtils.isNotBlank(finalSystemPrompt)
? finalSystemPrompt + "\n\n" + augmentedContent : augmentedContent;
// 4. 落库用户消息(仅在具备用户与会话标识时)
if (userId != null && sessionId != null) {
try {
chatMessageService.saveChatMessage(userId, sessionId, content,
RoleType.USER.getName(), modelVo.getModelName());
} catch (Exception e) {
log.warn("落库用户消息失败: {}", e.getMessage());
}
}
// 5. 构造流式模型并异步生成
ChatRequest chatRequest = new ChatRequest();
chatRequest.setContent(content);
chatRequest.setModel(modelVo.getModelName());
chatRequest.setKnowledgeId(knowledgeId);
StreamingChatModel streamingModel = chatServiceFactory
.getOriginalService(modelVo.getProviderCode())
.buildStreamingChatModel(modelVo, chatRequest);
final String modelName = modelVo.getModelName();
CompletableFuture.runAsync(() -> {
StringBuilder buffer = new StringBuilder();
// 是否已向前端发送 [DONE] 结束标记,避免重复发送或遗漏
boolean[] doneSent = {false};
StreamingChatResponseHandler handler = new StreamingChatResponseHandler() {
@Override
public void onPartialResponse(String partialResponse) {
buffer.append(partialResponse);
sendJson(session, Map.of("content", partialResponse));
}
@Override
public void onCompleteResponse(ChatResponse completeResponse) {
if (!doneSent[0]) {
doneSent[0] = true;
sendRaw(session, "[DONE]");
}
if (userId != null && sessionId != null && buffer.length() > 0) {
try {
chatMessageService.saveChatMessage(userId, sessionId, buffer.toString(),
RoleType.ASSISTANT.getName(), modelName);
} catch (Exception e) {
log.warn("落库助手回复失败: {}", e.getMessage());
}
}
}
@Override
public void onError(Throwable error) {
if (buffer.length() == 0) {
// 一点内容都没输出就出错:向前端报错
sendError(session, "错误:" + safeMsg(error));
} else if (!doneSent[0]) {
// 已有部分内容但流式中途异常:补发 [DONE] 让前端正常收尾,不报错
doneSent[0] = true;
sendRaw(session, "[DONE]");
log.warn("mp-chat 流式中途异常(已输出内容,补发 [DONE]: {}", safeMsg(error));
} else {
// onComplete 后的收尾异常:回复已正常结束,静默
log.warn("mp-chat 流式收尾异常(已结束,忽略): {}", safeMsg(error));
}
}
};
try {
streamingModel.chat(finalContent, handler);
} catch (Exception e) {
log.error("mp-chat 调用模型失败", e);
sendError(session, "错误:" + safeMsg(e));
}
});
} catch (Exception e) {
log.error("mp-chat 处理消息失败", e);
sendError(session, "错误:" + safeMsg(e));
}
}
/**
* 智能体绑定知识库 / 前端传入 knowledgeId 时,对 content 做向量检索增强。
* 复用 ChatServiceFacade.buildMultiKnowledgeAugmentor 的组装方式(简化为多库复合检索)。
*/
private String augmentWithKnowledge(String content, AgentVo agentVo, String knowledgeId) {
List<Long> kids = new ArrayList<>();
if (agentVo != null && agentVo.getKnowledgeIds() != null) {
kids.addAll(agentVo.getKnowledgeIds());
}
if (StringUtils.isBlank(knowledgeId) && kids.isEmpty()) {
return content;
}
if (StringUtils.isNotBlank(knowledgeId)) {
try {
kids.add(Long.valueOf(knowledgeId));
} catch (NumberFormatException ignored) {
}
}
if (kids.isEmpty()) {
return content;
}
try {
RetrievalAugmentor augmentor = buildMultiKnowledgeAugmentor(kids);
if (augmentor == null) {
return content;
}
UserMessage userMessage = UserMessage.userMessage(content);
Metadata metadata = Metadata.from(userMessage, null, new ArrayList<>());
AugmentationResult result = augmentor.augment(new AugmentationRequest(userMessage, metadata));
ChatMessage augmented = result.chatMessage();
return augmented instanceof UserMessage ? ((UserMessage) augmented).singleText() : content;
} catch (Exception e) {
log.warn("mp-chat RAG 增强失败,回退原文: {}", e.getMessage());
return content;
}
}
private RetrievalAugmentor buildMultiKnowledgeAugmentor(List<Long> knowledgeIds) {
List<ContentRetriever> retrievers = new ArrayList<>();
for (Long kid : knowledgeIds) {
try {
KnowledgeInfoVo kb = knowledgeInfoService.queryById(kid);
if (kb == null) {
continue;
}
ChatModelVo embModel = chatModelService.selectModelByName(kb.getEmbeddingModel());
if (embModel == null) {
log.warn("mp-chat 知识库向量模型未配置: kid={}, emb={}", kid, kb.getEmbeddingModel());
continue;
}
retrievers.add(new CustomVectorRetriever(knowledgeRetrievalService, kb, embModel));
} catch (Exception e) {
log.warn("mp-chat 构建检索器失败: kid={}, err={}", kid, e.getMessage());
}
}
if (retrievers.isEmpty()) {
return null;
}
ContentRetriever composite = retrievers.size() == 1
? retrievers.get(0)
: new CompositeContentRetriever(retrievers);
return DefaultRetrievalAugmentor.builder().contentRetriever(composite).build();
}
/**
* 默认模型兜底:优先用 chat.default-model 配置,其次取表内首个 chat 类模型。
*/
private ChatModelVo resolveDefaultModel() {
if (StringUtils.isNotBlank(defaultModel)) {
ChatModelVo vo = chatModelService.selectModelByName(defaultModel);
if (vo != null) {
return vo;
}
}
try {
List<ChatModelVo> list = chatModelService.queryList(new ChatModelBo());
if (list != null) {
for (ChatModelVo vo : list) {
if ("chat".equalsIgnoreCase(vo.getCategory()) && "Y".equalsIgnoreCase(vo.getModelShow())) {
return vo;
}
}
if (!list.isEmpty()) {
return list.get(0);
}
}
} catch (Exception e) {
log.warn("mp-chat 解析默认模型失败: {}", e.getMessage());
}
return null;
}
// ---------- WS 输出辅助 ----------
private void sendJson(WebSocketSession session, Map<String, ?> data) {
if (!session.isOpen()) {
return;
}
try {
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(data)));
} catch (Exception e) {
log.warn("mp-chat 发送 WS 消息失败: {}", e.getMessage());
}
}
private void sendRaw(WebSocketSession session, String raw) {
if (!session.isOpen()) {
return;
}
try {
session.sendMessage(new TextMessage(raw));
} catch (Exception e) {
log.warn("mp-chat 发送 WS 消息失败: {}", e.getMessage());
}
}
private void sendError(WebSocketSession session, String msg) {
Map<String, Object> err = new HashMap<>();
err.put("data", msg);
sendJson(session, err);
}
private static String safeMsg(Throwable e) {
return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
}
private static String asString(Object o) {
return o == null ? null : String.valueOf(o);
}
private static Long parseLong(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
try {
return Long.valueOf(raw.trim());
} catch (NumberFormatException e) {
return null;
}
}
/**
* 多知识库复合检索器:并发查询各库并合并结果。
* (与 ChatServiceFacade 内部 CompositeContentRetriever 同构,独立保留以解耦公共门面)
*/
private static class CompositeContentRetriever implements ContentRetriever {
private final List<ContentRetriever> delegates;
CompositeContentRetriever(List<ContentRetriever> delegates) {
this.delegates = delegates;
}
@Override
public List<Content> retrieve(Query query) {
List<Content> all = new ArrayList<>();
for (ContentRetriever r : delegates) {
try {
List<Content> part = r.retrieve(query);
if (part != null) {
all.addAll(part);
}
} catch (Exception e) {
log.warn("mp-chat 复合检索子检索器异常: {}", e.getMessage());
}
}
return all;
}
}
}