feat: 新增短剧/媒体生成能力并脱敏数据库脚本

- 新增音频/视频/图片生成服务与 Atlas、OpenAI 实现
- 新增短剧脚本生成、分镜与 ffmpeg 合成模块
- 新增 Coze/Dify 聊天 provider 及 Agent 配置
- 脱敏 SQL 脚本:移除真实 DeepSeek API Key,掩码 COS 桶名
- 清理过期的 SQL update 脚本与 chat 文档

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ageerle@163.com
2026-07-14 21:49:03 +08:00
parent 7aa67cfc2f
commit 2ae13aafd4
132 changed files with 11949 additions and 977 deletions

View File

@@ -0,0 +1,51 @@
package org.ruoyi.agent;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptResult;
/**
* 短剧剧本打磨 Agent —— 使用 langchain4j AiServices 结构化输出
* <p>
* 框架自动生成 JSON Schema 并强制 LLM 返回符合结构的数据,无需手工 parse。
*
* @author ageerle
*/
public interface ShortDramaScriptAgent {
@SystemMessage("""
你是顶级短剧编剧和创意总监。请根据用户的一个创意想法,创作完整的短剧剧本。
【核心原则 - 最高优先级】
1. 剧本必须完整、有张力、有画面感
2. 角色要有鲜明性格,不是工具人
3. 情节紧凑,每句台词都推动剧情
4. 场景描写具体,让分镜师能直接画出画面
5. 对话自然有力,避免废话
【剧本格式】
使用标准剧本格式,包含以下元素:
1. 场景头(Scene Heading):内景/外景+地点+时间,如"内景 客厅 清晨"
2. 场景描述(Scene Description):简洁描述场景环境、布局、关键道具
3. 动作描述(Action):描述角色的动作、表情、行为,连续段落形式
4. 对话(Dialogue):角色名: 台词内容
5. 画外音(Voiceover):旁白、独白、回忆中的声音
【剧本长度要求】
- scriptText1000-3000字含完整的开场、冲突发展、高潮、结尾
- outlineText400-800字概述完整故事线
【角色塑造要求】
- 每个角色要有明确的性格标签(如:霸道总裁、温柔女医、腹黑谋士)
- 角色之间要有清晰的关系和冲突
- 对话要符合角色性格
【情节要求】
- 必须有清晰的冲突和反转
- 情绪节奏要有起伏(紧张→舒缓→爆发)
- 结尾要有记忆点(反转/留白/情感升华)
""")
@UserMessage("用户期望项目名:{{projectName}}\n用户创意{{idea}}")
ShortDramaScriptResult polish(@V("projectName") String projectName, @V("idea") String idea);
}

View File

@@ -0,0 +1,32 @@
package org.ruoyi.config.agent;
import java.nio.file.Path;
/**
* 磁盘 Skills 目录路径解析器
* <p>
* langchain4j 的 ShellSkills 通过 FileSystemSkillLoader 从磁盘加载 SKILL.md
* 路径硬编码在 ChatServiceFacade 中。抽到此工具类供智能体管理端与聊天流程共用,
* 避免两处路径漂移。
*
* @author ruoyi team
*/
public final class SkillsPathResolver {
private SkillsPathResolver() {
}
/**
* skills 目录相对项目根目录的路径
*/
private static final String SKILLS_RELATIVE_PATH = "ruoyi-admin/src/main/resources/skills";
/**
* 返回磁盘 skills 目录的绝对路径
*/
public static Path resolveSkillsPath() {
String userDir = System.getProperty("user.dir");
return Path.of(userDir, SKILLS_RELATIVE_PATH);
}
}

View File

@@ -0,0 +1,72 @@
package org.ruoyi.constant;
/**
* 短剧图片资产常量 — 三视图 prompt 工程
*
* @author ruoyi
*/
public final class ShortDramaImageConstants {
private ShortDramaImageConstants() {}
/**
* 角色三视图 prompt 前缀,生图时自动拼到用户 prompt 之前。
* 把风格和构图指令放最前面,避免被角色描述稀释导致画风漂移。
*/
public static final String CHARACTER_PROMPT_PREFIX =
"character design sheet, multiple views reference sheet, " +
"front view / side view / back view full body, " +
"clean white background, no props, no text. ";
/**
* 角色三视图 prompt 后缀,生图时自动追加到用户 prompt 之后。
* 左侧1/3正面特写 + 右侧2/3三视图横向排列正面全身、侧面全身、背面全身
*/
public static final String CHARACTER_PROMPT_SUFFIX =
"。角色设定图,画面分为左右两个区域:" +
"【左侧区域】占约1/3宽度是角色的正面特写" +
"(完整正脸,最具辨识度的正面形态);" +
"【右侧区域】占约2/3宽度是角色三视图横向排列" +
"(从左到右依次为:正面全身、侧面全身、背面全身)," +
"三视图高度一致。纯白色背景,无其他元素。";
/** 场景图 prompt 前缀 */
public static final String LOCATION_PROMPT_PREFIX = "宽广空间全景,";
/** 场景图 prompt 后缀 */
public static final String LOCATION_PROMPT_SUFFIX = ",禁止出现任何角色,纯背景板";
/** 每个资产最多保留的图片变体数量 */
public static final int MAX_IMAGE_VARIANTS = 20;
// ==================== 视觉风格 ====================
/** 项目视觉风格 → 生图 prompt 后缀映射,确保同项目所有图片风格一致 */
public static final java.util.Map<String, String> ART_STYLE_PROMPTS = java.util.Map.of(
"american-comic", "美式漫画风格,粗线条,高饱和度色彩,强烈光影对比",
"chinese-comic", "现代国漫动画风格Chinese donghua 2D comic style赛璐璐平涂上色干净锐利的黑色线稿平面化光影无真实景深动漫人物比例略放大双眼、修长身形皮肤平滑无毛孔无写实肤质国风服饰剪裁与材质细节清晰色彩饱满通透画面精致干净禁止真人写实、摄影实拍、3D渲染、CGI、厚涂油画、写实皮肤纹理、景深虚化",
"japanese-anime", "现代日系动漫风格赛璐璐上色清晰干净的线条视觉小说CG感高质量2D风格",
"realistic", "真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感"
);
/** 默认视觉风格 */
public static final String DEFAULT_ART_STYLE = "realistic";
/** 查找 artStyle 对应的 prompt 后缀,找不到返回空字符串 */
public static String artStylePrompt(String artStyle) {
if (artStyle == null) return "";
return ART_STYLE_PROMPTS.getOrDefault(artStyle, "");
}
/**
* 由项目 id 派生一个稳定的生图随机种子。
* 同项目内所有角色/形象共用同一颗种子,渲染基调(光影、配色、笔触)更趋一致;
* 不同项目派生不同种子,避免跨项目撞图。返回值落在 [0, 2_000_000_000),兼容各供应商。
*/
public static Integer styleSeed(Long projectId) {
if (projectId == null) return null;
long h = projectId;
h ^= (h >>> 32);
return (int) Math.floorMod(h * 2654435761L, 2_000_000_000L);
}
}

View File

