mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-09-13 00:14:59 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 只做参数绑定 + 同步取 userId(Sa-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) { }
|
||||
}
|
||||
@@ -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。 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -32,4 +32,6 @@ public class ShortDramaCharacterAppearanceBo extends BaseEntity {
|
||||
private String previousImageUrls;
|
||||
|
||||
private String previousDescriptions;
|
||||
|
||||
private String voice;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/** 对白关联的分镜ID(NULL=全局旁白) */
|
||||
private Long linkedStoryboardId;
|
||||
|
||||
/** 音频时长(秒) */
|
||||
private Integer durationSeconds;
|
||||
}
|
||||
@@ -43,4 +43,7 @@ public class ShortDramaCharacterAppearance extends BaseEntity {
|
||||
|
||||
/** 上一轮提示词列表(撤销用,JSON数组) */
|
||||
private String previousDescriptions;
|
||||
|
||||
/** 音色名(如 zh_male_taocheng_uranus_bigtts),用于该形象的对白配音 */
|
||||
private String voice;
|
||||
}
|
||||
|
||||
@@ -58,4 +58,7 @@ public class ShortDramaStoryboard extends BaseEntity {
|
||||
private String videoId;
|
||||
|
||||
private String videoStatus;
|
||||
|
||||
/** 上一镜末帧URL(同场景连续镜头首帧承接用) */
|
||||
private String lastFrameUrl;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -37,6 +37,8 @@ public class ShortDramaCharacterAppearanceVo implements Serializable {
|
||||
|
||||
private String previousDescriptions;
|
||||
|
||||
private String voice;
|
||||
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
|
||||
@@ -20,5 +20,7 @@ public class ShortDramaDetailVo implements Serializable {
|
||||
|
||||
private List<ShortDramaLocationVo> locations;
|
||||
|
||||
private List<ShortDramaAudioVo> audios;
|
||||
|
||||
private List<ShortDramaStoryboardVo> storyboards;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ public class ShortDramaStoryboardVo implements Serializable {
|
||||
|
||||
private String videoStatus;
|
||||
|
||||
private String lastFrameUrl;
|
||||
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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。支持 references(speaker 音色 + 参考音频)做多角色对白配音。
|
||||
*/
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
@@ -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)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
* 先归一化再与主音轨 amix,normalize=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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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-token(LoginHelper.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;
|
||||
}
|
||||
}
|
||||
@@ -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("*");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user