mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-09-13 08:25:00 +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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,6 +45,7 @@ nbdist/
|
|||||||
*.xml.versionsBackup
|
*.xml.versionsBackup
|
||||||
*.swp
|
*.swp
|
||||||
data/
|
data/
|
||||||
|
logs/
|
||||||
|
|
||||||
!*/build/*.java
|
!*/build/*.java
|
||||||
!*/build/*.html
|
!*/build/*.html
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -20,6 +20,10 @@ server:
|
|||||||
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
|
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
|
||||||
worker: 256
|
worker: 256
|
||||||
|
|
||||||
|
--- # 小程序对话 WebSocket 兜底默认模型(前端未传 model 且无智能体绑定时使用)
|
||||||
|
chat:
|
||||||
|
default-model: deepseek-v4-flash
|
||||||
|
|
||||||
captcha:
|
captcha:
|
||||||
# 是否启用验证码校验
|
# 是否启用验证码校验
|
||||||
enable: false
|
enable: false
|
||||||
@@ -125,6 +129,7 @@ security:
|
|||||||
- /*/api-docs/**
|
- /*/api-docs/**
|
||||||
- /warm-flow-ui/config
|
- /warm-flow-ui/config
|
||||||
- /workflow/run
|
- /workflow/run
|
||||||
|
- /coding/**
|
||||||
# 多租户配置
|
# 多租户配置
|
||||||
tenant:
|
tenant:
|
||||||
# 是否开启
|
# 是否开启
|
||||||
@@ -335,6 +340,7 @@ short-drama:
|
|||||||
probe-timeout: ${SHORT_DRAMA_COMPOSITION_PROBE_TIMEOUT:30s}
|
probe-timeout: ${SHORT_DRAMA_COMPOSITION_PROBE_TIMEOUT:30s}
|
||||||
process-timeout: ${SHORT_DRAMA_COMPOSITION_PROCESS_TIMEOUT:30m}
|
process-timeout: ${SHORT_DRAMA_COMPOSITION_PROCESS_TIMEOUT:30m}
|
||||||
job-stale-after: ${SHORT_DRAMA_COMPOSITION_JOB_STALE_AFTER:45m}
|
job-stale-after: ${SHORT_DRAMA_COMPOSITION_JOB_STALE_AFTER:45m}
|
||||||
|
watermark-font-file: ${SHORT_DRAMA_COMPOSITION_WATERMARK_FONT_FILE:}
|
||||||
worker-core-size: ${SHORT_DRAMA_COMPOSITION_WORKER_CORE_SIZE:1}
|
worker-core-size: ${SHORT_DRAMA_COMPOSITION_WORKER_CORE_SIZE:1}
|
||||||
worker-max-size: ${SHORT_DRAMA_COMPOSITION_WORKER_MAX_SIZE:2}
|
worker-max-size: ${SHORT_DRAMA_COMPOSITION_WORKER_MAX_SIZE:2}
|
||||||
worker-queue-capacity: ${SHORT_DRAMA_COMPOSITION_WORKER_QUEUE_CAPACITY:8}
|
worker-queue-capacity: ${SHORT_DRAMA_COMPOSITION_WORKER_QUEUE_CAPACITY:8}
|
||||||
|
|||||||
@@ -26,4 +26,19 @@ public class AudioContext {
|
|||||||
private Double speed;
|
private Double speed;
|
||||||
|
|
||||||
private String instructions;
|
private String instructions;
|
||||||
|
|
||||||
|
/** Atlas seed-audio 参考资源:[{speaker, audioUrl, audioData, imageData}],speaker 为音色名 */
|
||||||
|
private java.util.List<java.util.Map<String, String>> references;
|
||||||
|
|
||||||
|
/** Atlas seed-audio 采样率 */
|
||||||
|
private Integer sampleRate;
|
||||||
|
|
||||||
|
/** Atlas seed-audio 音调调整 (-12~12) */
|
||||||
|
private Integer pitchRate;
|
||||||
|
|
||||||
|
/** Atlas seed-audio 语速调整 (-50~100) */
|
||||||
|
private Integer speechRate;
|
||||||
|
|
||||||
|
/** Atlas seed-audio 响度调整 (-50~100) */
|
||||||
|
private Integer loudnessRate;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,4 +25,7 @@ public class MediaGenerationResponse {
|
|||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
private String rawResponse;
|
private String rawResponse;
|
||||||
|
|
||||||
|
/** 末帧图片 URL(Atlas return_last_frame=true 时返回,用于下一镜首帧承接) */
|
||||||
|
private String lastFrameUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,17 @@ public class VideoContext {
|
|||||||
/** 多参考图 URL(多参考图生视频模式,配合 @imageN prompt 使用) */
|
/** 多参考图 URL(多参考图生视频模式,配合 @imageN prompt 使用) */
|
||||||
private java.util.List<String> referenceImages;
|
private java.util.List<String> referenceImages;
|
||||||
|
|
||||||
|
/** 参考音频 URL 列表(对白口型对齐模式) */
|
||||||
|
private java.util.List<String> referenceAudios;
|
||||||
|
|
||||||
|
/** 是否让模型生成同步音频(环境音/动效) */
|
||||||
|
private Boolean generateAudio;
|
||||||
|
|
||||||
|
/** 是否要求返回末帧,用于下一镜首帧承接 */
|
||||||
|
private Boolean returnLastFrame;
|
||||||
|
|
||||||
|
/** 上一镜末帧 URL(同场景连续镜头首帧承接用,作为额外参考图传入) */
|
||||||
|
private String lastFrameUrl;
|
||||||
|
|
||||||
private String videoId;
|
private String videoId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,14 @@
|
|||||||
<artifactId>ruoyi-common-sse</artifactId>
|
<artifactId>ruoyi-common-sse</artifactId>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>org.ruoyi</groupId>
|
<groupId>org.ruoyi</groupId>
|
||||||
<artifactId>ruoyi-common-sensitive</artifactId>
|
<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.ShortDramaCharacterBo;
|
||||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo;
|
import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo;
|
||||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaComposeVideoBo;
|
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.ShortDramaLocationBo;
|
||||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo;
|
import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo;
|
||||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo;
|
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.ShortDramaCharacterAppearanceVo;
|
||||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo;
|
import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo;
|
||||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo;
|
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.ShortDramaLocationVo;
|
||||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaProjectVo;
|
import org.ruoyi.domain.vo.shortdrama.ShortDramaProjectVo;
|
||||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo;
|
import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo;
|
||||||
@@ -340,6 +342,35 @@ public class ShortDramaController {
|
|||||||
return R.ok(shortDramaService.undoLocationImage(locationId, LoginHelper.getUserId()));
|
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。 */
|
/** 上传本地照片到图片供应商,返回当前生成会话使用的临时 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 previousImageUrls;
|
||||||
|
|
||||||
private String previousDescriptions;
|
private String previousDescriptions;
|
||||||
|
|
||||||
|
private String voice;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,21 @@ public class ShortDramaComposeVideoBo {
|
|||||||
|
|
||||||
@NotBlank(message = "转场类型不能为空")
|
@NotBlank(message = "转场类型不能为空")
|
||||||
@Pattern(regexp = "none|dissolve|fade|slide", message = "不支持的转场类型")
|
@Pattern(regexp = "none|dissolve|fade|slide", message = "不支持的转场类型")
|
||||||
private String transitionType = "dissolve";
|
private String transitionType = "fade";
|
||||||
|
|
||||||
@NotNull(message = "转场时长不能为空")
|
@NotNull(message = "转场时长不能为空")
|
||||||
@DecimalMin(value = "0.0", message = "转场时长不能小于0秒")
|
@DecimalMin(value = "0.0", message = "转场时长不能小于0秒")
|
||||||
private BigDecimal transitionDurationSeconds = new BigDecimal("0.5");
|
private BigDecimal transitionDurationSeconds = new BigDecimal("0.3");
|
||||||
|
|
||||||
@NotBlank(message = "成片画幅不能为空")
|
@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";
|
private String aspectRatio = "9:16";
|
||||||
@Size(min = 2, message = "至少选择2个分镜视频")
|
@Size(min = 2, message = "至少选择2个分镜视频")
|
||||||
private List<Long> storyboardIds;
|
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数组) */
|
/** 上一轮提示词列表(撤销用,JSON数组) */
|
||||||
private String previousDescriptions;
|
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 videoId;
|
||||||
|
|
||||||
private String videoStatus;
|
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 previousDescriptions;
|
||||||
|
|
||||||
|
private String voice;
|
||||||
|
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
|||||||
@@ -20,5 +20,7 @@ public class ShortDramaDetailVo implements Serializable {
|
|||||||
|
|
||||||
private List<ShortDramaLocationVo> locations;
|
private List<ShortDramaLocationVo> locations;
|
||||||
|
|
||||||
|
private List<ShortDramaAudioVo> audios;
|
||||||
|
|
||||||
private List<ShortDramaStoryboardVo> storyboards;
|
private List<ShortDramaStoryboardVo> storyboards;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ public class ShortDramaStoryboardVo implements Serializable {
|
|||||||
|
|
||||||
private String videoStatus;
|
private String videoStatus;
|
||||||
|
|
||||||
|
private String lastFrameUrl;
|
||||||
|
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
private Date updateTime;
|
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 dev.langchain4j.agent.tool.Tool;
|
||||||
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
|
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 org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -10,8 +13,6 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 编辑文件工具
|
* 编辑文件工具
|
||||||
@@ -20,16 +21,26 @@ import java.util.List;
|
|||||||
@Component
|
@Component
|
||||||
public class EditFileTool implements BuiltinToolProvider {
|
public class EditFileTool implements BuiltinToolProvider {
|
||||||
|
|
||||||
public static final String DESCRIPTION = "Edits a file by applying a diff. " +
|
public static final String DESCRIPTION = "Edits an existing file by replacing its full content. " +
|
||||||
"Use this tool when you need to make specific changes to a file. " +
|
"ALWAYS read the file first with read_file, then provide the COMPLETE new content here. " +
|
||||||
"The tool will show the diff before applying changes. " +
|
|
||||||
"Use absolute paths within the workspace directory.";
|
"Use absolute paths within the workspace directory.";
|
||||||
|
|
||||||
private final String rootDirectory;
|
private final String rootDirectory;
|
||||||
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
|
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
|
||||||
|
/** 编程能力 SSE 事件通道,可为 null(兼容无参构造的老调用方) */
|
||||||
|
private final CodingEventChannel channel;
|
||||||
|
|
||||||
public EditFileTool() {
|
public EditFileTool() {
|
||||||
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
|
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;
|
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;
|
return "Error: Path is a directory, not a file: " + filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取原始内容
|
// 推送编辑开始事件
|
||||||
String originalContent = Files.readString(path, StandardCharsets.UTF_8);
|
String relativePath = getRelativePath(path);
|
||||||
List<String> originalLines = Arrays.asList(originalContent.split("\n"));
|
if (channel != null) {
|
||||||
|
channel.send(CodingSseEvent.of("edit-start", filePath, null, null, "running"));
|
||||||
|
}
|
||||||
|
|
||||||
// 应用diff
|
// 应用diff(简化:整体替换为新内容)
|
||||||
try {
|
try {
|
||||||
// 这里简化处理,直接用新内容替换
|
String newContent = applyDiff(null, diff);
|
||||||
// 在实际应用中,可能需要更复杂的diff解析
|
|
||||||
String newContent = applyDiff(originalContent, diff);
|
|
||||||
|
|
||||||
// 写入文件
|
|
||||||
Files.writeString(path, newContent, StandardCharsets.UTF_8,
|
Files.writeString(path, newContent, StandardCharsets.UTF_8,
|
||||||
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
|
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);
|
return String.format("Successfully edited file: %s", relativePath);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} 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();
|
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) {
|
} catch (Exception e) {
|
||||||
logger.error("Unexpected error editing file: {}", filePath, e);
|
logger.error("Unexpected error editing file: {}", filePath, e);
|
||||||
return "Error: Unexpected error: " + e.getMessage();
|
return "Error: Unexpected error: " + e.getMessage();
|
||||||
@@ -115,14 +127,7 @@ public class EditFileTool implements BuiltinToolProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isWithinWorkspace(Path filePath) {
|
private boolean isWithinWorkspace(Path filePath) {
|
||||||
try {
|
return WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), filePath);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getRelativePath(Path 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 dev.langchain4j.agent.tool.Tool;
|
||||||
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
|
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 org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -30,9 +33,20 @@ public class ListDirectoryTool implements BuiltinToolProvider {
|
|||||||
|
|
||||||
private final String rootDirectory;
|
private final String rootDirectory;
|
||||||
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
|
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
|
||||||
|
/** 编程能力 SSE 事件通道,可为 null(兼容无参构造的老调用方) */
|
||||||
|
private final CodingEventChannel channel;
|
||||||
|
|
||||||
public ListDirectoryTool() {
|
public ListDirectoryTool() {
|
||||||
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
|
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;
|
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);
|
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) {
|
} catch (IOException e) {
|
||||||
logger.error("Error listing directory: {}", params.filePath, e);
|
logger.error("Error listing directory: {}", params.filePath, e);
|
||||||
@@ -233,14 +259,7 @@ public class ListDirectoryTool implements BuiltinToolProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isWithinWorkspace(Path dirPath) {
|
private boolean isWithinWorkspace(Path dirPath) {
|
||||||
try {
|
return WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), dirPath);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getRelativePath(Path dirPath) {
|
private String getRelativePath(Path dirPath) {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package org.ruoyi.mcp.tools;
|
|||||||
|
|
||||||
import dev.langchain4j.agent.tool.Tool;
|
import dev.langchain4j.agent.tool.Tool;
|
||||||
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
|
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 org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -22,10 +25,26 @@ public class ReadFileTool implements BuiltinToolProvider {
|
|||||||
"Returns the complete file content as a string.";
|
"Returns the complete file content as a string.";
|
||||||
|
|
||||||
private final String rootDirectory;
|
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());
|
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
|
||||||
|
/** 编程能力 SSE 事件通道,可为 null(兼容 BuiltinToolRegistry 无参构造的老调用方) */
|
||||||
|
private final CodingEventChannel channel;
|
||||||
|
|
||||||
public ReadFileTool() {
|
public ReadFileTool() {
|
||||||
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
|
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;
|
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;
|
return "Error: Path is a directory, not a file: " + filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取文件内容
|
// 推送读取开始事件(前端展示为正在读取)
|
||||||
String content = Files.readString(path, StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
// 获取相对路径
|
|
||||||
String relativePath = getRelativePath(path);
|
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 sizeBytes = content.getBytes(StandardCharsets.UTF_8).length;
|
||||||
long lineCount = content.lines().count();
|
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",
|
if (channel != null) {
|
||||||
relativePath, lineCount, sizeBytes, content);
|
channel.send(CodingSseEvent.of("edit-end", filePath, null, null, "done"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return header + content;
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("Error reading file: {}", filePath, e);
|
logger.error("Error reading file: {}", filePath, e);
|
||||||
@@ -85,14 +119,7 @@ public class ReadFileTool implements BuiltinToolProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isWithinWorkspace(Path filePath) {
|
private boolean isWithinWorkspace(Path filePath) {
|
||||||
try {
|
return WorkspaceGuard.isWithinWorkspace(Paths.get(rootDirectory), filePath);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getRelativePath(Path 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);
|
JsonNode value = node == null ? null : node.get(field);
|
||||||
return value == null || value.isNull() ? null : value.asText();
|
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 {
|
public MediaGenerationResponse toResponse(String raw, String type) throws IOException {
|
||||||
JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(raw);
|
JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(raw);
|
||||||
JsonNode data = root.path("data");
|
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()
|
return MediaGenerationResponse.builder()
|
||||||
.type(type)
|
.type(type)
|
||||||
.mimeType("image".equals(type) ? "image/png" : "video/mp4")
|
.mimeType("image".equals(type) ? "image/png" : "video/mp4")
|
||||||
.id(AtlasMediaSupport.text(data, "id"))
|
.id(AtlasMediaSupport.text(data, "id"))
|
||||||
.status(AtlasMediaSupport.text(data, "status"))
|
.status(status)
|
||||||
.url(firstOutput(data))
|
.url(firstOutput(data))
|
||||||
|
.lastFrameUrl(lastFrameUrl)
|
||||||
.rawResponse(raw)
|
.rawResponse(raw)
|
||||||
.build();
|
.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) {
|
private String firstOutput(JsonNode data) {
|
||||||
JsonNode outputs = data.path("outputs");
|
JsonNode outputs = data.path("outputs");
|
||||||
if (outputs.isArray() && !outputs.isEmpty()) {
|
if (outputs.isArray() && !outputs.isEmpty()) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.ruoyi.service.shortdrama;
|
package org.ruoyi.service.shortdrama;
|
||||||
|
|
||||||
import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
|
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.ShortDramaCharacterBo;
|
||||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo;
|
import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo;
|
||||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo;
|
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.ShortDramaScriptBo;
|
||||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo;
|
import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo;
|
||||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo;
|
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.ShortDramaCharacterVo;
|
||||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo;
|
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo;
|
||||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo;
|
import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo;
|
||||||
@@ -113,4 +115,15 @@ public interface IShortDramaService {
|
|||||||
ShortDramaLocationVo confirmLocationImage(Long locationId, String predictionId, String model, Long userId);
|
ShortDramaLocationVo confirmLocationImage(Long locationId, String predictionId, String model, Long userId);
|
||||||
|
|
||||||
Boolean deleteProject(Long projectId, 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 {
|
public enum AspectRatio {
|
||||||
PORTRAIT("9:16", 1080, 1920),
|
PORTRAIT("9:16", 1080, 1920),
|
||||||
LANDSCAPE("16:9", 1920, 1080),
|
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 String value;
|
||||||
private final int width;
|
private final int width;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.ruoyi.service.shortdrama.composition;
|
package org.ruoyi.service.shortdrama.composition;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@@ -7,7 +8,9 @@ public record CompositionSpec(
|
|||||||
List<CompositionSource> sources,
|
List<CompositionSource> sources,
|
||||||
TransitionType transitionType,
|
TransitionType transitionType,
|
||||||
double transitionDurationSeconds,
|
double transitionDurationSeconds,
|
||||||
AspectRatio aspectRatio
|
AspectRatio aspectRatio,
|
||||||
|
Path narrationAudioPath,
|
||||||
|
boolean watermark
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public CompositionSpec {
|
public CompositionSpec {
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ public class FfmpegCommandBuilder {
|
|||||||
command.add("-i");
|
command.add("-i");
|
||||||
command.add(source.path().toString());
|
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("-filter_complex_script");
|
||||||
command.add(filterScript.toAbsolutePath().normalize().toString());
|
command.add(filterScript.toAbsolutePath().normalize().toString());
|
||||||
command.add("-map");
|
command.add("-map");
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ public class FfmpegCompositionProperties {
|
|||||||
private String storageMode = "local";
|
private String storageMode = "local";
|
||||||
private String localOutputDirectory = "logs/short-drama-compositions";
|
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) {
|
public BigDecimal normalizeTransitionDuration(TransitionType type, BigDecimal requested) {
|
||||||
if (type == null || type == TransitionType.NONE) {
|
if (type == null || type == TransitionType.NONE) {
|
||||||
return BigDecimal.ZERO;
|
return BigDecimal.ZERO;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -42,10 +44,32 @@ public class FfmpegFilterGraphBuilder {
|
|||||||
filters.add(normalizeAudio(index, duration, info.hasAudio()));
|
filters.add(normalizeAudio(index, duration, info.hasAudio()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FfmpegFilterGraph base;
|
||||||
if (spec.transitionType() == TransitionType.NONE) {
|
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) {
|
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) {
|
private String normalizeVideo(int inputIndex, int videoStreamIndex, String duration, VideoCanvas canvas) {
|
||||||
return "[" + inputIndex + ":" + videoStreamIndex + "]"
|
return "[" + inputIndex + ":" + videoStreamIndex + "]"
|
||||||
+ "scale=" + canvas.width() + ":" + canvas.height()
|
+ "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 org.ruoyi.service.shortdrama.composition.TransitionType;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@@ -14,7 +15,10 @@ record ShortDramaVideoComposeJob(
|
|||||||
TransitionType transitionType,
|
TransitionType transitionType,
|
||||||
BigDecimal transitionDurationSeconds,
|
BigDecimal transitionDurationSeconds,
|
||||||
AspectRatio aspectRatio,
|
AspectRatio aspectRatio,
|
||||||
List<Long> storyboardIds
|
List<Long> storyboardIds,
|
||||||
|
Long narrationAudioId,
|
||||||
|
Path narrationAudioPath,
|
||||||
|
boolean watermark
|
||||||
) {
|
) {
|
||||||
|
|
||||||
ShortDramaVideoComposeJob {
|
ShortDramaVideoComposeJob {
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ public class ShortDramaVideoComposeServiceImpl implements IShortDramaVideoCompos
|
|||||||
transitionType,
|
transitionType,
|
||||||
transitionDuration,
|
transitionDuration,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
bo.getStoryboardIds()
|
bo.getStoryboardIds(),
|
||||||
|
bo.getNarrationAudioId(),
|
||||||
|
null,
|
||||||
|
resolveWatermark(bo.getWatermark())
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
composeWorker.composeAsync(job);
|
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) {
|
private Date staleBefore(Date now) {
|
||||||
Duration staleAfter = compositionProperties.getJobStaleAfter();
|
Duration staleAfter = compositionProperties.getJobStaleAfter();
|
||||||
if (staleAfter == null || staleAfter.isZero() || staleAfter.isNegative()) {
|
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.service.OssService;
|
||||||
import org.ruoyi.common.core.utils.file.FileUtils;
|
import org.ruoyi.common.core.utils.file.FileUtils;
|
||||||
import org.ruoyi.common.tenant.helper.TenantHelper;
|
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.ShortDramaProject;
|
||||||
import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard;
|
import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard;
|
||||||
|
import org.ruoyi.mapper.shortdrama.ShortDramaAudioMapper;
|
||||||
import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper;
|
import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper;
|
||||||
import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper;
|
import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper;
|
||||||
import org.ruoyi.service.shortdrama.composition.CompositionArtifact;
|
import org.ruoyi.service.shortdrama.composition.CompositionArtifact;
|
||||||
@@ -41,6 +43,7 @@ public class ShortDramaVideoComposeWorker {
|
|||||||
|
|
||||||
private final ShortDramaProjectMapper projectMapper;
|
private final ShortDramaProjectMapper projectMapper;
|
||||||
private final ShortDramaStoryboardMapper storyboardMapper;
|
private final ShortDramaStoryboardMapper storyboardMapper;
|
||||||
|
private final ShortDramaAudioMapper audioMapper;
|
||||||
private final FfmpegVideoComposer videoComposer;
|
private final FfmpegVideoComposer videoComposer;
|
||||||
private final FfmpegCompositionProperties compositionProperties;
|
private final FfmpegCompositionProperties compositionProperties;
|
||||||
private final SafeVideoSourceDownloader sourceDownloader;
|
private final SafeVideoSourceDownloader sourceDownloader;
|
||||||
@@ -65,11 +68,16 @@ public class ShortDramaVideoComposeWorker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 旁白语音资产下载到工作目录(ossId → 本地文件)
|
||||||
|
Path narrationAudioPath = downloadNarration(job, workDirectory);
|
||||||
|
|
||||||
CompositionArtifact artifact = videoComposer.compose(new CompositionSpec(
|
CompositionArtifact artifact = videoComposer.compose(new CompositionSpec(
|
||||||
sources,
|
sources,
|
||||||
job.transitionType(),
|
job.transitionType(),
|
||||||
job.transitionDurationSeconds().doubleValue(),
|
job.transitionDurationSeconds().doubleValue(),
|
||||||
job.aspectRatio()
|
job.aspectRatio(),
|
||||||
|
narrationAudioPath,
|
||||||
|
job.watermark()
|
||||||
), workDirectory);
|
), workDirectory);
|
||||||
if (!updateProgress(job, 85)) {
|
if (!updateProgress(job, 85)) {
|
||||||
return;
|
return;
|
||||||
@@ -198,6 +206,24 @@ public class ShortDramaVideoComposeWorker {
|
|||||||
.set(ShortDramaProject::getUpdateTime, new Date())) > 0;
|
.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) {
|
private boolean isActive(ShortDramaVideoComposeJob job) {
|
||||||
return selectActiveProject(job) != null;
|
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());
|
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()
|
Request request = new Request.Builder()
|
||||||
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateVideo"))
|
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateVideo"))
|
||||||
.addHeader("Authorization", "Bearer " + model.getApiKey())
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user