@@ -0,0 +1,130 @@
package org.ruoyi.controller.agent;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.common.excel.utils.ExcelUtil;
import org.ruoyi.common.idempotent.annotation.RepeatSubmit;
import org.ruoyi.common.log.annotation.Log;
import org.ruoyi.common.log.enums.BusinessType;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.common.web.core.BaseController;
import org.ruoyi.domain.bo.agent.AgentBo;
import org.ruoyi.domain.vo.agent.AgentVo;
import org.ruoyi.domain.vo.agent.SkillOptionVo;
import org.ruoyi.service.agent.IAgentService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 智能体管理 Controller
*
* @author ruoyi team
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/agent/agent")
public class AgentController extends BaseController {
private final IAgentService agentService;
/**
* 分页查询智能体列表
*/
@SaCheckPermission("agent:agent:list")
@GetMapping("/list")
public TableDataInfo<AgentVo> list(AgentBo bo, PageQuery pageQuery) {
return agentService.queryPageList(bo, pageQuery);
}
/**
* 查询智能体列表(不分页,用于导出)
*/
@SaCheckPermission("agent:agent:list")
@GetMapping("/queryList")
public R<List<AgentVo>> queryList(AgentBo bo) {
return R.ok(agentService.queryList(bo));
}
/**
* 导出智能体列表
*/
@SaCheckPermission("agent:agent:export")
@Log(title = "智能体管理", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(AgentBo bo, HttpServletResponse response) {
List<AgentVo> list = agentService.queryList(bo);
ExcelUtil.exportExcel(list, "智能体", AgentVo.class, response);
}
/**
* 根据ID获取智能体详情
*/
@SaCheckPermission("agent:agent:query")
@GetMapping("/{id}")
public R<AgentVo> getInfo(@PathVariable Long id) {
return R.ok(agentService.queryById(id));
}
/**
* 新增智能体
*/
@SaCheckPermission("agent:agent:add")
@Log(title = "智能体管理", businessType = BusinessType.INSERT)
@RepeatSubmit
@PostMapping
public R<Void> add(@Validated @RequestBody AgentBo bo) {
return toAjax(agentService.insertByBo(bo));
}
/**
* 修改智能体
*/
@SaCheckPermission("agent:agent:edit")
@Log(title = "智能体管理", businessType = BusinessType.UPDATE)
@RepeatSubmit
@PutMapping
public R<Void> edit(@Validated @RequestBody AgentBo bo) {
return toAjax(agentService.updateByBo(bo));
}
/**
* 删除智能体
*/
@SaCheckPermission("agent:agent:remove")
@Log(title = "智能体管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public R<Void> remove(@PathVariable Long[] ids) {
return toAjax(agentService.deleteByIds(List.of(ids)));
}
/**
* 用户端聊天页智能体下拉选项(启用状态,不需权限校验)
*/
@GetMapping("/agentOptions")
public R<List<AgentVo>> agentOptions() {
return R.ok(agentService.queryEnabledOptions());
}
/**
* 列出磁盘上可用的 Skills供管理端表单勾选
*/
@SaCheckPermission("agent:agent:list")
@GetMapping("/skillOptions")
public R<List<SkillOptionVo>> skillOptions() {
return R.ok(agentService.listSkillOptions());
}
}

View File

@@ -9,6 +9,7 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
import org.ruoyi.common.chat.service.chat.IChatModelService;
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.core.utils.StringUtils;
import org.ruoyi.enums.ChatModeType;
import org.ruoyi.enums.ModelType;
import org.springframework.web.bind.annotation.*;
@@ -54,7 +55,9 @@ public class ChatModelController extends BaseController {
*/
@GetMapping("/modelList")
public R<List<ChatModelVo>> modelList(ChatModelBo bo) {
bo.setCategory(ModelType.CHAT.getKey());
if (StringUtils.isBlank(bo.getCategory())) {
bo.setCategory(ModelType.CHAT.getKey());
}
return R.ok(chatModelService.queryList(bo));
}

View File

@@ -0,0 +1,141 @@
package org.ruoyi.controller.chat;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.chat.entity.audio.AudioContext;
import org.ruoyi.common.chat.entity.image.ImageContext;
import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
import org.ruoyi.common.chat.entity.video.VideoContext;
import org.ruoyi.common.chat.factory.AudioServiceFactory;
import org.ruoyi.common.chat.factory.ImageServiceFactory;
import org.ruoyi.common.chat.factory.VideoServiceFactory;
import org.ruoyi.common.chat.service.chat.IChatModelService;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.domain.bo.media.ImageGenerationRequest;
import org.ruoyi.domain.bo.media.SpeechGenerationRequest;
import org.ruoyi.domain.bo.media.VideoGenerationRequest;
import org.ruoyi.enums.ModelType;
import org.ruoyi.service.media.AtlasPredictionService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/media")
public class MediaGenerationController {
private final IChatModelService chatModelService;
private final AudioServiceFactory audioServiceFactory;
private final ImageServiceFactory imageServiceFactory;
private final VideoServiceFactory videoServiceFactory;
private final AtlasPredictionService atlasPredictionService;
@PostMapping("/speech")
public R<MediaGenerationResponse> speech(@Valid @RequestBody SpeechGenerationRequest request) {
ChatModelVo model = loadModel(request.getModel(), ModelType.AUDIO.getKey());
MediaGenerationResponse response = audioServiceFactory.getOriginalService(model.getProviderCode())
.generateSpeech(AudioContext.builder()
.chatModelVo(model)
.input(request.getInput())
.voice(request.getVoice())
.responseFormat(request.getResponseFormat())
.speed(request.getSpeed())
.instructions(request.getInstructions())
.build());
return R.ok(response);
}
@PostMapping("/image")
public R<MediaGenerationResponse> image(@Valid @RequestBody ImageGenerationRequest request) {
ChatModelVo model = loadModel(request.getModel(), ModelType.IMAGE.getKey());
String result = imageServiceFactory.getOriginalService(model.getProviderCode())
.generateImage(ImageContext.builder()
.chatModelVo(model)
.prompt(request.getPrompt())
.size(request.getSize())
.seed(request.getSeed())
.build());
return R.ok(toImageResponse(result));
}
@PostMapping("/video")
public R<MediaGenerationResponse> video(@Valid @RequestBody VideoGenerationRequest request) {
ChatModelVo model = loadModel(request.getModel(), ModelType.VIDEO.getKey());
MediaGenerationResponse response = videoServiceFactory.getOriginalService(model.getProviderCode())
.generateVideo(VideoContext.builder()
.chatModelVo(model)
.prompt(request.getPrompt())
.size(request.getSize())
.seconds(request.getSeconds())
.quality(request.getQuality())
.build());
return R.ok(response);
}
@GetMapping("/video")
public R<MediaGenerationResponse> videoResult(@NotBlank(message = "模型不能为空") @RequestParam String model,
@NotBlank(message = "videoId不能为空") @RequestParam String videoId) {
ChatModelVo chatModelVo = loadModel(model, ModelType.VIDEO.getKey());
MediaGenerationResponse response = videoServiceFactory.getOriginalService(chatModelVo.getProviderCode())
.retrieveVideo(VideoContext.builder()
.chatModelVo(chatModelVo)
.prompt("retrieve")
.videoId(videoId)
.build());
return R.ok(response);
}
@GetMapping("/prediction")
public R<MediaGenerationResponse> prediction(@NotBlank(message = "模型不能为空") @RequestParam String model,
@NotBlank(message = "predictionId不能为空") @RequestParam String predictionId) {
ChatModelVo chatModelVo = chatModelService.selectModelByName(model);
if (chatModelVo == null) {
throw new IllegalArgumentException("未找到模型配置: " + model);
}
return R.ok(atlasPredictionService.retrieve(chatModelVo, predictionId));
}
private ChatModelVo loadModel(String modelName, String category) {
ChatModelVo model = chatModelService.selectModelByName(modelName);
if (model == null) {
throw new IllegalArgumentException("未找到模型配置: " + modelName);
}
if (!category.equals(model.getCategory())) {
throw new IllegalArgumentException("模型分类不匹配,期望: " + category + ", 实际: " + model.getCategory());
}
return model;
}
private MediaGenerationResponse toImageResponse(String result) {
if (result != null && result.startsWith("{")) {
try {
return atlasPredictionService.toResponse(result, "image");
} catch (Exception e) {
throw new RuntimeException("图片生成响应解析失败: " + e.getMessage(), e);
}
}
if (result != null && result.startsWith("data:")) {
String mimeType = result.substring("data:".length(), result.indexOf(";base64,"));
String b64 = result.substring(result.indexOf(";base64,") + ";base64,".length());
return MediaGenerationResponse.builder()
.type("image")
.mimeType(mimeType)
.b64Json(b64)
.dataUrl(result)
.build();
}
return MediaGenerationResponse.builder()
.type("image")
.mimeType("image/png")
.url(result)
.build();
}
}

View File

@@ -0,0 +1,377 @@
package org.ruoyi.controller.shortdrama;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.common.core.exception.ServiceException;
import org.ruoyi.common.core.service.OssService;
import org.ruoyi.common.satoken.utils.LoginHelper;
import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaComposeVideoBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaLocationVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaProjectVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaStoryboardVo;
import org.ruoyi.service.shortdrama.IShortDramaService;
import org.ruoyi.service.shortdrama.IShortDramaVideoComposeService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RestController;
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.util.List;
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/short-drama")
public class ShortDramaController {
private final IShortDramaService shortDramaService;
private final IShortDramaVideoComposeService videoComposeService;
private final OssService ossService;
// ==================== 项目 ====================
@GetMapping("/projects")
public R<List<ShortDramaProjectVo>> projects() {
return R.ok(shortDramaService.listProjects(LoginHelper.getUserId()));
}
@GetMapping("/{projectId}")
public R<ShortDramaDetailVo> detail(@PathVariable Long projectId) {
return R.ok(shortDramaService.getDetail(projectId, LoginHelper.getUserId()));
}
@PostMapping("/create-from-idea")
public R<ShortDramaDetailVo> createFromIdea(@Valid @RequestBody ShortDramaIdeaBo bo) {
return R.ok(shortDramaService.createFromIdea(bo, LoginHelper.getUserId()));
}
/** SSE 流式创建:逐阶段推送进度,避免用户等待焦虑 */
@PostMapping("/create-from-idea/stream")
public SseEmitter createFromIdeaStream(@Valid @RequestBody ShortDramaIdeaBo bo) {
return shortDramaService.createFromIdeaStream(bo, LoginHelper.getUserId());
}
@PostMapping("/project")
public R<Long> saveProject(@Valid @RequestBody ShortDramaProjectBo bo) {
return R.ok(shortDramaService.saveProject(bo, LoginHelper.getUserId()));
}
@PutMapping("/project")
public R<Long> updateProject(@Valid @RequestBody ShortDramaProjectBo bo) {
return R.ok(shortDramaService.saveProject(bo, LoginHelper.getUserId()));
}
@DeleteMapping("/project/{projectId}")
public R<Void> deleteProject(@NotNull @PathVariable Long projectId) {
shortDramaService.deleteProject(projectId, LoginHelper.getUserId());
return R.ok();
}
// ==================== 剧本 ====================
@PostMapping("/script")
public R<ShortDramaScriptVo> saveScript(@Valid @RequestBody ShortDramaScriptBo bo) {
return R.ok(shortDramaService.saveScript(bo, LoginHelper.getUserId()));
}
// ==================== 分镜 ====================
@PostMapping("/storyboards/generate")
public R<List<ShortDramaStoryboardVo>> generate(@NotNull @RequestParam Long projectId,
@NotNull @RequestParam Long scriptId,
@RequestParam(required = false) String model) {
return R.ok(shortDramaService.generateStoryboards(projectId, scriptId, model, LoginHelper.getUserId()));
}
@PostMapping("/storyboard")
public R<ShortDramaStoryboardVo> saveStoryboard(@Valid @RequestBody ShortDramaStoryboardBo bo) {
return R.ok(shortDramaService.saveStoryboard(bo, LoginHelper.getUserId()));
}
@PostMapping("/storyboard/{storyboardId}/generate-video")
public R<ShortDramaStoryboardVo> generateVideo(@NotNull @PathVariable Long storyboardId,
@NotBlank @RequestParam String model) {
return R.ok(shortDramaService.generateVideo(storyboardId, model, LoginHelper.getUserId()));
}
@GetMapping("/storyboard/{storyboardId}/video-result")
public R<ShortDramaStoryboardVo> videoResult(@NotNull @PathVariable Long storyboardId,
@NotBlank @RequestParam String model) {
return R.ok(shortDramaService.retrieveVideo(storyboardId, model, LoginHelper.getUserId()));
}
@PostMapping("/{projectId}/generate-all-videos")
public R<List<ShortDramaStoryboardVo>> generateAllVideos(@NotNull @PathVariable Long projectId,
@NotBlank @RequestParam String model) {
return R.ok(shortDramaService.generateAllVideos(projectId, model, LoginHelper.getUserId()));
}
@PostMapping("/{projectId}/compose-video")
public R<ShortDramaComposeVideoVo> composeVideo(@NotNull @PathVariable Long projectId,
@Valid @RequestBody ShortDramaComposeVideoBo bo) {
return R.ok(videoComposeService.composeVideo(projectId, bo, LoginHelper.getUserId()));
}
@GetMapping("/{projectId}/compose-video")
public R<ShortDramaComposeVideoVo> getComposedVideo(@NotNull @PathVariable Long projectId) {
return R.ok(videoComposeService.getComposedVideo(projectId, LoginHelper.getUserId()));
}
@GetMapping("/{projectId}/compose-video/download")
public void downloadComposedVideo(@NotNull @PathVariable Long projectId, HttpServletResponse response)
throws IOException {
ShortDramaComposeVideoVo composition = videoComposeService.getComposedVideo(projectId, LoginHelper.getUserId());
if (composition == null || !"done".equals(composition.getStatus())) {
throw new ServiceException("成片尚未生成完成");
}
if (composition.getVideoOssId() != null) {
ossService.downloadFile(composition.getVideoOssId(), response);
return;
}
Path localVideo = videoComposeService.getLocalComposedVideo(projectId, LoginHelper.getUserId());
response.setContentType("video/mp4");
response.setHeader("Content-Disposition", "attachment; filename=short-drama-" + projectId + ".mp4");
response.setContentLengthLong(Files.size(localVideo));
Files.copy(localVideo, response.getOutputStream());
}
// ==================== 阶段式流水线端点 ====================
/** Phase 1: 剧本打磨 */
@PostMapping("/{projectId}/polish-script")
public R<ShortDramaDetailVo> polishScript(@NotNull @PathVariable Long projectId) {
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId()));
}
// ==================== 资产分析 ====================
/** Phase 2: 资产分析(角色+场景提取) */
@PostMapping("/{projectId}/analyze-assets")
public R<ShortDramaDetailVo> analyzeAssets(@NotNull @PathVariable Long projectId,
@NotNull @RequestParam Long scriptId) {
return R.ok(shortDramaService.analyzeAssets(projectId, scriptId, LoginHelper.getUserId()));
}
// ==================== 分镜流水线 ====================
/** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */
@PostMapping("/{projectId}/plan-storyboard")
public R<List<ShortDramaStoryboardVo>> planStoryboard(@NotNull @PathVariable Long projectId,
@NotNull @RequestParam Long scriptId,
@RequestParam(required = false) String model) {
return R.ok(shortDramaService.planStoryboard(projectId, scriptId, model, LoginHelper.getUserId()));
}
/** Phase 3-6: SSE 流式生成分镜,持续推送规划和细化进度 */
@PostMapping("/{projectId}/plan-storyboard/stream")
public SseEmitter planStoryboardStream(@NotNull @PathVariable Long projectId,
@NotNull @RequestParam Long scriptId,
@RequestParam(required = false) String model) {
return shortDramaService.planStoryboardStream(projectId, scriptId, model, LoginHelper.getUserId());
}
/** Phase 4: 重新生成摄影规则 */
@PostMapping("/{projectId}/photography-rules")
public R<List<ShortDramaStoryboardVo>> generatePhotographyRules(@NotNull @PathVariable Long projectId,
@NotNull @RequestParam Long scriptId) {
return R.ok(shortDramaService.generatePhotographyRules(projectId, scriptId, LoginHelper.getUserId()));
}
/** Phase 5: 重新生成表演指导 */
@PostMapping("/{projectId}/acting-directions")
public R<List<ShortDramaStoryboardVo>> generateActingDirections(@NotNull @PathVariable Long projectId,
@NotNull @RequestParam Long scriptId) {
return R.ok(shortDramaService.generateActingDirections(projectId, scriptId, LoginHelper.getUserId()));
}
// ==================== 角色管理 ====================
@PostMapping("/character")
public R<ShortDramaCharacterVo> saveCharacter(@Valid @RequestBody ShortDramaCharacterBo bo) {
return R.ok(shortDramaService.saveCharacter(bo, LoginHelper.getUserId()));
}
@PutMapping("/character")
public R<ShortDramaCharacterVo> updateCharacter(@Valid @RequestBody ShortDramaCharacterBo bo) {
return R.ok(shortDramaService.saveCharacter(bo, LoginHelper.getUserId()));
}
@DeleteMapping("/character/{characterId}")
public R<Void> deleteCharacter(@NotNull @PathVariable Long characterId) {
shortDramaService.deleteCharacter(characterId, LoginHelper.getUserId());
return R.ok();
}
@PostMapping("/character/{characterId}/generate-image")
public R<ShortDramaCharacterVo> generateCharacterImage(@NotNull @PathVariable Long characterId,
@NotBlank @RequestParam String model,
@RequestParam(required = false) String referenceImageUrl) {
return R.ok(shortDramaService.generateCharacterImage(characterId, model, referenceImageUrl, LoginHelper.getUserId()));
}
// ==================== 角色形象管理 ====================
@PostMapping("/character-appearance")
public R<ShortDramaCharacterAppearanceVo> saveAppearance(@Valid @RequestBody ShortDramaCharacterAppearanceBo bo) {
return R.ok(shortDramaService.saveAppearance(bo, LoginHelper.getUserId()));
}
@PutMapping("/character-appearance")
public R<ShortDramaCharacterAppearanceVo> updateAppearance(@Valid @RequestBody ShortDramaCharacterAppearanceBo bo) {
return R.ok(shortDramaService.saveAppearance(bo, LoginHelper.getUserId()));
}
@DeleteMapping("/character-appearance/{appearanceId}")
public R<Void> deleteAppearance(@NotNull @PathVariable Long appearanceId) {
shortDramaService.deleteAppearance(appearanceId, LoginHelper.getUserId());
return R.ok();
}
@PostMapping("/character-appearance/{appearanceId}/generate-image")
public R<ShortDramaCharacterAppearanceVo> generateAppearanceImage(@NotNull @PathVariable Long appearanceId,
@NotBlank @RequestParam String model,
@RequestParam(required = false) String referenceImageUrl) {
return R.ok(shortDramaService.generateAppearanceImage(appearanceId, model, referenceImageUrl, LoginHelper.getUserId()));
}
@PostMapping("/character-appearance/{appearanceId}/regenerate")
public R<ShortDramaCharacterAppearanceVo> regenerateAppearanceImage(@NotNull @PathVariable Long appearanceId,
@NotBlank @RequestParam String model,
@RequestParam(required = false) String referenceImageUrl) {
return R.ok(shortDramaService.regenerateAppearanceImage(appearanceId, model, referenceImageUrl, LoginHelper.getUserId()));
}
@PostMapping("/character-appearance/{appearanceId}/select-image")
public R<ShortDramaCharacterAppearanceVo> selectAppearanceImage(@NotNull @PathVariable Long appearanceId,
@NotNull @RequestParam Integer index) {
return R.ok(shortDramaService.selectAppearanceImage(appearanceId, index, LoginHelper.getUserId()));
}
@DeleteMapping("/character-appearance/{appearanceId}/image")
public R<ShortDramaCharacterAppearanceVo> deleteAppearanceImage(@NotNull @PathVariable Long appearanceId,
@NotNull @RequestParam Integer index) {
return R.ok(shortDramaService.deleteAppearanceImage(appearanceId, index, LoginHelper.getUserId()));
}
@PostMapping("/character-appearance/{appearanceId}/undo-image")
public R<ShortDramaCharacterAppearanceVo> undoAppearanceImage(@NotNull @PathVariable Long appearanceId) {
return R.ok(shortDramaService.undoAppearanceImage(appearanceId, LoginHelper.getUserId()));
}
// ==================== 场景管理 ====================
@PostMapping("/location")
public R<ShortDramaLocationVo> saveLocation(@Valid @RequestBody ShortDramaLocationBo bo) {
return R.ok(shortDramaService.saveLocation(bo, LoginHelper.getUserId()));
}
@PutMapping("/location")
public R<ShortDramaLocationVo> updateLocation(@Valid @RequestBody ShortDramaLocationBo bo) {
return R.ok(shortDramaService.saveLocation(bo, LoginHelper.getUserId()));
}
@DeleteMapping("/location/{locationId}")
public R<Void> deleteLocation(@NotNull @PathVariable Long locationId) {
shortDramaService.deleteLocation(locationId, LoginHelper.getUserId());
return R.ok();
}
@PostMapping("/location/{locationId}/generate-image")
public R<ShortDramaLocationVo> generateLocationImage(@NotNull @PathVariable Long locationId,
@NotBlank @RequestParam String model,
@RequestParam(required = false) String referenceImageUrl) {
return R.ok(shortDramaService.generateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId()));
}
@PostMapping("/location/{locationId}/regenerate")
public R<ShortDramaLocationVo> regenerateLocationImage(@NotNull @PathVariable Long locationId,
@NotBlank @RequestParam String model,
@RequestParam(required = false) String referenceImageUrl) {
return R.ok(shortDramaService.regenerateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId()));
}
@PostMapping("/location/{locationId}/select-image")
public R<ShortDramaLocationVo> selectLocationImage(@NotNull @PathVariable Long locationId,
@NotNull @RequestParam Integer index) {
return R.ok(shortDramaService.selectLocationImage(locationId, index, LoginHelper.getUserId()));
}
@DeleteMapping("/location/{locationId}/image")
public R<ShortDramaLocationVo> deleteLocationImage(@NotNull @PathVariable Long locationId,
@NotNull @RequestParam Integer index) {
return R.ok(shortDramaService.deleteLocationImage(locationId, index, LoginHelper.getUserId()));
}
@PostMapping("/location/{locationId}/undo-image")
public R<ShortDramaLocationVo> undoLocationImage(@NotNull @PathVariable Long locationId) {
return R.ok(shortDramaService.undoLocationImage(locationId, LoginHelper.getUserId()));
}
// ==================== 异步图片生成(轮询进度) ====================
/** 上传本地照片到图片供应商,返回当前生成会话使用的临时 URL。 */
@PostMapping(value = "/image/upload-reference", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<String> uploadReferenceImage(@RequestPart("file") MultipartFile file,
@NotBlank @RequestParam String model) {
String temporaryUrl = shortDramaService.uploadReferenceImage(file, model, LoginHelper.getUserId());
return R.ok("上传成功", temporaryUrl);
}
/** 异步启动图片生成,返回 predictionId 供前端轮询 */
@PostMapping("/image/start")
public R<MediaGenerationResponse> startImage(@NotBlank @RequestParam String assetType,
@NotNull @RequestParam Long assetId,
@NotBlank @RequestParam String model,
@RequestParam(required = false) String referenceImageUrl) {
return R.ok(shortDramaService.startImageGeneration(assetType, assetId, model, referenceImageUrl, LoginHelper.getUserId()));
}
/** 轮询确认形象图片并保存 */
@PostMapping("/character-appearance/{id}/confirm-image")
public R<ShortDramaCharacterAppearanceVo> confirmAppearanceImage(@NotNull @PathVariable Long id,
@NotBlank @RequestParam String predictionId,
@NotBlank @RequestParam String model) {
return R.ok(shortDramaService.confirmAppearanceImage(id, predictionId, model, LoginHelper.getUserId()));
}
/** 轮询确认场景图片并保存 */
@PostMapping("/location/{id}/confirm-image")
public R<ShortDramaLocationVo> confirmLocationImage(@NotNull @PathVariable Long id,
@NotBlank @RequestParam String predictionId,
@NotBlank @RequestParam String model) {
return R.ok(shortDramaService.confirmLocationImage(id, predictionId, model, LoginHelper.getUserId()));
}
}

View File

@@ -0,0 +1,95 @@
package org.ruoyi.domain.bo.agent;
import io.github.linpeilie.annotations.AutoMapper;
import io.github.linpeilie.annotations.AutoMapping;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import org.ruoyi.domain.entity.agent.Agent;
import java.io.Serial;
import java.util.List;
/**
* 智能体业务对象
*
* @author ruoyi team
*/
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = Agent.class, reverseConvertGenerate = false)
public class AgentBo extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
/**
* 智能体ID
*/
private Long id;
/**
* 智能体名称
*/
@NotBlank(message = "智能体名称不能为空")
@Size(min = 0, max = 200, message = "智能体名称不能超过{max}个字符")
private String agentName;
/**
* 智能体描述
*/
private String agentDescribe;
/**
* 展示图标/头像URL
*/
private String agentShow;
/**
* 绑定的聊天模型ID
*/
@NotNull(message = "绑定模型不能为空")
private Long modelId;
/**
* 是否启用深度思考0 否 1 是
*/
private String enableThinking;
/**
* 自定义系统提示词
*/
private String systemPrompt;
/**
* 关联MCP工具ID列表
*/
@AutoMapping(target = "mcpToolIds", expression = "java(org.ruoyi.common.json.utils.JsonUtils.toJsonString(source.getMcpToolIds()))")
private List<Long> mcpToolIds;
/**
* 关联磁盘技能名列表
*/
@AutoMapping(target = "skillNames", expression = "java(org.ruoyi.common.json.utils.JsonUtils.toJsonString(source.getSkillNames()))")
private List<String> skillNames;
/**
* 关联知识库ID列表
*/
@AutoMapping(target = "knowledgeIds", expression = "java(org.ruoyi.common.json.utils.JsonUtils.toJsonString(source.getKnowledgeIds()))")
private List<Long> knowledgeIds;
/**
* 状态0 正常 1 停用
*/
private String status;
/**
* 备注
*/
private String remark;
}

View File

@@ -0,0 +1,22 @@
package org.ruoyi.domain.bo.media;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Data;
@Data
public class ImageGenerationRequest {
@NotBlank(message = "模型不能为空")
private String model;
@NotBlank(message = "提示词不能为空")
private String prompt;
private String size;
@Min(value = 0, message = "随机种子不能小于0")
@Max(value = 2147483647, message = "随机种子不能大于2147483647")
private Integer seed;
}

View File

@@ -0,0 +1,22 @@
package org.ruoyi.domain.bo.media;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class SpeechGenerationRequest {
@NotBlank(message = "模型不能为空")
private String model;
@NotBlank(message = "输入文本不能为空")
private String input;
private String voice;
private String responseFormat;
private Double speed;
private String instructions;
}

View File

@@ -0,0 +1,20 @@
package org.ruoyi.domain.bo.media;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class VideoGenerationRequest {
@NotBlank(message = "模型不能为空")
private String model;
@NotBlank(message = "提示词不能为空")
private String prompt;
private String size;
private Integer seconds;
private String quality;
}

View File

@@ -0,0 +1,35 @@
package org.ruoyi.domain.bo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacterAppearance;
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = ShortDramaCharacterAppearance.class, reverseConvertGenerate = false)
public class ShortDramaCharacterAppearanceBo extends BaseEntity {
private Long id;
private Long characterId;
private Integer appearanceIndex;
private String changeReason;
private String description;
private String referenceImageUrl;
private String imageUrls;
private String imageDescriptions;
private Integer selectedImageIndex;
private String previousImageUrls;
private String previousDescriptions;
}

View File

@@ -0,0 +1,37 @@
package org.ruoyi.domain.bo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter;
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = ShortDramaCharacter.class, reverseConvertGenerate = false)
public class ShortDramaCharacterBo extends BaseEntity {
private Long id;
private Long projectId;
private String name;
private String aliases;
private String introduction;
private String roleLevel;
private String gender;
private String ageRange;
private String personalityTags;
private Integer costumeTier;
private String visualDescription;
private String referenceImageUrl;
}

View File

@@ -0,0 +1,29 @@
package org.ruoyi.domain.bo.shortdrama;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
public class ShortDramaComposeVideoBo {
@NotBlank(message = "转场类型不能为空")
@Pattern(regexp = "none|dissolve|fade|slide", message = "不支持的转场类型")
private String transitionType = "dissolve";
@NotNull(message = "转场时长不能为空")
@DecimalMin(value = "0.0", message = "转场时长不能小于0秒")
private BigDecimal transitionDurationSeconds = new BigDecimal("0.5");
@NotBlank(message = "成片画幅不能为空")
@Pattern(regexp = "9:16|16:9|1:1", message = "不支持的成片画幅")
private String aspectRatio = "9:16";
@Size(min = 2, message = "至少选择2个分镜视频")
private List<Long> storyboardIds;
}

View File

@@ -0,0 +1,20 @@
package org.ruoyi.domain.bo.shortdrama;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class ShortDramaIdeaBo {
@NotBlank(message = "创意想法不能为空")
private String idea;
@NotBlank(message = "模型不能为空")
private String model;
private String projectName;
private String artStyle;
private String aspectRatio;
}

View File

@@ -0,0 +1,41 @@
package org.ruoyi.domain.bo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import org.ruoyi.domain.entity.shortdrama.ShortDramaLocation;
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = ShortDramaLocation.class, reverseConvertGenerate = false)
public class ShortDramaLocationBo extends BaseEntity {
private Long id;
private Long projectId;
private String name;
private String summary;
private Boolean hasCrowd;
private String crowdDescription;
private String availableSlots;
private String descriptions;
private String referenceImageUrl;
private String imageUrls;
private String imageDescriptions;
private Integer selectedImageIndex;
private String previousImageUrls;
private String previousDescriptions;
}

View File

@@ -0,0 +1,27 @@
package org.ruoyi.domain.bo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import org.ruoyi.domain.entity.shortdrama.ShortDramaProject;
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = ShortDramaProject.class, reverseConvertGenerate = false)
public class ShortDramaProjectBo extends BaseEntity {
private Long id;
private Long userId;
private String projectName;
private String description;
private String status;
private String artStyle;
private String composeAspectRatio;
}

View File

@@ -0,0 +1,27 @@
package org.ruoyi.domain.bo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import org.ruoyi.domain.entity.shortdrama.ShortDramaScript;
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = ShortDramaScript.class, reverseConvertGenerate = false)
public class ShortDramaScriptBo extends BaseEntity {
private Long id;
private Long projectId;
private String scriptName;
private String scriptText;
private String outlineText;
private String tone;
private String sourceType;
}

View File

@@ -0,0 +1,31 @@
package org.ruoyi.domain.bo.shortdrama;
import dev.langchain4j.model.output.structured.Description;
import lombok.Data;
/**
* Phase 1 剧本打磨结构化响应 —— langchain4j AiServices 自动解析用
*
* @author ageerle
*/
@Data
public class ShortDramaScriptResult {
@Description("项目名称(有吸引力的短剧名)")
private String projectName;
@Description("一句话简介20-50字")
private String description;
@Description("剧本名称")
private String scriptName;
@Description("风格基调(如:都市甜宠/古装虐恋/悬疑惊悚/喜剧爽文)")
private String tone;
@Description("剧情大纲400-800字完整故事线")
private String outlineText;
@Description("完整短剧文本1000-3000字标准剧本格式含场景头、动作描述、对话")
private String scriptText;
}

View File

@@ -0,0 +1,49 @@
package org.ruoyi.domain.bo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard;
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = ShortDramaStoryboard.class, reverseConvertGenerate = false)
public class ShortDramaStoryboardBo extends BaseEntity {
private Long id;
private Long projectId;
private Long scriptId;
private Integer sceneNo;
private String sceneTitle;
private String sceneText;
private String sceneType;
private String shotType;
private String cameraMove;
private String charactersJson;
private String locationName;
private String photographyRules;
private String actingNotes;
private String continuityJson;
private String sourceText;
private String imagePrompt;
private Integer durationSeconds;
private String videoPrompt;
}

View File

@@ -0,0 +1,84 @@
package org.ruoyi.domain.entity.agent;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.tenant.core.TenantEntity;
/**
* 智能体信息实体
* <p>
* 一个智能体聚合:一个聊天模型 + 一组 MCP 工具 + 一组磁盘技能 + 一组知识库 + 自定义提示词
* 关联以 JSON 数组字符串列存储mcp_tool_ids / skill_names / knowledge_ids
*
* @author ruoyi team
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("agent_info")
public class Agent extends TenantEntity {
/**
* 智能体ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 智能体名称
*/
private String agentName;
/**
* 智能体描述(下拉展示用)
*/
private String agentDescribe;
/**
* 展示图标/头像URL
*/
private String agentShow;
/**
* 绑定的聊天模型IDchat_model.id, category=chat
*/
private Long modelId;
/**
* 是否启用深度思考(ReAct多子Agent)0 否 1 是
*/
private String enableThinking;
/**
* 自定义系统提示词
*/
private String systemPrompt;
/**
* 关联MCP工具ID列表JSON数组[Long]
*/
private String mcpToolIds;
/**
* 关联磁盘技能名列表JSON数组[String]
*/
private String skillNames;
/**
* 关联知识库ID列表JSON数组[Long]
*/
private String knowledgeIds;
/**
* 状态0 正常 1 停用
*/
private String status;
/**
* 备注
*/
private String remark;
}

View File

@@ -0,0 +1,43 @@
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_character")
public class ShortDramaCharacter extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
private Long projectId;
private String name;
private String aliases;
private String introduction;
private String roleLevel;
private String gender;
private String ageRange;
private String personalityTags;
private Integer costumeTier;
private String visualDescription;
private String referenceImageUrl;
}

View File

@@ -0,0 +1,46 @@
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_character_appearance")
public class ShortDramaCharacterAppearance extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
private Long characterId;
private Integer appearanceIndex;
private String changeReason;
private String description;
private String referenceImageUrl;
/** 生成图片URL列表JSON数组 */
private String imageUrls;
/** 每张图片对应的提示词JSON数组 */
private String imageDescriptions;
/** 当前选中的图片索引 */
private Integer selectedImageIndex;
/** 上一轮图片URL列表撤销用JSON数组 */
private String previousImageUrls;
/** 上一轮提示词列表撤销用JSON数组 */
private String previousDescriptions;
}

View File

@@ -0,0 +1,52 @@
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_location")
public class ShortDramaLocation extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
private Long projectId;
private String name;
private String summary;
private Boolean hasCrowd;
private String crowdDescription;
private String availableSlots;
private String descriptions;
private String referenceImageUrl;
/** 生成图片URL列表JSON数组 */
private String imageUrls;
/** 每张图片对应的提示词JSON数组 */
private String imageDescriptions;
/** 当前选中的图片索引 */
private Integer selectedImageIndex;
/** 上一轮图片URL列表撤销用JSON数组 */
private String previousImageUrls;
/** 上一轮提示词列表撤销用JSON数组 */
private String previousDescriptions;
}

View File

@@ -0,0 +1,53 @@
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;
import java.math.BigDecimal;
import java.util.Date;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("short_drama_project")
public class ShortDramaProject extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
private Long userId;
private String projectName;
private String description;
private String status;
private String artStyle;
private Long composedVideoOssId;
private String composeStatus;
private String composeJobId;
private Integer composeProgress;
private String composeTransitionType;
private BigDecimal composeTransitionDurationSeconds;
private String composeAspectRatio;
private BigDecimal composedVideoDurationSeconds;
private String composeErrorMessage;
private Date composedAt;
}

View File

@@ -0,0 +1,33 @@
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_script")
public class ShortDramaScript extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
private Long projectId;
private String scriptName;
private String scriptText;
private String outlineText;
private String tone;
private String sourceType;
}

View File

@@ -0,0 +1,61 @@
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_storyboard")
public class ShortDramaStoryboard extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
private Long projectId;
private Long scriptId;
private Integer sceneNo;
private String sceneTitle;
private String sceneText;
private String sceneType;
private String shotType;
private String cameraMove;
private String charactersJson;
private String locationName;
private String photographyRules;
private String actingNotes;
private String continuityJson;
private String sourceText;
private String imagePrompt;
private Integer durationSeconds;
private String videoPrompt;
private String videoUrl;
private String videoId;
private String videoStatus;
}

View File

@@ -0,0 +1,121 @@
package org.ruoyi.domain.vo.agent;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 智能体视图对象
* <p>
* 注意:不使用 @AutoMapper因为 entity 的 mcpToolIds/skillNames/knowledgeIds 是 JSON 字符串列,
* 而 VO 是 List 类型MapStruct 无法双向自动转换。由 AgentServiceImpl.toVo 手动组装。
*
* @author ruoyi team
*/
@Data
@ExcelIgnoreUnannotated
public class AgentVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 智能体ID
*/
@ExcelProperty(value = "智能体ID")
private Long id;
/**
* 智能体名称
*/
@ExcelProperty(value = "智能体名称")
private String agentName;
/**
* 智能体描述
*/
@ExcelProperty(value = "智能体描述")
private String agentDescribe;
/**
* 展示图标/头像URL
*/
private String agentShow;
/**
* 绑定的聊天模型ID
*/
@ExcelProperty(value = "绑定模型ID")
private Long modelId;
/**
* 绑定的聊天模型名称(关联展示)
*/
@ExcelProperty(value = "绑定模型")
private String modelName;
/**
* 是否启用深度思考0 否 1 是
*/
@ExcelProperty(value = "深度思考")
private String enableThinking;
/**
* 自定义系统提示词
*/
private String systemPrompt;
/**
* 关联MCP工具ID列表
*/
private List<Long> mcpToolIds;
/**
* 关联MCP工具名称列表关联展示
*/
private List<String> mcpToolNames;
/**
* 关联磁盘技能名列表
*/
private List<String> skillNames;
/**
* 关联知识库ID列表
*/
private List<Long> knowledgeIds;
/**
* 关联知识库名称列表(关联展示)
*/
private List<String> knowledgeNames;
/**
* 状态0 正常 1 停用
*/
@ExcelProperty(value = "状态")
private String status;
/**
* 备注
*/
private String remark;
/**
* 创建时间
*/
@ExcelProperty(value = "创建时间")
private Date createTime;
/**
* 更新时间
*/
@ExcelProperty(value = "更新时间")
private Date updateTime;
}

View File

@@ -0,0 +1,33 @@
package org.ruoyi.domain.vo.agent;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
/**
* 磁盘技能可选项
*
* @author ruoyi team
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SkillOptionVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 技能名称(对应 SKILL.md front-matter name
*/
private String name;
/**
* 技能描述
*/
private String description;
}

View File

@@ -0,0 +1,43 @@
package org.ruoyi.domain.vo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacterAppearance;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
@Data
@AutoMapper(target = ShortDramaCharacterAppearance.class)
public class ShortDramaCharacterAppearanceVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long id;
private Long characterId;
private Integer appearanceIndex;
private String changeReason;
private String description;
private String referenceImageUrl;
private String imageUrls;
private String imageDescriptions;
private Integer selectedImageIndex;
private String previousImageUrls;
private String previousDescriptions;
private Date createTime;
private Date updateTime;
}

View File

@@ -0,0 +1,48 @@
package org.ruoyi.domain.vo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
@Data
@AutoMapper(target = ShortDramaCharacter.class)
public class ShortDramaCharacterVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long id;
private Long projectId;
private String name;
private String aliases;
private String introduction;
private String roleLevel;
private String gender;
private String ageRange;
private String personalityTags;
private Integer costumeTier;
private String visualDescription;
private String referenceImageUrl;
private List<ShortDramaCharacterAppearanceVo> appearances;
private Date createTime;
private Date updateTime;
}

View File

@@ -0,0 +1,45 @@
package org.ruoyi.domain.vo.shortdrama;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShortDramaComposeVideoVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long projectId;
private String status;
private Integer progress;
private String transitionType;
private BigDecimal transitionDurationSeconds;
private String aspectRatio;
/** Actual duration measured from the final MP4 with ffprobe. */
private BigDecimal outputDurationSeconds;
private Long videoOssId;
/** Freshly resolved URL; private-bucket URLs may be short lived. */
private String videoUrl;
private String errorMessage;
private Date composedAt;
}

View File

@@ -0,0 +1,24 @@
package org.ruoyi.domain.vo.shortdrama;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
@Data
public class ShortDramaDetailVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private ShortDramaProjectVo project;
private ShortDramaScriptVo script;
private List<ShortDramaCharacterVo> characters;
private List<ShortDramaLocationVo> locations;
private List<ShortDramaStoryboardVo> storyboards;
}

View File

@@ -0,0 +1,49 @@
package org.ruoyi.domain.vo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.domain.entity.shortdrama.ShortDramaLocation;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
@Data
@AutoMapper(target = ShortDramaLocation.class)
public class ShortDramaLocationVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long id;
private Long projectId;
private String name;
private String summary;
private Boolean hasCrowd;
private String crowdDescription;
private String availableSlots;
private String descriptions;
private String referenceImageUrl;
private String imageUrls;
private String imageDescriptions;
private Integer selectedImageIndex;
private String previousImageUrls;
private String previousDescriptions;
private Date createTime;
private Date updateTime;
}

View File

@@ -0,0 +1,52 @@
package org.ruoyi.domain.vo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.domain.entity.shortdrama.ShortDramaProject;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
@Data
@AutoMapper(target = ShortDramaProject.class)
public class ShortDramaProjectVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long id;
private Long userId;
private String projectName;
private String description;
private String status;
private String artStyle;
private Long composedVideoOssId;
private String composeStatus;
private Integer composeProgress;
private String composeTransitionType;
private BigDecimal composeTransitionDurationSeconds;
private String composeAspectRatio;
private BigDecimal composedVideoDurationSeconds;
private String composeErrorMessage;
private Date composedAt;
private Date createTime;
private Date updateTime;
}

View File

@@ -0,0 +1,35 @@
package org.ruoyi.domain.vo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.domain.entity.shortdrama.ShortDramaScript;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
@Data
@AutoMapper(target = ShortDramaScript.class)
public class ShortDramaScriptVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long id;
private Long projectId;
private String scriptName;
private String scriptText;
private String outlineText;
private String tone;
private String sourceType;
private Date createTime;
private Date updateTime;
}

View File

@@ -0,0 +1,63 @@
package org.ruoyi.domain.vo.shortdrama;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
@Data
@AutoMapper(target = ShortDramaStoryboard.class)
public class ShortDramaStoryboardVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long id;
private Long projectId;
private Long scriptId;
private Integer sceneNo;
private String sceneTitle;
private String sceneText;
private String sceneType;
private String shotType;
private String cameraMove;
private String charactersJson;
private String locationName;
private String photographyRules;
private String actingNotes;
private String continuityJson;
private String sourceText;
private String imagePrompt;
private Integer durationSeconds;
private String videoPrompt;
private String videoUrl;
private String videoId;
private String videoStatus;
private Date createTime;
private Date updateTime;
}

View File

@@ -18,6 +18,8 @@ public enum ChatModeType {
ATLAS("atlas", "Atlas Cloud"),
CUSTOM_API("custom_api", "自定义API"),
MINIMAX("minimax", "MiniMax"),
DIFY("dify", "Dify"),
COZE("coze", "Coze"),
XIAOMI("xiaomi", "小米MiMo");
private final String code;
private final String description;

View File

@@ -0,0 +1,14 @@
package org.ruoyi.mapper.agent;
import org.apache.ibatis.annotations.Mapper;
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
import org.ruoyi.domain.entity.agent.Agent;
import org.ruoyi.domain.vo.agent.AgentVo;
/**
* 智能体信息 Mapper
*
* @author ruoyi team
*/
@Mapper
public interface AgentMapper extends BaseMapperPlus<Agent, AgentVo> {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
package org.ruoyi.service.agent;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.domain.bo.agent.AgentBo;
import org.ruoyi.domain.vo.agent.AgentVo;
import org.ruoyi.domain.vo.agent.SkillOptionVo;
import java.util.Collection;
import java.util.List;
/**
* 智能体服务接口
*
* @author ruoyi team
*/
public interface IAgentService {
/**
* 分页查询智能体列表
*/
TableDataInfo<AgentVo> queryPageList(AgentBo bo, PageQuery pageQuery);
/**
* 查询符合条件的智能体列表(用于导出)
*/
List<AgentVo> queryList(AgentBo bo);
/**
* 根据ID查询智能体展开 JSON 数组字段为 List关联填充模型/工具/知识库名称)
*/
AgentVo queryById(Long id);
/**
* 新增智能体
*/
Boolean insertByBo(AgentBo bo);
/**
* 修改智能体
*/
Boolean updateByBo(AgentBo bo);
/**
* 批量删除智能体
*/
Boolean deleteByIds(Collection<Long> ids);
/**
* 查询启用的智能体下拉选项用户端聊天页选择用status=0
*/
List<AgentVo> queryEnabledOptions();
/**
* 列出磁盘上可用的 Skills供管理端表单勾选用
*/
List<SkillOptionVo> listSkillOptions();
}

View File

@@ -0,0 +1,226 @@
package org.ruoyi.service.agent.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import dev.langchain4j.skills.FileSystemSkill;
import dev.langchain4j.skills.FileSystemSkillLoader;
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.core.utils.MapstructUtils;
import org.ruoyi.common.json.utils.JsonUtils;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.config.agent.SkillsPathResolver;
import org.ruoyi.domain.entity.agent.Agent;
import org.ruoyi.domain.bo.agent.AgentBo;
import org.ruoyi.domain.entity.mcp.McpTool;
import org.ruoyi.domain.vo.agent.AgentVo;
import org.ruoyi.domain.vo.agent.SkillOptionVo;
import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo;
import org.ruoyi.mapper.agent.AgentMapper;
import org.ruoyi.mapper.mcp.McpToolMapper;
import org.ruoyi.service.agent.IAgentService;
import org.ruoyi.service.knowledge.IKnowledgeInfoService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
/**
* 智能体服务实现
* <p>
* 注意entity 的 mcpToolIds/skillNames/knowledgeIds 是 JSON 字符串列,
* VO 中是 List 类型。MapStruct 无法自动 String→List因此这里查询 entity 后
* 手动用 JsonUtils 解析并组装 VO。
*
* @author ruoyi team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentServiceImpl implements IAgentService {
private final AgentMapper baseMapper;
private final McpToolMapper mcpToolMapper;
private final IKnowledgeInfoService knowledgeInfoService;
private final IChatModelService chatModelService;
@Override
public TableDataInfo<AgentVo> queryPageList(AgentBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<Agent> wrapper = buildQueryWrapper(bo);
Page<Agent> page = baseMapper.selectPage(pageQuery.build(), wrapper);
List<AgentVo> records = page.getRecords() == null ? List.of()
: page.getRecords().stream().map(this::toVo).toList();
Page<AgentVo> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
voPage.setRecords(records);
return TableDataInfo.build(voPage);
}
@Override
public List<AgentVo> queryList(AgentBo bo) {
LambdaQueryWrapper<Agent> wrapper = buildQueryWrapper(bo);
List<Agent> list = baseMapper.selectList(wrapper);
return list.stream().map(this::toVo).toList();
}
@Override
public AgentVo queryById(Long id) {
Agent entity = baseMapper.selectById(id);
return entity == null ? null : toVo(entity);
}
@Override
@Transactional
public Boolean insertByBo(AgentBo bo) {
Agent entity = MapstructUtils.convert(bo, Agent.class);
if (!StringUtils.hasText(entity.getStatus())) {
entity.setStatus("0");
}
if (!StringUtils.hasText(entity.getEnableThinking())) {
entity.setEnableThinking("0");
}
entity.setMcpToolIds(JsonUtils.toJsonString(bo.getMcpToolIds()));
entity.setSkillNames(JsonUtils.toJsonString(bo.getSkillNames()));
entity.setKnowledgeIds(JsonUtils.toJsonString(bo.getKnowledgeIds()));
return baseMapper.insert(entity) > 0;
}
@Override
@Transactional
public Boolean updateByBo(AgentBo bo) {
Agent entity = MapstructUtils.convert(bo, Agent.class);
entity.setMcpToolIds(JsonUtils.toJsonString(bo.getMcpToolIds()));
entity.setSkillNames(JsonUtils.toJsonString(bo.getSkillNames()));
entity.setKnowledgeIds(JsonUtils.toJsonString(bo.getKnowledgeIds()));
return baseMapper.updateById(entity) > 0;
}
@Override
@Transactional
public Boolean deleteByIds(Collection<Long> ids) {
return baseMapper.deleteByIds(ids) > 0;
}
@Override
public List<AgentVo> queryEnabledOptions() {
LambdaQueryWrapper<Agent> wrapper = Wrappers.lambdaQuery();
wrapper.eq(Agent::getStatus, "0")
.orderByDesc(Agent::getUpdateTime)
.orderByDesc(Agent::getId);
return baseMapper.selectList(wrapper).stream().map(this::toVo).toList();
}
@Override
public List<SkillOptionVo> listSkillOptions() {
try {
List<FileSystemSkill> skills = FileSystemSkillLoader.loadSkills(SkillsPathResolver.resolveSkillsPath());
if (skills == null || skills.isEmpty()) {
return Collections.emptyList();
}
return skills.stream()
.map(s -> new SkillOptionVo(s.name(), s.description()))
.collect(Collectors.toList());
} catch (Exception e) {
log.error("加载磁盘 Skills 失败: {}", e.getMessage(), e);
return Collections.emptyList();
}
}
/**
* entity → vo展开 JSON 数组并填充关联名称与模型名
*/
private AgentVo toVo(Agent entity) {
if (entity == null) {
return null;
}
AgentVo vo = new AgentVo();
vo.setId(entity.getId());
vo.setAgentName(entity.getAgentName());
vo.setAgentDescribe(entity.getAgentDescribe());
vo.setAgentShow(entity.getAgentShow());
vo.setModelId(entity.getModelId());
vo.setEnableThinking(entity.getEnableThinking());
vo.setSystemPrompt(entity.getSystemPrompt());
vo.setStatus(entity.getStatus());
vo.setRemark(entity.getRemark());
vo.setCreateTime(entity.getCreateTime());
vo.setUpdateTime(entity.getUpdateTime());
// 展开 JSON 数组
List<Long> toolIds = parseLongArray(entity.getMcpToolIds());
List<String> skillNames = parseStringArray(entity.getSkillNames());
List<Long> knowledgeIds = parseLongArray(entity.getKnowledgeIds());
vo.setMcpToolIds(toolIds);
vo.setSkillNames(skillNames);
vo.setKnowledgeIds(knowledgeIds);
// 绑定模型名称
if (entity.getModelId() != null) {
try {
ChatModelVo model = chatModelService.queryById(entity.getModelId());
if (model != null) {
vo.setModelName(model.getModelName());
}
} catch (Exception e) {
log.warn("查询模型失败: modelId={}, err={}", entity.getModelId(), e.getMessage());
}
}
// MCP 工具名称
if (toolIds != null && !toolIds.isEmpty()) {
List<McpTool> tools = mcpToolMapper.selectByIds(toolIds);
if (tools != null) {
vo.setMcpToolNames(tools.stream().map(McpTool::getName).toList());
}
}
// 知识库名称
if (knowledgeIds != null && !knowledgeIds.isEmpty()) {
List<String> names = new ArrayList<>();
for (Long kid : knowledgeIds) {
try {
KnowledgeInfoVo kb = knowledgeInfoService.queryById(kid);
if (kb != null) {
names.add(kb.getName());
}
} catch (Exception e) {
log.warn("查询知识库失败: kid={}, err={}", kid, e.getMessage());
}
}
vo.setKnowledgeNames(names);
}
return vo;
}
private List<Long> parseLongArray(String json) {
if (!StringUtils.hasText(json)) {
return new ArrayList<>();
}
List<Long> list = JsonUtils.parseArray(json, Long.class);
return list == null ? new ArrayList<>() : list;
}
private List<String> parseStringArray(String json) {
if (!StringUtils.hasText(json)) {
return new ArrayList<>();
}
List<String> list = JsonUtils.parseArray(json, String.class);
return list == null ? new ArrayList<>() : list;
}
private LambdaQueryWrapper<Agent> buildQueryWrapper(AgentBo bo) {
LambdaQueryWrapper<Agent> wrapper = Wrappers.lambdaQuery();
wrapper.like(StringUtils.hasText(bo.getAgentName()), Agent::getAgentName, bo.getAgentName())
.like(StringUtils.hasText(bo.getAgentDescribe()), Agent::getAgentDescribe, bo.getAgentDescribe())
.eq(StringUtils.hasText(bo.getStatus()), Agent::getStatus, bo.getStatus())
.eq(bo.getModelId() != null, Agent::getModelId, bo.getModelId())
.orderByDesc(Agent::getUpdateTime);
return wrapper;
}
}

View File

@@ -0,0 +1,19 @@
package org.ruoyi.service.audio;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.chat.entity.audio.AudioContext;
import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
import org.ruoyi.common.chat.service.audio.IAudioGenerationService;
import org.springframework.validation.annotation.Validated;
@Slf4j
@Validated
public abstract class AbstractAudioGenerationService implements IAudioGenerationService {
@Override
public MediaGenerationResponse generateSpeech(AudioContext audioContext) {
return doGenerateSpeech(audioContext);
}
protected abstract MediaGenerationResponse doGenerateSpeech(AudioContext audioContext);
}

View File

@@ -0,0 +1,102 @@
package org.ruoyi.service.audio.provider;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.databind.node.ObjectNode;
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.OpenAiMediaSupport;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Base64;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component("openaiAudio")
public class OpenAiAudioGenerationServiceImpl extends AbstractAudioGenerationService {
private static final String DEFAULT_VOICE = "alloy";
private static final String DEFAULT_FORMAT = "mp3";
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();
String format = StrUtil.blankToDefault(audioContext.getResponseFormat(), DEFAULT_FORMAT);
String mimeType = mimeType(format);
ObjectNode payload = OpenAiMediaSupport.OBJECT_MAPPER.createObjectNode();
payload.put("model", model.getModelName());
payload.put("input", audioContext.getInput());
payload.put("voice", StrUtil.blankToDefault(audioContext.getVoice(), DEFAULT_VOICE));
payload.put("response_format", format);
if (audioContext.getSpeed() != null) {
payload.put("speed", audioContext.getSpeed());
}
if (StrUtil.isNotBlank(audioContext.getInstructions())) {
payload.put("instructions", audioContext.getInstructions());
}
try {
byte[] bytes = postBytes(model, "/audio/speech", payload.toString());
String b64 = Base64.getEncoder().encodeToString(bytes);
return MediaGenerationResponse.builder()
.type("audio")
.mimeType(mimeType)
.b64Json(b64)
.dataUrl(OpenAiMediaSupport.dataUrl(mimeType, b64))
.build();
} catch (IOException e) {
throw new RuntimeException("语音生成失败: " + e.getMessage(), e);
}
}
@Override
public String getProviderName() {
return ChatModeType.OPEN_AI.getCode();
}
private byte[] postBytes(ChatModelVo model, String path, String jsonBody) throws IOException {
Request request = new Request.Builder()
.url(OpenAiMediaSupport.endpoint(model.getApiHost(), path))
.addHeader("Authorization", "Bearer " + model.getApiKey())
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(jsonBody, OpenAiMediaSupport.JSON))
.build();
try (Response response = okHttpClient.newCall(request).execute()) {
ResponseBody body = response.body();
if (!response.isSuccessful()) {
String err = body == null ? "" : body.string();
throw new IllegalArgumentException("OpenAI Audio API调用失败: " + response.code() + " - " + err);
}
if (body == null) {
throw new IllegalArgumentException("OpenAI Audio API响应为空");
}
return body.bytes();
}
}
private String mimeType(String format) {
return switch (format) {
case "opus" -> "audio/opus";
case "aac" -> "audio/aac";
case "flac" -> "audio/flac";
case "wav" -> "audio/wav";
case "pcm" -> "audio/pcm";
default -> "audio/mpeg";
};
}
}

Some files were not shown because too many files have changed in this diff Show More