@@ -69,7 +69,7 @@
dev.langchain4j
langchain4j-document-parser-apache-tika
- ${langchain4j.community.version}
+ ${langchain4j.beta.version}
org.apache.commons
@@ -82,39 +82,39 @@
dev.langchain4j
langchain4j-milvus
- ${langchain4j.community.version}
+ ${langchain4j.beta.version}
dev.langchain4j
langchain4j-weaviate
- ${langchain4j.community.version}
+ ${langchain4j.beta.version}
dev.langchain4j
langchain4j-qdrant
- ${langchain4j.community.version}
+ ${langchain4j.beta.version}
dev.langchain4j
langchain4j-mcp
- ${langchain4j.community.version}
+ ${langchain4j.beta.version}
dev.langchain4j
langchain4j-skills
- ${langchain4j.community.version}
+ ${langchain4j.beta.version}
dev.langchain4j
langchain4j-experimental-skills-shell
- ${langchain4j.community.version}
+ ${langchain4j.beta.version}
@@ -155,10 +155,24 @@
${dify.version}
-
- com.zaxxer
- HikariCP
+ com.coze
+ coze-api
+ ${coze.version}
+
+
+ io.jsonwebtoken
+ jjwt-api
+
+
+ io.jsonwebtoken
+ jjwt-impl
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+
+
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ShortDramaScriptAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ShortDramaScriptAgent.java
new file mode 100644
index 00000000..7601feeb
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ShortDramaScriptAgent.java
@@ -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 结构化输出
+ *
+ * 框架自动生成 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):旁白、独白、回忆中的声音
+
+ 【剧本长度要求】
+ - scriptText:1000-3000字,含完整的开场、冲突发展、高潮、结尾
+ - outlineText:400-800字,概述完整故事线
+
+ 【角色塑造要求】
+ - 每个角色要有明确的性格标签(如:霸道总裁、温柔女医、腹黑谋士)
+ - 角色之间要有清晰的关系和冲突
+ - 对话要符合角色性格
+
+ 【情节要求】
+ - 必须有清晰的冲突和反转
+ - 情绪节奏要有起伏(紧张→舒缓→爆发)
+ - 结尾要有记忆点(反转/留白/情感升华)
+ """)
+ @UserMessage("用户期望项目名:{{projectName}}\n用户创意:{{idea}}")
+ ShortDramaScriptResult polish(@V("projectName") String projectName, @V("idea") String idea);
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/config/agent/SkillsPathResolver.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/config/agent/SkillsPathResolver.java
new file mode 100644
index 00000000..83f3cf50
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/config/agent/SkillsPathResolver.java
@@ -0,0 +1,32 @@
+package org.ruoyi.config.agent;
+
+import java.nio.file.Path;
+
+/**
+ * 磁盘 Skills 目录路径解析器
+ *
+ * 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);
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/constant/ShortDramaImageConstants.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/constant/ShortDramaImageConstants.java
new file mode 100644
index 00000000..619b7bef
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/constant/ShortDramaImageConstants.java
@@ -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 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);
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/agent/AgentController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/agent/AgentController.java
new file mode 100644
index 00000000..972b7d9d
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/agent/AgentController.java
@@ -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 list(AgentBo bo, PageQuery pageQuery) {
+ return agentService.queryPageList(bo, pageQuery);
+ }
+
+ /**
+ * 查询智能体列表(不分页,用于导出)
+ */
+ @SaCheckPermission("agent:agent:list")
+ @GetMapping("/queryList")
+ public R> 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 list = agentService.queryList(bo);
+ ExcelUtil.exportExcel(list, "智能体", AgentVo.class, response);
+ }
+
+ /**
+ * 根据ID获取智能体详情
+ */
+ @SaCheckPermission("agent:agent:query")
+ @GetMapping("/{id}")
+ public R getInfo(@PathVariable Long id) {
+ return R.ok(agentService.queryById(id));
+ }
+
+ /**
+ * 新增智能体
+ */
+ @SaCheckPermission("agent:agent:add")
+ @Log(title = "智能体管理", businessType = BusinessType.INSERT)
+ @RepeatSubmit
+ @PostMapping
+ public R add(@Validated @RequestBody AgentBo bo) {
+ return toAjax(agentService.insertByBo(bo));
+ }
+
+ /**
+ * 修改智能体
+ */
+ @SaCheckPermission("agent:agent:edit")
+ @Log(title = "智能体管理", businessType = BusinessType.UPDATE)
+ @RepeatSubmit
+ @PutMapping
+ public R edit(@Validated @RequestBody AgentBo bo) {
+ return toAjax(agentService.updateByBo(bo));
+ }
+
+ /**
+ * 删除智能体
+ */
+ @SaCheckPermission("agent:agent:remove")
+ @Log(title = "智能体管理", businessType = BusinessType.DELETE)
+ @DeleteMapping("/{ids}")
+ public R remove(@PathVariable Long[] ids) {
+ return toAjax(agentService.deleteByIds(List.of(ids)));
+ }
+
+ /**
+ * 用户端聊天页智能体下拉选项(启用状态,不需权限校验)
+ */
+ @GetMapping("/agentOptions")
+ public R> agentOptions() {
+ return R.ok(agentService.queryEnabledOptions());
+ }
+
+ /**
+ * 列出磁盘上可用的 Skills(供管理端表单勾选)
+ */
+ @SaCheckPermission("agent:agent:list")
+ @GetMapping("/skillOptions")
+ public R> skillOptions() {
+ return R.ok(agentService.listSkillOptions());
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java
index adf4322b..565cd953 100644
--- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java
@@ -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> modelList(ChatModelBo bo) {
- bo.setCategory(ModelType.CHAT.getKey());
+ if (StringUtils.isBlank(bo.getCategory())) {
+ bo.setCategory(ModelType.CHAT.getKey());
+ }
return R.ok(chatModelService.queryList(bo));
}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/MediaGenerationController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/MediaGenerationController.java
new file mode 100644
index 00000000..b2f032ab
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/MediaGenerationController.java
@@ -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 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 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 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 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 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();
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java
new file mode 100644
index 00000000..5e6d6ce6
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java
@@ -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> projects() {
+ return R.ok(shortDramaService.listProjects(LoginHelper.getUserId()));
+ }
+
+ @GetMapping("/{projectId}")
+ public R detail(@PathVariable Long projectId) {
+ return R.ok(shortDramaService.getDetail(projectId, LoginHelper.getUserId()));
+ }
+
+ @PostMapping("/create-from-idea")
+ public R 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 saveProject(@Valid @RequestBody ShortDramaProjectBo bo) {
+ return R.ok(shortDramaService.saveProject(bo, LoginHelper.getUserId()));
+ }
+
+ @PutMapping("/project")
+ public R updateProject(@Valid @RequestBody ShortDramaProjectBo bo) {
+ return R.ok(shortDramaService.saveProject(bo, LoginHelper.getUserId()));
+ }
+
+ @DeleteMapping("/project/{projectId}")
+ public R deleteProject(@NotNull @PathVariable Long projectId) {
+ shortDramaService.deleteProject(projectId, LoginHelper.getUserId());
+ return R.ok();
+ }
+
+ // ==================== 剧本 ====================
+
+ @PostMapping("/script")
+ public R saveScript(@Valid @RequestBody ShortDramaScriptBo bo) {
+ return R.ok(shortDramaService.saveScript(bo, LoginHelper.getUserId()));
+ }
+
+ // ==================== 分镜 ====================
+
+ @PostMapping("/storyboards/generate")
+ public R> 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 saveStoryboard(@Valid @RequestBody ShortDramaStoryboardBo bo) {
+ return R.ok(shortDramaService.saveStoryboard(bo, LoginHelper.getUserId()));
+ }
+
+ @PostMapping("/storyboard/{storyboardId}/generate-video")
+ public R 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 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> generateAllVideos(@NotNull @PathVariable Long projectId,
+ @NotBlank @RequestParam String model) {
+ return R.ok(shortDramaService.generateAllVideos(projectId, model, LoginHelper.getUserId()));
+ }
+
+ @PostMapping("/{projectId}/compose-video")
+ public R composeVideo(@NotNull @PathVariable Long projectId,
+ @Valid @RequestBody ShortDramaComposeVideoBo bo) {
+ return R.ok(videoComposeService.composeVideo(projectId, bo, LoginHelper.getUserId()));
+ }
+
+ @GetMapping("/{projectId}/compose-video")
+ public R 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 polishScript(@NotNull @PathVariable Long projectId) {
+ return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId()));
+ }
+
+ // ==================== 资产分析 ====================
+
+ /** Phase 2: 资产分析(角色+场景提取) */
+ @PostMapping("/{projectId}/analyze-assets")
+ public R 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> 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> 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> generateActingDirections(@NotNull @PathVariable Long projectId,
+ @NotNull @RequestParam Long scriptId) {
+ return R.ok(shortDramaService.generateActingDirections(projectId, scriptId, LoginHelper.getUserId()));
+ }
+
+ // ==================== 角色管理 ====================
+
+ @PostMapping("/character")
+ public R saveCharacter(@Valid @RequestBody ShortDramaCharacterBo bo) {
+ return R.ok(shortDramaService.saveCharacter(bo, LoginHelper.getUserId()));
+ }
+
+ @PutMapping("/character")
+ public R updateCharacter(@Valid @RequestBody ShortDramaCharacterBo bo) {
+ return R.ok(shortDramaService.saveCharacter(bo, LoginHelper.getUserId()));
+ }
+
+ @DeleteMapping("/character/{characterId}")
+ public R deleteCharacter(@NotNull @PathVariable Long characterId) {
+ shortDramaService.deleteCharacter(characterId, LoginHelper.getUserId());
+ return R.ok();
+ }
+
+ @PostMapping("/character/{characterId}/generate-image")
+ public R 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 saveAppearance(@Valid @RequestBody ShortDramaCharacterAppearanceBo bo) {
+ return R.ok(shortDramaService.saveAppearance(bo, LoginHelper.getUserId()));
+ }
+
+ @PutMapping("/character-appearance")
+ public R updateAppearance(@Valid @RequestBody ShortDramaCharacterAppearanceBo bo) {
+ return R.ok(shortDramaService.saveAppearance(bo, LoginHelper.getUserId()));
+ }
+
+ @DeleteMapping("/character-appearance/{appearanceId}")
+ public R deleteAppearance(@NotNull @PathVariable Long appearanceId) {
+ shortDramaService.deleteAppearance(appearanceId, LoginHelper.getUserId());
+ return R.ok();
+ }
+
+ @PostMapping("/character-appearance/{appearanceId}/generate-image")
+ public R 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 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 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 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 undoAppearanceImage(@NotNull @PathVariable Long appearanceId) {
+ return R.ok(shortDramaService.undoAppearanceImage(appearanceId, LoginHelper.getUserId()));
+ }
+
+ // ==================== 场景管理 ====================
+
+ @PostMapping("/location")
+ public R saveLocation(@Valid @RequestBody ShortDramaLocationBo bo) {
+ return R.ok(shortDramaService.saveLocation(bo, LoginHelper.getUserId()));
+ }
+
+ @PutMapping("/location")
+ public R updateLocation(@Valid @RequestBody ShortDramaLocationBo bo) {
+ return R.ok(shortDramaService.saveLocation(bo, LoginHelper.getUserId()));
+ }
+
+ @DeleteMapping("/location/{locationId}")
+ public R deleteLocation(@NotNull @PathVariable Long locationId) {
+ shortDramaService.deleteLocation(locationId, LoginHelper.getUserId());
+ return R.ok();
+ }
+
+ @PostMapping("/location/{locationId}/generate-image")
+ public R 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 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 selectLocationImage(@NotNull @PathVariable Long locationId,
+ @NotNull @RequestParam Integer index) {
+ return R.ok(shortDramaService.selectLocationImage(locationId, index, LoginHelper.getUserId()));
+ }
+
+ @DeleteMapping("/location/{locationId}/image")
+ public R 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 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 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 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 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 confirmLocationImage(@NotNull @PathVariable Long id,
+ @NotBlank @RequestParam String predictionId,
+ @NotBlank @RequestParam String model) {
+ return R.ok(shortDramaService.confirmLocationImage(id, predictionId, model, LoginHelper.getUserId()));
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/agent/AgentBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/agent/AgentBo.java
new file mode 100644
index 00000000..4146dde0
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/agent/AgentBo.java
@@ -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 mcpToolIds;
+
+ /**
+ * 关联磁盘技能名列表
+ */
+ @AutoMapping(target = "skillNames", expression = "java(org.ruoyi.common.json.utils.JsonUtils.toJsonString(source.getSkillNames()))")
+ private List skillNames;
+
+ /**
+ * 关联知识库ID列表
+ */
+ @AutoMapping(target = "knowledgeIds", expression = "java(org.ruoyi.common.json.utils.JsonUtils.toJsonString(source.getKnowledgeIds()))")
+ private List knowledgeIds;
+
+ /**
+ * 状态:0 正常 1 停用
+ */
+ private String status;
+
+ /**
+ * 备注
+ */
+ private String remark;
+
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/ImageGenerationRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/ImageGenerationRequest.java
new file mode 100644
index 00000000..b3c44665
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/ImageGenerationRequest.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/SpeechGenerationRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/SpeechGenerationRequest.java
new file mode 100644
index 00000000..1683bb6a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/SpeechGenerationRequest.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/VideoGenerationRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/VideoGenerationRequest.java
new file mode 100644
index 00000000..df2f45d5
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/media/VideoGenerationRequest.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterAppearanceBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterAppearanceBo.java
new file mode 100644
index 00000000..7ff73f92
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterAppearanceBo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterBo.java
new file mode 100644
index 00000000..bc988198
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaCharacterBo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaComposeVideoBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaComposeVideoBo.java
new file mode 100644
index 00000000..363fb8c4
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaComposeVideoBo.java
@@ -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 storyboardIds;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java
new file mode 100644
index 00000000..b6eeb4f7
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaLocationBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaLocationBo.java
new file mode 100644
index 00000000..23f2aa77
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaLocationBo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaProjectBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaProjectBo.java
new file mode 100644
index 00000000..ce73bec3
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaProjectBo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptBo.java
new file mode 100644
index 00000000..c4252441
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptBo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptResult.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptResult.java
new file mode 100644
index 00000000..8a61c5a5
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptResult.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaStoryboardBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaStoryboardBo.java
new file mode 100644
index 00000000..8eaf989f
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaStoryboardBo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/agent/Agent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/agent/Agent.java
new file mode 100644
index 00000000..098763e4
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/agent/Agent.java
@@ -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;
+
+/**
+ * 智能体信息实体
+ *
+ * 一个智能体聚合:一个聊天模型 + 一组 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;
+
+ /**
+ * 绑定的聊天模型ID(chat_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;
+
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacter.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacter.java
new file mode 100644
index 00000000..25472e44
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacter.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacterAppearance.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacterAppearance.java
new file mode 100644
index 00000000..b9f28848
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaCharacterAppearance.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaLocation.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaLocation.java
new file mode 100644
index 00000000..7ae05126
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaLocation.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaProject.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaProject.java
new file mode 100644
index 00000000..529c3cdd
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaProject.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaScript.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaScript.java
new file mode 100644
index 00000000..7a5b0e60
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaScript.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaStoryboard.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaStoryboard.java
new file mode 100644
index 00000000..63e3cedd
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaStoryboard.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/AgentVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/AgentVo.java
new file mode 100644
index 00000000..102d83a5
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/AgentVo.java
@@ -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;
+
+/**
+ * 智能体视图对象
+ *
+ * 注意:不使用 @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 mcpToolIds;
+
+ /**
+ * 关联MCP工具名称列表(关联展示)
+ */
+ private List mcpToolNames;
+
+ /**
+ * 关联磁盘技能名列表
+ */
+ private List skillNames;
+
+ /**
+ * 关联知识库ID列表
+ */
+ private List knowledgeIds;
+
+ /**
+ * 关联知识库名称列表(关联展示)
+ */
+ private List knowledgeNames;
+
+ /**
+ * 状态:0 正常 1 停用
+ */
+ @ExcelProperty(value = "状态")
+ private String status;
+
+ /**
+ * 备注
+ */
+ private String remark;
+
+ /**
+ * 创建时间
+ */
+ @ExcelProperty(value = "创建时间")
+ private Date createTime;
+
+ /**
+ * 更新时间
+ */
+ @ExcelProperty(value = "更新时间")
+ private Date updateTime;
+
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/SkillOptionVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/SkillOptionVo.java
new file mode 100644
index 00000000..40959059
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/agent/SkillOptionVo.java
@@ -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;
+
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterAppearanceVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterAppearanceVo.java
new file mode 100644
index 00000000..5ee0b031
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterAppearanceVo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterVo.java
new file mode 100644
index 00000000..50f77b81
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaCharacterVo.java
@@ -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 appearances;
+
+ private Date createTime;
+
+ private Date updateTime;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaComposeVideoVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaComposeVideoVo.java
new file mode 100644
index 00000000..f3004a52
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaComposeVideoVo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaDetailVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaDetailVo.java
new file mode 100644
index 00000000..7e25bf86
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaDetailVo.java
@@ -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 characters;
+
+ private List locations;
+
+ private List storyboards;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaLocationVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaLocationVo.java
new file mode 100644
index 00000000..9d7353e1
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaLocationVo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaProjectVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaProjectVo.java
new file mode 100644
index 00000000..a5cde322
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaProjectVo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaScriptVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaScriptVo.java
new file mode 100644
index 00000000..a26e6067
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaScriptVo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaStoryboardVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaStoryboardVo.java
new file mode 100644
index 00000000..da0eedc1
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaStoryboardVo.java
@@ -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;
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java
index 6279b107..b7b545bb 100644
--- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/enums/ChatModeType.java
@@ -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;
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/agent/AgentMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/agent/AgentMapper.java
new file mode 100644
index 00000000..e13ecf25
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/agent/AgentMapper.java
@@ -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 {
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterAppearanceMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterAppearanceMapper.java
new file mode 100644
index 00000000..5077c8e6
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterAppearanceMapper.java
@@ -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 {
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterMapper.java
new file mode 100644
index 00000000..49905499
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaCharacterMapper.java
@@ -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 {
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaLocationMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaLocationMapper.java
new file mode 100644
index 00000000..a4beea96
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaLocationMapper.java
@@ -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 {
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaProjectMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaProjectMapper.java
new file mode 100644
index 00000000..1841ce0e
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaProjectMapper.java
@@ -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 {
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaScriptMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaScriptMapper.java
new file mode 100644
index 00000000..e36afcc0
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaScriptMapper.java
@@ -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 {
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaStoryboardMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaStoryboardMapper.java
new file mode 100644
index 00000000..5f9c0a5d
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaStoryboardMapper.java
@@ -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 {
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/IAgentService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/IAgentService.java
new file mode 100644
index 00000000..7d48f617
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/IAgentService.java
@@ -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 queryPageList(AgentBo bo, PageQuery pageQuery);
+
+ /**
+ * 查询符合条件的智能体列表(用于导出)
+ */
+ List queryList(AgentBo bo);
+
+ /**
+ * 根据ID查询智能体(展开 JSON 数组字段为 List,关联填充模型/工具/知识库名称)
+ */
+ AgentVo queryById(Long id);
+
+ /**
+ * 新增智能体
+ */
+ Boolean insertByBo(AgentBo bo);
+
+ /**
+ * 修改智能体
+ */
+ Boolean updateByBo(AgentBo bo);
+
+ /**
+ * 批量删除智能体
+ */
+ Boolean deleteByIds(Collection ids);
+
+ /**
+ * 查询启用的智能体下拉选项(用户端聊天页选择用,status=0)
+ */
+ List queryEnabledOptions();
+
+ /**
+ * 列出磁盘上可用的 Skills(供管理端表单勾选用)
+ */
+ List listSkillOptions();
+
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/impl/AgentServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/impl/AgentServiceImpl.java
new file mode 100644
index 00000000..1a67e3bf
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/agent/impl/AgentServiceImpl.java
@@ -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;
+
+/**
+ * 智能体服务实现
+ *
+ * 注意: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 queryPageList(AgentBo bo, PageQuery pageQuery) {
+ LambdaQueryWrapper wrapper = buildQueryWrapper(bo);
+ Page page = baseMapper.selectPage(pageQuery.build(), wrapper);
+ List records = page.getRecords() == null ? List.of()
+ : page.getRecords().stream().map(this::toVo).toList();
+ Page voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
+ voPage.setRecords(records);
+ return TableDataInfo.build(voPage);
+ }
+
+ @Override
+ public List queryList(AgentBo bo) {
+ LambdaQueryWrapper wrapper = buildQueryWrapper(bo);
+ List 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 ids) {
+ return baseMapper.deleteByIds(ids) > 0;
+ }
+
+ @Override
+ public List queryEnabledOptions() {
+ LambdaQueryWrapper 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 listSkillOptions() {
+ try {
+ List 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 toolIds = parseLongArray(entity.getMcpToolIds());
+ List skillNames = parseStringArray(entity.getSkillNames());
+ List 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 tools = mcpToolMapper.selectByIds(toolIds);
+ if (tools != null) {
+ vo.setMcpToolNames(tools.stream().map(McpTool::getName).toList());
+ }
+ }
+ // 知识库名称
+ if (knowledgeIds != null && !knowledgeIds.isEmpty()) {
+ List 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 parseLongArray(String json) {
+ if (!StringUtils.hasText(json)) {
+ return new ArrayList<>();
+ }
+ List list = JsonUtils.parseArray(json, Long.class);
+ return list == null ? new ArrayList<>() : list;
+ }
+
+ private List parseStringArray(String json) {
+ if (!StringUtils.hasText(json)) {
+ return new ArrayList<>();
+ }
+ List list = JsonUtils.parseArray(json, String.class);
+ return list == null ? new ArrayList<>() : list;
+ }
+
+ private LambdaQueryWrapper buildQueryWrapper(AgentBo bo) {
+ LambdaQueryWrapper 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;
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/AbstractAudioGenerationService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/AbstractAudioGenerationService.java
new file mode 100644
index 00000000..eb1b5675
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/AbstractAudioGenerationService.java
@@ -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);
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/OpenAiAudioGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/OpenAiAudioGenerationServiceImpl.java
new file mode 100644
index 00000000..cc406fa9
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/OpenAiAudioGenerationServiceImpl.java
@@ -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";
+ };
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java
index 16e0750d..020f5006 100644
--- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java
@@ -6,6 +6,7 @@ import dev.langchain4j.agentic.supervisor.SupervisorAgent;
import dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
+import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.mcp.McpToolProvider;
import dev.langchain4j.mcp.client.DefaultMcpClient;
@@ -17,14 +18,18 @@ import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.StreamingChatModel;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
-import dev.langchain4j.model.openai.OpenAiChatModel;
+import dev.langchain4j.rag.content.Content;
+import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.service.tool.ToolProvider;
+import dev.langchain4j.skills.FileSystemSkill;
+import dev.langchain4j.skills.FileSystemSkillLoader;
import dev.langchain4j.skills.shell.ShellSkills;
import dev.langchain4j.rag.AugmentationRequest;
import dev.langchain4j.rag.AugmentationResult;
import dev.langchain4j.rag.DefaultRetrievalAugmentor;
import dev.langchain4j.rag.RetrievalAugmentor;
import dev.langchain4j.rag.query.Metadata;
+import dev.langchain4j.rag.query.Query;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
@@ -38,7 +43,6 @@ import org.ruoyi.agent.tool.QueryAllTablesTool;
import org.ruoyi.agent.tool.QueryTableSchemaTool;
import org.ruoyi.common.chat.base.ThreadContext;
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
-import org.ruoyi.common.chat.domain.dto.request.ReSumeRunner;
import org.ruoyi.common.chat.domain.dto.request.WorkFlowRunner;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.chat.enums.RoleType;
@@ -50,11 +54,15 @@ import org.ruoyi.common.core.utils.StringUtils;
import org.ruoyi.common.satoken.utils.LoginHelper;
import org.ruoyi.common.sse.core.SseEmitterManager;
import org.ruoyi.common.sse.utils.SseMessageUtils;
+import org.ruoyi.config.agent.SkillsPathResolver;
import org.ruoyi.domain.bo.vector.QueryVectorBo;
+import org.ruoyi.domain.vo.agent.AgentVo;
import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo;
import org.ruoyi.factory.ChatServiceFactory;
+import org.ruoyi.mcp.service.core.LangChain4jMcpToolProviderService;
import org.ruoyi.mcp.service.core.ToolProviderFactory;
import org.ruoyi.observability.*;
+import org.ruoyi.service.agent.IAgentService;
import org.ruoyi.service.chat.AbstractChatService;
import org.ruoyi.service.chat.IChatMessageService;
import org.ruoyi.service.chat.impl.memory.PersistentChatMemoryStore;
@@ -106,6 +114,10 @@ public class ChatServiceFacade implements IChatService {
private final ToolProviderFactory toolProviderFactory;
+ private final IAgentService agentService;
+
+ private final LangChain4jMcpToolProviderService langChain4jMcpToolProviderService;
+
/**
* 内存实例缓存,避免同一会话重复创建
* Key: sessionId, Value: MessageWindowChatMemory实例
@@ -127,6 +139,21 @@ public class ChatServiceFacade implements IChatService {
String tokenValue = StpUtil.getTokenValue();
SseEmitter emitter = sseEmitterManager.connect(userId, tokenValue);
+ // 0. 智能体解析:传入 agentId 时按智能体绑定的模型覆盖 model 字段
+ // (前端默认走智能体;enableThinking 不再作为对话模式开关,Supervisor 多 Agent 编排成为默认智能体路径)
+ AgentVo agentVo = null;
+ if (chatRequest.getAgentId() != null) {
+ agentVo = agentService.queryById(chatRequest.getAgentId());
+ if (agentVo != null && agentVo.getModelId() != null) {
+ ChatModelVo agentModel = chatModelService.queryById(agentVo.getModelId());
+ if (agentModel != null) {
+ chatRequest.setModel(agentModel.getModelName());
+ }
+ } else {
+ log.warn("智能体不存在或未配置模型,回退到 model 字段: agentId={}", chatRequest.getAgentId());
+ }
+ }
+
// 1. 根据模型名称查询完整配置
ChatModelVo chatModelVo = chatModelService.selectModelByName(chatRequest.getModel());
if (chatModelVo == null) {
@@ -134,7 +161,7 @@ public class ChatServiceFacade implements IChatService {
}
// 2. 构建上下文消息列表
- List contextMessages = buildContextMessages(chatRequest);
+ List contextMessages = buildContextMessages(chatRequest, agentVo);
chatRequest.setEmitter(emitter);
chatRequest.setUserId(userId);
@@ -145,37 +172,21 @@ public class ChatServiceFacade implements IChatService {
// 保存用户消息
chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), chatRequest.getContent(), RoleType.USER.getName(), chatRequest.getModel());
- // 3. 处理特殊聊天模式(工作流、人机交互恢复、思考模式)
- SseEmitter sseEmitter = handleSpecialChatModes(chatRequest);
- if (sseEmitter != null) {
- return sseEmitter;
- }
-
- // 4. 路由服务提供商
- String providerCode = chatModelVo.getProviderCode();
- log.info("路由到服务提供商: {}, 模型: {}", providerCode, chatRequest.getModel());
- AbstractChatService chatService = chatServiceFactory.getOriginalService(providerCode);
-
- StreamingChatResponseHandler handler = createResponseHandler(userId, tokenValue,chatRequest);
-
-
- // 5. 发起对话
- StreamingChatModel streamingChatModel = chatService.buildStreamingChatModel(chatModelVo, chatRequest);
- streamingChatModel.chat(contextMessages, handler);
- return emitter;
+ // 3. 路由对话模式:工作流对话 / 智能体对话(两者均返回各自的 SseEmitter)
+ return handleSpecialChatModes(chatRequest, agentVo);
}
/**
- * 处理特殊聊天模式(工作流、人机交互恢复、思考模式)
+ * 路由对话模式:仅两种情况——工作流对话 / 智能体对话。
*
- * @param chatRequest 聊天请求
- * @return 如果需要提前返回则返回SseEmitter,否则返回null
+ * @param chatRequest 聊天请求
+ * @param agentVo 智能体配置(可为 null)
+ * @return 对应模式的 SseEmitter
*/
- private SseEmitter handleSpecialChatModes(ChatRequest chatRequest) {
- // 处理工作流对话
- if (chatRequest.getEnableWorkFlow()) {
+ private SseEmitter handleSpecialChatModes(ChatRequest chatRequest, AgentVo agentVo) {
+ // 模式1:工作流对话(前端应用市场选工作流后携带 workFlowRunner)
+ if (Boolean.TRUE.equals(chatRequest.getEnableWorkFlow())) {
log.info("处理工作流对话,会话: {}", chatRequest.getSessionId());
-
WorkFlowRunner runner = chatRequest.getWorkFlowRunner();
if (ObjectUtils.isEmpty(runner)) {
log.warn("工作流参数为空");
@@ -187,98 +198,36 @@ public class ChatServiceFacade implements IChatService {
chatRequest.getSessionId()
);
}
-
- // 处理人机交互恢复
- if (chatRequest.getIsResume()) {
- log.info("处理人机交互恢复");
- ReSumeRunner reSumeRunner = chatRequest.getReSumeRunner();
- if (ObjectUtils.isEmpty(reSumeRunner)) {
- log.warn("人机交互恢复参数为空");
- }
- workFlowStarterService.resumeFlow(
- reSumeRunner.getRuntimeUuid(),
- reSumeRunner.getFeedbackContent(),
- chatRequest.getEmitter()
- );
-
- return chatRequest.getEmitter();
-
- }
- // 处理思考模式
- if (chatRequest.getEnableThinking()) {
- return handleThinkingMode(chatRequest);
- }
-
- return null;
+ // 模式2:智能体对话(默认走 Supervisor 多 Agent 编排)
+ return handleAgentChat(chatRequest, agentVo);
}
/**
- * 处理思考模式
+ * 智能体对话模式(默认):构建 Supervisor 多 Agent 编排并异步执行,结果通过 SSE 推送。
*
- * @param chatRequest 聊天请求
-
+ * @param chatRequest 聊天请求
+ * @param agentVo 智能体配置(可为 null,无智能体时用请求 model 兜底)
*/
- private SseEmitter handleThinkingMode(ChatRequest chatRequest) {
- // 配置监督者模型
- OpenAiChatModel plannerModel = OpenAiChatModel.builder()
- .baseUrl(chatRequest.getChatModelVo().getApiHost())
- .apiKey(chatRequest.getChatModelVo().getApiKey())
- .modelName(chatRequest.getChatModelVo().getModelName())
- .build();
+ private SseEmitter handleAgentChat(ChatRequest chatRequest, AgentVo agentVo) {
+ ChatModelVo chatModelVo = chatRequest.getChatModelVo();
- // Bing 搜索 MCP 客户端
- McpTransport bingTransport = new StdioMcpTransport.Builder()
- .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "bing-cn-mcp"))
- .logEvents(true)
- .build();
+ // 配置监督者模型:统一按 providerCode 走对应 AbstractChatService.buildChatModel,
+ // 兼容 ZhiPu/QianWen/Ollama/Dify/Coze/CustomApi 等非 OpenAI 协议;默认实现为 OpenAI 兼容。
+ AbstractChatService chatService = chatServiceFactory.getOriginalService(chatModelVo.getProviderCode());
+ ChatModel plannerModel = chatService.buildChatModel(chatModelVo);
Long userId = chatRequest.getUserId();
- McpClient bingMcpClient = new DefaultMcpClient.Builder()
- .transport(bingTransport)
- .listener(new MyMcpClientListener(userId))
- .build();
- // Playwright MCP 客户端 - 浏览器自动化工具
- McpTransport playwrightTransport = new StdioMcpTransport.Builder()
- .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "@playwright/mcp@latest"))
- .logEvents(true)
- .build();
+ // 工具装配:智能体有关联工具ID时按ID装配,否则回退到原有硬编码 MCP 客户端
+ ToolProvider toolProvider;
+ if (agentVo != null && agentVo.getMcpToolIds() != null && !agentVo.getMcpToolIds().isEmpty()) {
+ toolProvider = langChain4jMcpToolProviderService.getToolProvider(agentVo.getMcpToolIds());
+ } else {
+ toolProvider = buildDefaultMcpToolProvider(userId);
+ }
- McpClient playwrightMcpClient = new DefaultMcpClient.Builder()
- .transport(playwrightTransport)
- .listener(new MyMcpClientListener(userId))
- .build();
-
- // Filesystem MCP 客户端 - 文件管理工具
- // 允许 AI 读取、写入、搜索文件(基于当前项目根目录)
- String userDir = System.getProperty("user.dir");
- McpTransport filesystemTransport = new StdioMcpTransport.Builder()
- .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y",
- "@modelcontextprotocol/server-filesystem", userDir))
- .logEvents(true)
-
- .build();
-
- McpClient filesystemMcpClient = new DefaultMcpClient.Builder()
- .transport(filesystemTransport)
- .listener(new MyMcpClientListener(userId))
- .build();
-
- // 合并三个 MCP 客户端的工具
- ToolProvider toolProvider = McpToolProvider.builder()
- // bingMcpClient,
- .mcpClients(List.of(playwrightMcpClient, filesystemMcpClient))
- .build();
-
- // ========== LangChain4j Skills 基本用法 ==========
- // 通过 SKILL.md 文件定义,LLM 按需通过 activate_skill 工具加载
- // 加载 Skills - 使用相对路径,基于项目根目录
- java.nio.file.Path skillsPath = java.nio.file.Path.of(userDir, "ruoyi-admin/src/main/resources/skills");
- List skillsList = dev.langchain4j.skills.FileSystemSkillLoader
- .loadSkills(skillsPath)
- ;
-
- ShellSkills skills = ShellSkills.from(skillsList);
+ // Skills 装配:智能体有勾选技能名时按名过滤磁盘 skills,否则加载全部
+ ShellSkills skills = buildShellSkills(agentVo);
// 构建子 Agent
WebSearchAgent searchAgent = AgenticServices.agentBuilder(WebSearchAgent.class)
@@ -287,14 +236,16 @@ public class ChatServiceFacade implements IChatService {
.listener(new MyAgentListener())
.build();
- // 构建子 Agent 2: SkillsAgent - 负责文档处理技能(docx、pdf、xlsx)
- // 独立管理 Skills 工具
- SkillsAgent skillsAgent = AgenticServices.agentBuilder(SkillsAgent.class)
- .chatModel(plannerModel)
- .systemMessage("You have access to the following skills:\n" + skills.formatAvailableSkills()
- + "\nWhen the user's request relates to one of these skills, activate it first using the `activate_skill` tool before proceeding.")
- .toolProvider(skills.toolProvider())
- .build();
+ // SkillsAgent:仅当有可用 skills 时才注入 systemMessage + toolProvider
+ var skillsAgentBuilder = AgenticServices.agentBuilder(SkillsAgent.class)
+ .chatModel(plannerModel);
+ if (skills != null) {
+ skillsAgentBuilder
+ .systemMessage("You have access to the following skills:\n" + skills.formatAvailableSkills()
+ + "\nWhen the user's request relates to one of these skills, activate it first using the `activate_skill` tool before proceeding.")
+ .toolProvider(skills.toolProvider());
+ }
+ SkillsAgent skillsAgent = skillsAgentBuilder.build();
// 构建子 Agent 3: SqlAgent - 负责数据库查询
SqlAgent sqlAgent = AgenticServices.agentBuilder(SqlAgent.class)
@@ -317,23 +268,32 @@ public class ChatServiceFacade implements IChatService {
.build();
// 构建监督者 Agent - 管理多个子 Agent
- SupervisorAgent supervisor = AgenticServices.supervisorBuilder()
+ var supervisorBuilder = AgenticServices.supervisorBuilder()
.chatModel(plannerModel)
- //.listener(new SupervisorStreamListener(null))
- .subAgents(skillsAgent,searchAgent, sqlAgent, chartGenerationAgent, echartsAgent)
- // 加入历史上下文 - 使用 ChatMemoryProvider 提供持久化的聊天内存
- //.chatMemoryProvider(memoryId -> createChatMemory(chatRequest.getSessionId()))
- .responseStrategy(SupervisorResponseStrategy.LAST)
- .build();
+ .subAgents(skillsAgent, searchAgent, sqlAgent, chartGenerationAgent, echartsAgent)
+ .responseStrategy(SupervisorResponseStrategy.LAST);
+ SupervisorAgent supervisor = supervisorBuilder.build();
+
+ // 知识库增强:智能体绑定了知识库时,对 supervisor 输入做一次 RAG 增强
+ String augmentedInput = augmentAgentInput(chatRequest, agentVo);
+ // 智能体自定义系统提示词:supervisor builder 不支持 systemMessage,前置到输入
+ String prompt = (agentVo != null && StringUtils.isNotBlank(agentVo.getSystemPrompt()))
+ ? agentVo.getSystemPrompt() + "\n\n" + augmentedInput
+ : augmentedInput;
String tokenValue = chatRequest.getTokenValue();
// 异步执行 supervisor,避免阻塞 HTTP 请求线程导致 SSE 事件被缓冲
CompletableFuture.runAsync(() -> {
try {
- String result = supervisor.invoke(chatRequest.getContent());
+ String result = supervisor.invoke(prompt);
SseMessageUtils.sendContent(userId, result);
SseMessageUtils.sendDone(userId);
+ // 保存助手回复到数据库(智能体对话为默认路径后,需在此落库以保留历史)
+ if (StringUtils.isNotBlank(result)) {
+ chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(),
+ result, RoleType.ASSISTANT.getName(), chatRequest.getModel());
+ }
} catch (Exception e) {
log.error("Supervisor 执行失败", e);
SseMessageUtils.sendError(userId, e.getMessage());
@@ -344,6 +304,82 @@ public class ChatServiceFacade implements IChatService {
return chatRequest.getEmitter();
}
+ /**
+ * 兜底 MCP 工具装配(无智能体时使用,保留原有 3 个硬编码客户端逻辑)
+ */
+ private ToolProvider buildDefaultMcpToolProvider(Long userId) {
+ McpTransport playwrightTransport = new StdioMcpTransport.Builder()
+ .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "@playwright/mcp@latest"))
+ .logEvents(true)
+ .build();
+ McpClient playwrightMcpClient = new DefaultMcpClient.Builder()
+ .transport(playwrightTransport)
+ .listener(new MyMcpClientListener(userId))
+ .build();
+
+ String userDir = System.getProperty("user.dir");
+ McpTransport filesystemTransport = new StdioMcpTransport.Builder()
+ .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y",
+ "@modelcontextprotocol/server-filesystem", userDir))
+ .logEvents(true)
+ .build();
+ McpClient filesystemMcpClient = new DefaultMcpClient.Builder()
+ .transport(filesystemTransport)
+ .listener(new MyMcpClientListener(userId))
+ .build();
+
+ return McpToolProvider.builder()
+ .mcpClients(List.of(playwrightMcpClient, filesystemMcpClient))
+ .build();
+ }
+
+ /**
+ * 装配磁盘 ShellSkills:智能体勾选了技能名时按名过滤,否则加载全部。
+ * 无 skills 时返回 null(调用方据此跳过 SkillsAgent 的 toolProvider 注入)
+ */
+ private ShellSkills buildShellSkills(AgentVo agentVo) {
+ java.nio.file.Path skillsPath = SkillsPathResolver.resolveSkillsPath();
+ List skillsList = FileSystemSkillLoader.loadSkills(skillsPath);
+ if (skillsList == null || skillsList.isEmpty()) {
+ return null;
+ }
+ if (agentVo != null && agentVo.getSkillNames() != null && !agentVo.getSkillNames().isEmpty()) {
+ skillsList = skillsList.stream()
+ .filter(s -> agentVo.getSkillNames().contains(s.name()))
+ .toList();
+ if (skillsList.isEmpty()) {
+ return null;
+ }
+ }
+ return ShellSkills.from(skillsList);
+ }
+
+ /**
+ * 智能体对话下的输入增强:智能体绑定知识库时,对原始 content 做多知识库 RAG 增强。
+ * 无知识库时原样返回 content。
+ */
+ private String augmentAgentInput(ChatRequest chatRequest, AgentVo agentVo) {
+ String content = chatRequest.getContent();
+ List knowledgeIds = collectKnowledgeIds(chatRequest, agentVo);
+ if (knowledgeIds == null || knowledgeIds.isEmpty()) {
+ return content;
+ }
+ try {
+ RetrievalAugmentor augmentor = buildMultiKnowledgeAugmentor(knowledgeIds);
+ if (augmentor == null) {
+ return content;
+ }
+ UserMessage userMessage = UserMessage.userMessage(content);
+ Metadata metadata = Metadata.from(userMessage, chatRequest.getSessionId(), new ArrayList<>());
+ AugmentationResult result = augmentor.augment(new AugmentationRequest(userMessage, metadata));
+ ChatMessage augmented = result.chatMessage();
+ return augmented instanceof UserMessage ? ((UserMessage) augmented).singleText() : content;
+ } catch (Exception e) {
+ log.warn("智能体对话 RAG 增强失败,回退原始输入: {}", e.getMessage());
+ return content;
+ }
+ }
+
/**
* 支持外部 handler 的对话接口(跨模块调用)
* 同时发送到 SSE 和外部 handler
@@ -418,44 +454,36 @@ public class ChatServiceFacade implements IChatService {
/**
* 构建上下文消息列表
- * 消息顺序:历史消息 → 当前用户消息(确保 AI 正确理解对话上下文)
+ * 消息顺序:系统提示词 → 历史消息 → 当前用户消息(确保 AI 正确理解对话上下文)
*
* @param chatRequest 聊天请求
+ * @param agentVo 智能体配置(可为 null)
* @return 上下文消息列表
*/
- private List buildContextMessages(ChatRequest chatRequest) {
+ private List buildContextMessages(ChatRequest chatRequest, AgentVo agentVo) {
List messages = new ArrayList<>();
+ // 0. 智能体自定义系统提示词(普通对话今天无 SystemMessage,这里新增注入点)
+ if (agentVo != null && StringUtils.isNotBlank(agentVo.getSystemPrompt())) {
+ messages.add(SystemMessage.from(agentVo.getSystemPrompt()));
+ }
+
// 1. 初始化当前用户消息
UserMessage userMessage = UserMessage.userMessage(chatRequest.getContent());
- // 2. 知识库检索增强 (RAG)
- if (chatRequest.getKnowledgeId() != null) {
- KnowledgeInfoVo knowledgeInfoVo = knowledgeInfoService.queryById(Long.valueOf(chatRequest.getKnowledgeId()));
- if (knowledgeInfoVo != null) {
- ChatModelVo chatModel = chatModelService.selectModelByName(knowledgeInfoVo.getEmbeddingModel());
- if (chatModel != null) {
- log.info("执行高级 RAG 流程: kid={}", chatRequest.getKnowledgeId());
-
- // 构建自定义检索器
- CustomVectorRetriever retriever = new CustomVectorRetriever(
- knowledgeRetrievalService, knowledgeInfoVo, chatModel);
-
- // 构建增强流水线
- RetrievalAugmentor augmentor = DefaultRetrievalAugmentor.builder()
- .contentRetriever(retriever)
- .build();
-
- // 执行增强:编织上下文到 UserMessage
- Metadata metadata = Metadata.from(userMessage, chatRequest.getSessionId(), new ArrayList<>());
- AugmentationRequest augmentationRequest = new AugmentationRequest(userMessage, metadata);
- AugmentationResult result = augmentor.augment(augmentationRequest);
-
- ChatMessage augmented = result.chatMessage();
- if (augmented instanceof UserMessage) {
- userMessage = (UserMessage) augmented;
- log.debug("RAG 增强完成,UserMessage 已注入背景知识");
- }
+ // 2. 知识库检索增强 (RAG):智能体的 knowledgeIds 优先,回退到请求的 knowledgeId
+ List knowledgeIds = collectKnowledgeIds(chatRequest, agentVo);
+ if (knowledgeIds != null && !knowledgeIds.isEmpty()) {
+ RetrievalAugmentor augmentor = buildMultiKnowledgeAugmentor(knowledgeIds);
+ if (augmentor != null) {
+ log.info("执行多知识库 RAG 流程: kids={}", knowledgeIds);
+ Metadata metadata = Metadata.from(userMessage, chatRequest.getSessionId(), new ArrayList<>());
+ AugmentationRequest augmentationRequest = new AugmentationRequest(userMessage, metadata);
+ AugmentationResult result = augmentor.augment(augmentationRequest);
+ ChatMessage augmented = result.chatMessage();
+ if (augmented instanceof UserMessage) {
+ userMessage = (UserMessage) augmented;
+ log.debug("RAG 增强完成,UserMessage 已注入背景知识");
}
}
}
@@ -478,6 +506,87 @@ public class ChatServiceFacade implements IChatService {
return messages;
}
+ /**
+ * 汇总本次对话要检索的知识库ID列表:智能体绑定的 knowledgeIds 优先,回退到请求的 knowledgeId
+ */
+ private List collectKnowledgeIds(ChatRequest chatRequest, AgentVo agentVo) {
+ if (agentVo != null && agentVo.getKnowledgeIds() != null && !agentVo.getKnowledgeIds().isEmpty()) {
+ return agentVo.getKnowledgeIds();
+ }
+ if (StringUtils.isNotBlank(chatRequest.getKnowledgeId())) {
+ try {
+ return List.of(Long.valueOf(chatRequest.getKnowledgeId()));
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ return List.of();
+ }
+
+ /**
+ * 构建多知识库复合检索增强器。
+ * 单知识库直接用 DefaultRetrievalAugmentor + CustomVectorRetriever;
+ * 多知识库用一个复合 ContentRetriever 合并各库检索结果。
+ */
+ private RetrievalAugmentor buildMultiKnowledgeAugmentor(List knowledgeIds) {
+ if (knowledgeIds == null || knowledgeIds.isEmpty()) {
+ return null;
+ }
+ List retrievers = new ArrayList<>();
+ for (Long kid : knowledgeIds) {
+ try {
+ KnowledgeInfoVo kb = knowledgeInfoService.queryById(kid);
+ if (kb == null) {
+ continue;
+ }
+ ChatModelVo embModel = chatModelService.selectModelByName(kb.getEmbeddingModel());
+ if (embModel == null) {
+ log.warn("知识库向量模型未配置或不存在: kid={}, embeddingModel={}", kid, kb.getEmbeddingModel());
+ continue;
+ }
+ retrievers.add(new CustomVectorRetriever(knowledgeRetrievalService, kb, embModel));
+ } catch (Exception e) {
+ log.warn("构建知识库检索器失败: kid={}, err={}", kid, e.getMessage());
+ }
+ }
+ if (retrievers.isEmpty()) {
+ return null;
+ }
+ // 单库直接返回;多库用复合检索器
+ ContentRetriever composite = retrievers.size() == 1
+ ? retrievers.get(0)
+ : new CompositeContentRetriever(retrievers);
+ return DefaultRetrievalAugmentor.builder()
+ .contentRetriever(composite)
+ .build();
+ }
+
+ /**
+ * 复合内容检索器:对多个知识库检索器并发查询并合并结果
+ */
+ private static class CompositeContentRetriever implements ContentRetriever {
+ private final List delegates;
+
+ CompositeContentRetriever(List delegates) {
+ this.delegates = delegates;
+ }
+
+ @Override
+ public List retrieve(Query query) {
+ List all = new ArrayList<>();
+ for (ContentRetriever r : delegates) {
+ try {
+ List part = r.retrieve(query);
+ if (part != null) {
+ all.addAll(part);
+ }
+ } catch (Exception e) {
+ log.warn("复合检索子检索器异常: {}", e.getMessage());
+ }
+ }
+ return all;
+ }
+ }
+
/**
* 构建向量查询参数
*/
@@ -501,62 +610,6 @@ public class ChatServiceFacade implements IChatService {
return queryVectorBo;
}
- /**
- * 创建标准的响应处理器
- *
- * @param userId 用户ID
- * @param tokenValue 会话令牌
- * @return 标准的流式响应处理器
- */
- protected StreamingChatResponseHandler createResponseHandler(Long userId, String tokenValue,ChatRequest chatRequest) {
- return new StreamingChatResponseHandler() {
-
- private final StringBuilder messageBuffer = new StringBuilder();
-
- @SneakyThrows
- @Override
- public void onPartialResponse(String partialResponse) {
- // 将消息片段追加到缓冲区
- messageBuffer.append(partialResponse);
-
- // 实时发送内容事件到客户端
- SseMessageUtils.sendContent(userId, partialResponse);
- log.debug("收到消息片段: {}", partialResponse);
- }
-
- @Override
- public void onCompleteResponse(ChatResponse completeResponse) {
- try {
- // 发送完成事件
- SseMessageUtils.sendDone(userId);
-
- // 消息流完成,保存消息到数据库和内存
- String fullMessage = messageBuffer.toString();
-
- if (fullMessage.isEmpty()) {
- log.warn("接收到空消息");
- } else {
- // 保存助手回复消息
- chatMessageService.saveChatMessage(userId, chatRequest.getSessionId(), fullMessage, RoleType.ASSISTANT.getName(), chatRequest.getModel());
- }
-
- // 关闭SSE连接
- SseMessageUtils.completeConnection(userId, tokenValue);
- log.info("消息结束,已保存到数据库");
- } catch (Exception e) {
- log.error("完成响应时出错: {}", e.getMessage(), e);
- }
- }
-
- @Override
- public void onError(Throwable error) {
- // 发送错误事件
- SseMessageUtils.sendError(userId, error.getMessage());
- log.error("流式响应错误: {}", error.getMessage());
- }
- };
- }
-
/**
* 创建组合响应处理器 - 同时发送到 SSE 和外部 handler
*
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImpl.java
new file mode 100644
index 00000000..40bcb23f
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImpl.java
@@ -0,0 +1,409 @@
+package org.ruoyi.service.chat.impl.provider;
+
+import cn.hutool.core.util.StrUtil;
+import com.coze.openapi.client.chat.CreateChatReq;
+import com.coze.openapi.client.chat.model.Chat;
+import com.coze.openapi.client.chat.model.ChatError;
+import com.coze.openapi.client.chat.model.ChatEvent;
+import com.coze.openapi.client.chat.model.ChatEventType;
+import com.coze.openapi.client.chat.model.ChatUsage;
+import com.coze.openapi.client.connversations.message.model.Message;
+import com.coze.openapi.service.auth.TokenAuth;
+import com.coze.openapi.service.service.CozeAPI;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.ChatMessage;
+import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.ModelProvider;
+import dev.langchain4j.model.chat.Capability;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.StreamingChatModel;
+import dev.langchain4j.model.chat.listener.ChatModelListener;
+import dev.langchain4j.model.chat.request.ChatRequestParameters;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
+import dev.langchain4j.model.output.FinishReason;
+import dev.langchain4j.model.output.TokenUsage;
+import lombok.extern.slf4j.Slf4j;
+import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
+import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
+import org.ruoyi.enums.ChatModeType;
+import org.ruoyi.observability.MyChatModelListener;
+import org.ruoyi.service.chat.AbstractChatService;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Coze bot chat service.
+ *
+ * In model config, modelName stores the Coze bot ID, apiHost stores the Coze API host,
+ * and apiKey stores the PAT or OAuth access token.
+ */
+@Service
+@Slf4j
+public class CozeChatServiceImpl implements AbstractChatService {
+
+ @Override
+ public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
+ return new CozeLangChain4jChatModel(chatModelVo, chatRequest);
+ }
+
+ @Override
+ public ChatModel buildChatModel(ChatModelVo chatModelVo) {
+ return new CozeLangChain4jChatModel(chatModelVo, null);
+ }
+
+ @Override
+ public String getProviderName() {
+ return ChatModeType.COZE.getCode();
+ }
+
+ private static class CozeLangChain4jChatModel implements StreamingChatModel, ChatModel {
+
+ private static final int CONNECT_TIMEOUT_MS = 10_000;
+ private static final int READ_TIMEOUT_MS = 300_000;
+
+ private final ChatModelVo chatModelVo;
+ private final ChatRequest runtimeRequest;
+ private final List listeners = List.of(new MyChatModelListener());
+
+ private CozeLangChain4jChatModel(ChatModelVo chatModelVo, ChatRequest runtimeRequest) {
+ this.chatModelVo = chatModelVo;
+ this.runtimeRequest = runtimeRequest;
+ }
+
+ @Override
+ public void doChat(dev.langchain4j.model.chat.request.ChatRequest request,
+ StreamingChatResponseHandler handler) {
+ StringBuilder answerBuffer = new StringBuilder();
+ AtomicBoolean finished = new AtomicBoolean(false);
+ AtomicReference messageId = new AtomicReference<>();
+ AtomicReference completedChat = new AtomicReference<>();
+ CozeAPI coze = null;
+ try {
+ coze = createClient();
+ coze.chat().stream(toCozeRequest(request)).blockingForEach(event ->
+ handleEvent(event, answerBuffer, messageId, completedChat, finished, handler, null)
+ );
+ completeOnce(finished, handler, answerBuffer.toString(), messageId.get(), completedChat.get());
+ } catch (Throwable e) {
+ errorOnce(finished, handler, e);
+ } finally {
+ shutdown(coze);
+ }
+ }
+
+ @Override
+ public ChatResponse doChat(dev.langchain4j.model.chat.request.ChatRequest request) {
+ StringBuilder answerBuffer = new StringBuilder();
+ AtomicBoolean finished = new AtomicBoolean(false);
+ AtomicReference messageId = new AtomicReference<>();
+ AtomicReference completedChat = new AtomicReference<>();
+ AtomicReference failure = new AtomicReference<>();
+ CozeAPI coze = null;
+ try {
+ coze = createClient();
+ coze.chat().stream(toCozeRequest(request)).blockingForEach(event ->
+ handleEvent(event, answerBuffer, messageId, completedChat, finished, null, failure)
+ );
+ if (failure.get() != null) {
+ throw failure.get();
+ }
+ return toChatResponse(answerBuffer.toString(), messageId.get(), completedChat.get());
+ } catch (RuntimeException e) {
+ throw e;
+ } catch (Throwable e) {
+ throw new IllegalStateException("Coze call failed", e);
+ } finally {
+ shutdown(coze);
+ }
+ }
+
+ @Override
+ public ChatRequestParameters defaultRequestParameters() {
+ return ChatRequestParameters.builder()
+ .modelName(chatModelVo.getModelName())
+ .build();
+ }
+
+ @Override
+ public List listeners() {
+ return listeners;
+ }
+
+ @Override
+ public ModelProvider provider() {
+ return ModelProvider.OTHER;
+ }
+
+ @Override
+ public Set supportedCapabilities() {
+ return Set.of();
+ }
+
+ private CozeAPI createClient() {
+ return new CozeAPI.Builder()
+ .baseURL(normalizeBaseUrl(chatModelVo.getApiHost()))
+ .auth(new TokenAuth(requiredApiKey()))
+ .connectTimeout(CONNECT_TIMEOUT_MS)
+ .readTimeout(READ_TIMEOUT_MS)
+ .build();
+ }
+
+ private CreateChatReq toCozeRequest(dev.langchain4j.model.chat.request.ChatRequest request) {
+ return CreateChatReq.builder()
+ .botID(requiredBotId())
+ .userID(resolveUser())
+ .messages(toCozeMessages(request.messages()))
+ .autoSaveHistory(Boolean.FALSE)
+ .build();
+ }
+
+ private List toCozeMessages(List messages) {
+ if (messages == null || messages.isEmpty()) {
+ return List.of(Message.buildUserQuestionText(""));
+ }
+
+ List cozeMessages = new ArrayList<>();
+ StringBuilder systemMessages = new StringBuilder();
+ for (ChatMessage message : messages) {
+ if (message instanceof SystemMessage systemMessage) {
+ appendBlock(systemMessages, systemMessage.text());
+ continue;
+ }
+
+ String text = messageText(message);
+ if (StrUtil.isBlank(text)) {
+ continue;
+ }
+ if (message instanceof AiMessage) {
+ cozeMessages.add(Message.buildAssistantAnswer(text));
+ } else {
+ cozeMessages.add(Message.buildUserQuestionText(text));
+ }
+ }
+
+ if (!systemMessages.isEmpty()) {
+ cozeMessages.add(0, Message.buildUserQuestionText("System:\n" + systemMessages));
+ }
+ if (cozeMessages.isEmpty()) {
+ cozeMessages.add(Message.buildUserQuestionText(""));
+ }
+ return cozeMessages;
+ }
+
+ private void handleEvent(ChatEvent event,
+ StringBuilder answerBuffer,
+ AtomicReference messageId,
+ AtomicReference completedChat,
+ AtomicBoolean finished,
+ StreamingChatResponseHandler handler,
+ AtomicReference failure) {
+ if (event == null || finished.get()) {
+ return;
+ }
+
+ if (ChatEventType.CONVERSATION_MESSAGE_DELTA.equals(event.getEvent())) {
+ appendDelta(event, answerBuffer, messageId, handler);
+ return;
+ }
+
+ if (ChatEventType.CONVERSATION_MESSAGE_COMPLETED.equals(event.getEvent())) {
+ rememberMessageId(event, messageId);
+ if (answerBuffer.isEmpty() && event.getMessage() != null
+ && StrUtil.isNotEmpty(event.getMessage().getContent())) {
+ answerBuffer.append(event.getMessage().getContent());
+ }
+ return;
+ }
+
+ if (ChatEventType.CONVERSATION_CHAT_COMPLETED.equals(event.getEvent())) {
+ completedChat.set(event.getChat());
+ complete(handler, failure, finished, answerBuffer.toString(), messageId.get(), event.getChat());
+ return;
+ }
+
+ if (ChatEventType.CONVERSATION_CHAT_FAILED.equals(event.getEvent())
+ || ChatEventType.ERROR.equals(event.getEvent())) {
+ RuntimeException error = toFailure(event);
+ fail(handler, failure, finished, error);
+ }
+ }
+
+ private void appendDelta(ChatEvent event, StringBuilder answerBuffer,
+ AtomicReference messageId,
+ StreamingChatResponseHandler handler) {
+ if (event.getMessage() == null) {
+ return;
+ }
+ String chunk = event.getMessage().getContent();
+ if (StrUtil.isEmpty(chunk)) {
+ return;
+ }
+ answerBuffer.append(chunk);
+ rememberMessageId(event, messageId);
+ if (handler != null) {
+ handler.onPartialResponse(chunk);
+ }
+ }
+
+ private void complete(StreamingChatResponseHandler handler,
+ AtomicReference failure,
+ AtomicBoolean finished,
+ String answer,
+ String messageId,
+ Chat chat) {
+ if (handler != null) {
+ completeOnce(finished, handler, answer, messageId, chat);
+ } else {
+ finished.compareAndSet(false, true);
+ }
+ }
+
+ private void fail(StreamingChatResponseHandler handler,
+ AtomicReference failure,
+ AtomicBoolean finished,
+ RuntimeException error) {
+ if (handler != null) {
+ errorOnce(finished, handler, error);
+ } else if (finished.compareAndSet(false, true)) {
+ failure.set(error);
+ }
+ }
+
+ private void rememberMessageId(ChatEvent event, AtomicReference messageId) {
+ if (event.getMessage() != null && StrUtil.isNotBlank(event.getMessage().getId())) {
+ messageId.set(event.getMessage().getId());
+ }
+ }
+
+ private void completeOnce(AtomicBoolean finished,
+ StreamingChatResponseHandler handler,
+ String answer,
+ String messageId,
+ Chat chat) {
+ if (finished.compareAndSet(false, true)) {
+ handler.onCompleteResponse(toChatResponse(answer, messageId, chat));
+ }
+ }
+
+ private void errorOnce(AtomicBoolean finished, StreamingChatResponseHandler handler, Throwable error) {
+ if (finished.compareAndSet(false, true)) {
+ handler.onError(error);
+ }
+ }
+
+ private ChatResponse toChatResponse(String answer, String messageId, Chat chat) {
+ return ChatResponse.builder()
+ .aiMessage(AiMessage.from(StrUtil.nullToDefault(answer, "")))
+ .id(firstNotBlank(messageId, chat == null ? null : chat.getID()))
+ .modelName(chatModelVo.getModelName())
+ .tokenUsage(toTokenUsage(chat == null ? null : chat.getUsage()))
+ .finishReason(FinishReason.STOP)
+ .build();
+ }
+
+ private TokenUsage toTokenUsage(ChatUsage usage) {
+ if (usage == null) {
+ return null;
+ }
+ return new TokenUsage(
+ usage.getInputTokens(),
+ usage.getOutputTokens(),
+ usage.getTokenCount()
+ );
+ }
+
+ private RuntimeException toFailure(ChatEvent event) {
+ Chat chat = event.getChat();
+ if (chat != null && chat.getLastError() != null) {
+ ChatError error = chat.getLastError();
+ return new IllegalStateException(
+ StrUtil.format("Coze call failed: code={}, message={}", error.getCode(), error.getMsg())
+ );
+ }
+ return new IllegalStateException("Coze call failed: " + event.getEvent());
+ }
+
+ private String messageText(ChatMessage message) {
+ if (message instanceof UserMessage userMessage) {
+ if (userMessage.hasSingleText()) {
+ return userMessage.singleText();
+ }
+ return String.valueOf(userMessage.contents());
+ }
+ if (message instanceof SystemMessage systemMessage) {
+ return systemMessage.text();
+ }
+ if (message instanceof AiMessage aiMessage) {
+ return StrUtil.nullToDefault(aiMessage.text(), "");
+ }
+ return String.valueOf(message);
+ }
+
+ private void appendBlock(StringBuilder builder, String text) {
+ if (StrUtil.isBlank(text)) {
+ return;
+ }
+ if (!builder.isEmpty()) {
+ builder.append("\n\n");
+ }
+ builder.append(text);
+ }
+
+ private String normalizeBaseUrl(String baseUrl) {
+ if (StrUtil.isBlank(baseUrl)) {
+ throw new IllegalArgumentException("Coze apiHost cannot be blank");
+ }
+ String value = StrUtil.removeSuffix(baseUrl.trim(), "/");
+ if (!value.startsWith("http://") && !value.startsWith("https://")) {
+ value = "https://" + value;
+ }
+ return value;
+ }
+
+ private String requiredApiKey() {
+ if (StrUtil.isBlank(chatModelVo.getApiKey())) {
+ throw new IllegalArgumentException("Coze apiKey cannot be blank");
+ }
+ return chatModelVo.getApiKey();
+ }
+
+ private String requiredBotId() {
+ if (StrUtil.isBlank(chatModelVo.getModelName())) {
+ throw new IllegalArgumentException("Coze bot ID(modelName) cannot be blank");
+ }
+ return chatModelVo.getModelName();
+ }
+
+ private String resolveUser() {
+ if (runtimeRequest != null && runtimeRequest.getUserId() != null) {
+ return String.valueOf(runtimeRequest.getUserId());
+ }
+ if (runtimeRequest != null && runtimeRequest.getSessionId() != null) {
+ return "session-" + runtimeRequest.getSessionId();
+ }
+ return "ruoyi-ai";
+ }
+
+ private String firstNotBlank(String first, String second) {
+ return StrUtil.isNotBlank(first) ? first : second;
+ }
+
+ private void shutdown(CozeAPI coze) {
+ if (coze == null) {
+ return;
+ }
+ try {
+ coze.shutdownExecutor();
+ } catch (Exception e) {
+ log.debug("Coze client shutdown failed: {}", e.getMessage());
+ }
+ }
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImpl.java
new file mode 100644
index 00000000..611ed5f3
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImpl.java
@@ -0,0 +1,323 @@
+package org.ruoyi.service.chat.impl.provider;
+
+import cn.hutool.core.util.StrUtil;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.ChatMessage;
+import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.ModelProvider;
+import dev.langchain4j.model.chat.Capability;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.StreamingChatModel;
+import dev.langchain4j.model.chat.listener.ChatModelListener;
+import dev.langchain4j.model.chat.request.ChatRequestParameters;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
+import dev.langchain4j.model.output.FinishReason;
+import dev.langchain4j.model.output.TokenUsage;
+import io.github.imfangs.dify.client.DifyChatClient;
+import io.github.imfangs.dify.client.DifyClientFactory;
+import io.github.imfangs.dify.client.callback.ChatStreamCallback;
+import io.github.imfangs.dify.client.enums.ResponseMode;
+import io.github.imfangs.dify.client.event.AgentMessageEvent;
+import io.github.imfangs.dify.client.event.BaseMessageEvent;
+import io.github.imfangs.dify.client.event.ErrorEvent;
+import io.github.imfangs.dify.client.event.MessageEndEvent;
+import io.github.imfangs.dify.client.event.MessageEvent;
+import io.github.imfangs.dify.client.event.MessageReplaceEvent;
+import io.github.imfangs.dify.client.exception.DifyApiException;
+import io.github.imfangs.dify.client.model.DifyConfig;
+import io.github.imfangs.dify.client.model.common.Metadata;
+import io.github.imfangs.dify.client.model.common.Usage;
+import io.github.imfangs.dify.client.model.chat.ChatMessageResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
+import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
+import org.ruoyi.enums.ChatModeType;
+import org.ruoyi.observability.MyChatModelListener;
+import org.ruoyi.service.chat.AbstractChatService;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Dify 服务调用。
+ *
+ * 使用模型配置中的 apiHost 作为 Dify API 地址,apiKey 作为 Dify App API Key。
+ */
+@Service
+@Slf4j
+public class DifyChatServiceImpl implements AbstractChatService {
+
+ private static final int CONNECT_TIMEOUT_MS = 5_000;
+ private static final int READ_TIMEOUT_MS = 300_000;
+ private static final int WRITE_TIMEOUT_MS = 30_000;
+
+ @Override
+ public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
+ return new DifyLangChain4jChatModel(chatModelVo, chatRequest);
+ }
+
+ @Override
+ public ChatModel buildChatModel(ChatModelVo chatModelVo) {
+ return new DifyLangChain4jChatModel(chatModelVo, null);
+ }
+
+ @Override
+ public String getProviderName() {
+ return ChatModeType.DIFY.getCode();
+ }
+
+ private static class DifyLangChain4jChatModel implements StreamingChatModel, ChatModel {
+
+ private final ChatModelVo chatModelVo;
+ private final ChatRequest runtimeRequest;
+ private final List listeners = List.of(new MyChatModelListener());
+
+ private DifyLangChain4jChatModel(ChatModelVo chatModelVo, ChatRequest runtimeRequest) {
+ this.chatModelVo = chatModelVo;
+ this.runtimeRequest = runtimeRequest;
+ }
+
+ @Override
+ public void doChat(dev.langchain4j.model.chat.request.ChatRequest request,
+ StreamingChatResponseHandler handler) {
+ StringBuilder answerBuffer = new StringBuilder();
+ AtomicBoolean finished = new AtomicBoolean(false);
+ AtomicReference messageId = new AtomicReference<>();
+ AtomicReference metadata = new AtomicReference<>();
+
+ try {
+ DifyChatClient client = createClient();
+ client.sendChatMessageStream(toDifyMessage(request, ResponseMode.STREAMING), new ChatStreamCallback() {
+ @Override
+ public void onMessage(MessageEvent event) {
+ appendChunk(event.getAnswer(), event, answerBuffer, messageId, handler);
+ }
+
+ @Override
+ public void onAgentMessage(AgentMessageEvent event) {
+ appendChunk(event.getAnswer(), event, answerBuffer, messageId, handler);
+ }
+
+ @Override
+ public void onMessageReplace(MessageReplaceEvent event) {
+ if (StrUtil.isBlank(event.getAnswer())) {
+ return;
+ }
+ answerBuffer.setLength(0);
+ answerBuffer.append(event.getAnswer());
+ rememberMessageId(event, messageId);
+ log.debug("Dify 返回 message_replace 事件,当前 SSE 通道不支持替换已发送内容,仅用于最终消息落库");
+ }
+
+ @Override
+ public void onMessageEnd(MessageEndEvent event) {
+ metadata.set(event.getMetadata());
+ rememberMessageId(event, messageId);
+ completeOnce(finished, handler, answerBuffer.toString(), messageId.get(), metadata.get());
+ }
+
+ @Override
+ public void onError(ErrorEvent event) {
+ String message = StrUtil.format("Dify 调用失败: status={}, code={}, message={}",
+ event.getStatus(), event.getCode(), event.getMessage());
+ errorOnce(finished, handler, new IllegalStateException(message));
+ }
+
+ @Override
+ public void onException(Throwable throwable) {
+ errorOnce(finished, handler, throwable);
+ }
+ });
+ } catch (IOException | DifyApiException e) {
+ errorOnce(finished, handler, e);
+ }
+ }
+
+ @Override
+ public ChatResponse doChat(dev.langchain4j.model.chat.request.ChatRequest request) {
+ try {
+ DifyChatClient client = createClient();
+ ChatMessageResponse response = client.sendChatMessage(toDifyMessage(request, ResponseMode.BLOCKING));
+ return toChatResponse(
+ StrUtil.nullToDefault(response.getAnswer(), ""),
+ response.getMessageId(),
+ response.getMetadata()
+ );
+ } catch (IOException | DifyApiException e) {
+ throw new IllegalStateException("Dify 调用失败", e);
+ }
+ }
+
+ @Override
+ public ChatRequestParameters defaultRequestParameters() {
+ return ChatRequestParameters.builder()
+ .modelName(chatModelVo.getModelName())
+ .build();
+ }
+
+ @Override
+ public List listeners() {
+ return listeners;
+ }
+
+ @Override
+ public ModelProvider provider() {
+ return ModelProvider.OTHER;
+ }
+
+ @Override
+ public Set supportedCapabilities() {
+ return Set.of();
+ }
+
+ private DifyChatClient createClient() {
+ DifyConfig config = DifyConfig.builder()
+ .baseUrl(normalizeBaseUrl(chatModelVo.getApiHost()))
+ .apiKey(requiredApiKey())
+ .connectTimeout(CONNECT_TIMEOUT_MS)
+ .readTimeout(READ_TIMEOUT_MS)
+ .writeTimeout(WRITE_TIMEOUT_MS)
+ .build();
+ return DifyClientFactory.createChatClient(config);
+ }
+
+ private io.github.imfangs.dify.client.model.chat.ChatMessage toDifyMessage(
+ dev.langchain4j.model.chat.request.ChatRequest request,
+ ResponseMode responseMode) {
+ return io.github.imfangs.dify.client.model.chat.ChatMessage.builder()
+ .query(toDifyQuery(request.messages()))
+ .inputs(Map.of())
+ .responseMode(responseMode)
+ .user(resolveUser())
+ .autoGenerateName(Boolean.TRUE)
+ .build();
+ }
+
+ private String toDifyQuery(List messages) {
+ if (messages == null || messages.isEmpty()) {
+ return "";
+ }
+ if (messages.size() == 1) {
+ return messageText(messages.get(0));
+ }
+
+ StringBuilder builder = new StringBuilder();
+ builder.append("以下是当前会话上下文,请基于最后一条用户消息回答。").append("\n\n");
+ for (ChatMessage message : messages) {
+ builder.append(roleName(message)).append(": ")
+ .append(messageText(message))
+ .append("\n");
+ }
+ return builder.toString().trim();
+ }
+
+ private String messageText(ChatMessage message) {
+ if (message instanceof UserMessage userMessage) {
+ if (userMessage.hasSingleText()) {
+ return userMessage.singleText();
+ }
+ return String.valueOf(userMessage.contents());
+ }
+ if (message instanceof SystemMessage systemMessage) {
+ return systemMessage.text();
+ }
+ if (message instanceof AiMessage aiMessage) {
+ return StrUtil.nullToDefault(aiMessage.text(), "");
+ }
+ return String.valueOf(message);
+ }
+
+ private String roleName(ChatMessage message) {
+ return switch (message.type()) {
+ case SYSTEM -> "System";
+ case USER -> "User";
+ case AI -> "Assistant";
+ case TOOL_EXECUTION_RESULT -> "Tool";
+ case CUSTOM -> "Custom";
+ };
+ }
+
+ private String resolveUser() {
+ if (runtimeRequest != null && runtimeRequest.getUserId() != null) {
+ return String.valueOf(runtimeRequest.getUserId());
+ }
+ if (runtimeRequest != null && runtimeRequest.getSessionId() != null) {
+ return "session-" + runtimeRequest.getSessionId();
+ }
+ return "ruoyi-ai";
+ }
+
+ private String normalizeBaseUrl(String baseUrl) {
+ if (StrUtil.isBlank(baseUrl)) {
+ throw new IllegalArgumentException("Dify 的请求地址(apiHost)不能为空");
+ }
+ return StrUtil.removeSuffix(baseUrl, "/");
+ }
+
+ private String requiredApiKey() {
+ if (StrUtil.isBlank(chatModelVo.getApiKey())) {
+ throw new IllegalArgumentException("Dify 的 API Key 不能为空");
+ }
+ return chatModelVo.getApiKey();
+ }
+
+ private void appendChunk(String chunk, BaseMessageEvent event, StringBuilder answerBuffer,
+ AtomicReference messageId,
+ StreamingChatResponseHandler handler) {
+ if (StrUtil.isBlank(chunk)) {
+ return;
+ }
+ answerBuffer.append(chunk);
+ rememberMessageId(event, messageId);
+ handler.onPartialResponse(chunk);
+ }
+
+ private void rememberMessageId(BaseMessageEvent event, AtomicReference messageId) {
+ if (event != null && StrUtil.isNotBlank(event.getMessageId())) {
+ messageId.set(event.getMessageId());
+ }
+ }
+
+ private void completeOnce(AtomicBoolean finished, StreamingChatResponseHandler handler,
+ String answer, String messageId, Metadata metadata) {
+ if (finished.compareAndSet(false, true)) {
+ handler.onCompleteResponse(toChatResponse(answer, messageId, metadata));
+ }
+ }
+
+ private void errorOnce(AtomicBoolean finished, StreamingChatResponseHandler handler, Throwable error) {
+ if (finished.compareAndSet(false, true)) {
+ handler.onError(error);
+ }
+ }
+
+ private ChatResponse toChatResponse(String answer, String messageId, Metadata metadata) {
+ return ChatResponse.builder()
+ .aiMessage(AiMessage.from(StrUtil.nullToDefault(answer, "")))
+ .id(messageId)
+ .modelName(chatModelVo.getModelName())
+ .tokenUsage(toTokenUsage(metadata))
+ .finishReason(FinishReason.STOP)
+ .build();
+ }
+
+ private TokenUsage toTokenUsage(Metadata metadata) {
+ if (metadata == null || metadata.getUsage() == null) {
+ return null;
+ }
+ Usage usage = metadata.getUsage();
+ return new TokenUsage(
+ usage.getPromptTokens(),
+ usage.getCompletionTokens(),
+ usage.getTotalTokens()
+ );
+ }
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java
index eb3ed3ae..0b06614e 100644
--- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java
@@ -13,6 +13,7 @@ import org.ruoyi.observability.MyChatModelListener;
import org.ruoyi.service.chat.AbstractChatService;
import org.springframework.stereotype.Service;
+import java.time.Duration;
import java.util.List;
@@ -33,6 +34,7 @@ public class OpenAIServiceImpl implements AbstractChatService {
.baseUrl(chatModelVo.getApiHost())
.apiKey(chatModelVo.getApiKey())
.modelName(chatModelVo.getModelName())
+ .timeout(Duration.ofMinutes(30))
.listeners(List.of(new MyChatModelListener()))
.returnThinking(chatRequest.getEnableThinking())
.build();
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java
index 920dbaf1..f65b5f32 100644
--- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java
@@ -14,6 +14,7 @@ import org.ruoyi.observability.MyChatModelListener;
import org.ruoyi.service.chat.AbstractChatService;
import org.springframework.stereotype.Service;
+import java.time.Duration;
import java.util.List;
@@ -28,11 +29,15 @@ import java.util.List;
@RequiredArgsConstructor
public class ZhiPuChatServiceImpl implements AbstractChatService {
+ private static final int MAX_TOKENS = 65536;
+
@Override
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
return ZhipuAiStreamingChatModel.builder()
.apiKey(chatModelVo.getApiKey())
.model(chatModelVo.getModelName())
+ .maxToken(MAX_TOKENS)
+ .readTimeout(Duration.ofSeconds(300))
.listeners(List.of(new MyChatModelListener()))
.build();
}
@@ -42,6 +47,8 @@ public class ZhiPuChatServiceImpl implements AbstractChatService {
return ZhipuAiChatModel.builder()
.apiKey(chatModelVo.getApiKey())
.model(chatModelVo.getModelName())
+ .maxToken(MAX_TOKENS)
+ .readTimeout(Duration.ofSeconds(300))
.build();
}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/AbstractImageGenerationService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/AbstractImageGenerationService.java
index 4a9065a7..9ea0b945 100644
--- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/AbstractImageGenerationService.java
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/AbstractImageGenerationService.java
@@ -25,15 +25,15 @@ public abstract class AbstractImageGenerationService implements IImageGeneration
String size = imageContext.getSize();
// 获取随机数种子
Integer seed = imageContext.getSeed();
- return doGenerateImage(chatModelVo, prompt, size, seed);
+ // 获取参考图(图生图用)
+ String image = imageContext.getImage();
+ return doGenerateImage(chatModelVo, prompt, size, seed, image);
}
/**
* 执行生成图片(钩子方法 - 子类必须实现)
- *
- * @param prompt 提示词
*/
- protected abstract String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed);
+ protected abstract String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image);
/**
* 构建具体厂商的 ImageModel(原生SDK 非langchain4j-dashscope版)
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java
new file mode 100644
index 00000000..1d7a478e
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java
@@ -0,0 +1,286 @@
+package org.ruoyi.service.image.provider;
+
+import cn.hutool.core.util.StrUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+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 okhttp3.MediaType;
+import okhttp3.MultipartBody;
+import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
+import org.ruoyi.common.chat.entity.image.ImageContext;
+import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
+import org.ruoyi.enums.ChatModeType;
+import org.ruoyi.service.image.AbstractImageGenerationService;
+import org.ruoyi.service.media.AtlasMediaSupport;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Component("atlasImage")
+public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationService {
+
+ /** 比例 → 像素映射(AtlasCloud 要求 width*height,min 768 max 1360) */
+ private static final Map SIZE_MAP = Map.of(
+ "1:1", "1024*1024",
+ "3:2", "1152*768",
+ "4:3", "1152*864",
+ "16:9", "1360*768",
+ "9:16", "768*1360"
+ );
+ private static final Map GPT_IMAGE_SIZE_MAP = Map.of(
+ "1:1", "1024x1024",
+ "3:2", "1536x1024",
+ "4:3", "1536x1024",
+ "16:9", "1536x1024",
+ "9:16", "1024x1536"
+ );
+
+ private final OkHttpClient okHttpClient = new OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(300, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .build();
+
+ @Override
+ protected String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) {
+ ObjectNode payload = buildPayload(chatModelVo, prompt, size, seed, image);
+ payload.put("enable_sync_mode", true);
+
+ Request request = new Request.Builder()
+ .url(AtlasMediaSupport.endpoint(chatModelVo.getApiHost(), "/model/generateImage"))
+ .addHeader("Authorization", "Bearer " + chatModelVo.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()) {
+ log.error("AtlasCloud 图片生成 HTTP {}: {}", response.code(), responseText);
+ throw new IllegalArgumentException("Atlas Cloud图片生成失败: " + response.code() + " - " + responseText);
+ }
+ log.info("AtlasCloud 图片生成完成,响应长度: {}", responseText.length());
+ String url = extractImageUrl(responseText);
+ log.info("AtlasCloud 提取图片 URL: {}", url);
+ return url;
+ } catch (IOException e) {
+ throw new RuntimeException("Atlas Cloud图片生成失败: " + e.getMessage(), e);
+ }
+ }
+
+ /** 从 AtlasCloud 同步模式响应中提取图片 URL */
+ private String extractImageUrl(String responseText) {
+ if (StrUtil.isBlank(responseText)) return "";
+ String preview = responseText.length() > 500 ? responseText.substring(0, 500) + "..." : responseText;
+ log.info("AtlasCloud 原始响应(前500字符): {}", preview);
+ try {
+ JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(responseText);
+ JsonNode data = root.path("data");
+ String status = AtlasMediaSupport.text(data, "status");
+ log.info("AtlasCloud 任务状态: {}, 任务ID: {}", status, AtlasMediaSupport.text(data, "id"));
+ JsonNode outputs = data.path("outputs");
+ if (outputs.isArray() && !outputs.isEmpty()) {
+ String url = outputs.get(0).asText();
+ log.info("AtlasCloud outputs[0]: {}", url);
+ if (StrUtil.isNotBlank(url)) return url;
+ }
+ if ("processing".equals(status) || "pending".equals(status)) {
+ log.warn("AtlasCloud 图片为异步任务,请轮询 prediction/{}", AtlasMediaSupport.text(data, "id"));
+ }
+ } catch (IOException e) {
+ log.warn("解析 AtlasCloud 图片响应失败: {}", e.getMessage());
+ }
+ return "";
+ }
+
+ private ObjectNode buildPayload(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) {
+ String modelName = chatModelVo.getModelName();
+ boolean gptImageModel = isGptImageModel(modelName);
+ ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
+ payload.put("model", modelName);
+ payload.put("prompt", prompt);
+ if (StrUtil.isNotBlank(size)) {
+ payload.put("size", resolveSize(modelName, size));
+ }
+ if (seed != null && !gptImageModel) {
+ payload.put("seed", seed);
+ }
+ if (gptImageModel) {
+ payload.put("enable_base64_output", false);
+ payload.put("output_format", "jpeg");
+ payload.put("quality", "high");
+ payload.put("moderation", "low");
+ }
+ if (StrUtil.isNotBlank(image)) {
+ if (gptImageModel) {
+ payload.putArray("images").add(image);
+ } else {
+ // Atlas Cloud 图生图接口使用 image_url 接收 uploadMedia 返回的临时 URL。
+ payload.put("image_url", image);
+ }
+ }
+ return payload;
+ }
+
+ static boolean isGptImageModel(String modelName) {
+ return StrUtil.isNotBlank(modelName) && modelName.startsWith("openai/gpt-image-");
+ }
+
+ /** 根据 AtlasCloud 模型将比例转换为该模型支持的像素格式。 */
+ static String resolveSize(String modelName, String size) {
+ if (StrUtil.isBlank(size)) return null;
+ String normalizedSize = size.trim();
+ if (isGptImageModel(modelName)) {
+ String mappedSize = GPT_IMAGE_SIZE_MAP.get(normalizedSize);
+ if (mappedSize != null) return mappedSize;
+ return resolveGptImagePixelSize(normalizedSize);
+ }
+ if (normalizedSize.contains("*") || normalizedSize.contains("x")) return normalizedSize;
+ return SIZE_MAP.getOrDefault(normalizedSize, "1024*1024");
+ }
+
+ private static String resolveGptImagePixelSize(String size) {
+ String[] dimensions = size.toLowerCase().replace('*', 'x').split("x");
+ if (dimensions.length != 2) return "1024x1024";
+ try {
+ int width = Integer.parseInt(dimensions[0].trim());
+ int height = Integer.parseInt(dimensions[1].trim());
+ if (width == height) return "1024x1024";
+ return width > height ? "1536x1024" : "1024x1536";
+ } catch (NumberFormatException e) {
+ return "1024x1024";
+ }
+ }
+
+ @Override
+ public MediaGenerationResponse startImageGeneration(ImageContext imageContext) {
+ ChatModelVo chatModelVo = imageContext.getChatModelVo();
+ String prompt = imageContext.getPrompt();
+ String size = imageContext.getSize();
+ Integer seed = imageContext.getSeed();
+ String image = imageContext.getImage();
+
+ ObjectNode payload = buildPayload(chatModelVo, prompt, size, seed, image);
+ payload.put("enable_sync_mode", false);
+
+ Request request = new Request.Builder()
+ .url(AtlasMediaSupport.endpoint(chatModelVo.getApiHost(), "/model/generateImage"))
+ .addHeader("Authorization", "Bearer " + chatModelVo.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()) {
+ log.error("AtlasCloud 异步图片生成 HTTP {}: {}", response.code(), responseText);
+ throw new IllegalArgumentException("Atlas Cloud图片生成失败: " + response.code() + " - " + responseText);
+ }
+ log.info("AtlasCloud 异步图片生成响应: {}", responseText.length() > 300 ? responseText.substring(0, 300) + "..." : responseText);
+ JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(responseText);
+ String predictionId = AtlasMediaSupport.text(root.path("data"), "id");
+ if (StrUtil.isBlank(predictionId)) predictionId = AtlasMediaSupport.text(root, "id");
+ if (StrUtil.isBlank(predictionId)) {
+ throw new IllegalStateException("Atlas Cloud图片任务启动成功但响应中没有任务ID: "
+ + (responseText.length() > 500 ? responseText.substring(0, 500) : responseText));
+ }
+ log.info("AtlasCloud 异步任务 predictionId: {}", predictionId);
+ return MediaGenerationResponse.builder()
+ .type("image")
+ .id(predictionId)
+ .status("pending")
+ .rawResponse(responseText)
+ .build();
+ } catch (IOException e) {
+ throw new RuntimeException("Atlas Cloud图片生成失败: " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public String uploadMedia(ChatModelVo model, byte[] content, String fileName, String contentType) {
+ if (content == null || content.length == 0) {
+ throw new IllegalArgumentException("上传文件不能为空");
+ }
+ MediaType mediaType = MediaType.parse(StrUtil.blankToDefault(contentType, "application/octet-stream"));
+ RequestBody fileBody = RequestBody.create(content, mediaType);
+ MultipartBody requestBody = new MultipartBody.Builder()
+ .setType(MultipartBody.FORM)
+ .addFormDataPart("file", StrUtil.blankToDefault(fileName, "reference-image"), fileBody)
+ .build();
+ Request request = new Request.Builder()
+ .url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/uploadMedia"))
+ .addHeader("Authorization", "Bearer " + model.getApiKey())
+ .post(requestBody)
+ .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);
+ }
+ JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(responseText);
+ String url = findUploadedMediaUrl(root);
+ if (StrUtil.isBlank(url)) {
+ String preview = responseText.length() > 800 ? responseText.substring(0, 800) + "..." : responseText;
+ log.error("Atlas Cloud上传响应中未识别到媒体URL: {}", preview);
+ throw new IllegalStateException("Atlas Cloud文件上传成功但响应中没有可识别的媒体URL: " + preview);
+ }
+ return url;
+ } catch (IOException e) {
+ throw new RuntimeException("Atlas Cloud文件上传失败: " + e.getMessage(), e);
+ }
+ }
+
+ /** 兼容 Atlas 不同网关版本的上传响应字段。 */
+ private static String findUploadedMediaUrl(JsonNode node) {
+ if (node == null || node.isNull() || node.isMissingNode()) return null;
+ if (node.isTextual()) {
+ String value = node.asText();
+ return isHttpUrl(value) ? value : null;
+ }
+ if (node.isObject()) {
+ for (String key : new String[]{"url", "file_url", "fileUrl", "image_url", "imageUrl", "media_url", "mediaUrl", "download_url", "downloadUrl"}) {
+ String value = node.path(key).asText(null);
+ if (isHttpUrl(value)) return value;
+ }
+ for (String key : new String[]{"data", "output", "outputs", "urls", "result", "file", "media"}) {
+ String value = findUploadedMediaUrl(node.path(key));
+ if (StrUtil.isNotBlank(value)) return value;
+ }
+ java.util.Iterator children = node.elements();
+ while (children.hasNext()) {
+ String value = findUploadedMediaUrl(children.next());
+ if (StrUtil.isNotBlank(value)) return value;
+ }
+ } else if (node.isArray()) {
+ for (JsonNode child : node) {
+ String value = findUploadedMediaUrl(child);
+ if (StrUtil.isNotBlank(value)) return value;
+ }
+ }
+ return null;
+ }
+
+ private static boolean isHttpUrl(String value) {
+ return StrUtil.isNotBlank(value)
+ && (value.startsWith("https://") || value.startsWith("http://"));
+ }
+
+ @Override
+ protected Object buildImageModel(ChatModelVo chatModelVo) {
+ return chatModelVo;
+ }
+
+ @Override
+ public String getProviderName() {
+ return ChatModeType.ATLAS.getCode();
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/OpenAiImageGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/OpenAiImageGenerationServiceImpl.java
new file mode 100644
index 00000000..8b2287e2
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/OpenAiImageGenerationServiceImpl.java
@@ -0,0 +1,93 @@
+package org.ruoyi.service.image.provider;
+
+import cn.hutool.core.util.StrUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+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.enums.ChatModeType;
+import org.ruoyi.service.image.AbstractImageGenerationService;
+import org.ruoyi.service.media.OpenAiMediaSupport;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Component("openaiImage")
+public class OpenAiImageGenerationServiceImpl extends AbstractImageGenerationService {
+
+ private final OkHttpClient okHttpClient = new OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(180, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .build();
+
+ @Override
+ protected String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) {
+ ObjectNode payload = OpenAiMediaSupport.OBJECT_MAPPER.createObjectNode();
+ payload.put("model", chatModelVo.getModelName());
+ payload.put("prompt", prompt);
+ payload.put("n", 1);
+ if (StrUtil.isNotBlank(size)) {
+ payload.put("size", size);
+ }
+ if (seed != null && !isGptImageModel(chatModelVo.getModelName())) {
+ payload.put("seed", seed);
+ }
+
+ try {
+ String response = postJson(chatModelVo, "/images/generations", payload.toString());
+ JsonNode root = OpenAiMediaSupport.OBJECT_MAPPER.readTree(response);
+ JsonNode first = root.path("data").isArray() && !root.path("data").isEmpty() ? root.path("data").get(0) : null;
+ if (first == null) {
+ return "";
+ }
+ String url = OpenAiMediaSupport.text(first, "url");
+ if (StrUtil.isNotBlank(url)) {
+ return url;
+ }
+ String b64 = OpenAiMediaSupport.text(first, "b64_json");
+ return StrUtil.isBlank(b64) ? "" : OpenAiMediaSupport.dataUrl("image/png", b64);
+ } catch (IOException e) {
+ throw new RuntimeException("图片生成失败: " + e.getMessage(), e);
+ }
+ }
+
+ /** gpt-image 系列不支持 seed 参数,传入会被拒。 */
+ private static boolean isGptImageModel(String modelName) {
+ return StrUtil.isNotBlank(modelName) && modelName.contains("gpt-image");
+ }
+
+ @Override
+ protected Object buildImageModel(ChatModelVo chatModelVo) {
+ return chatModelVo;
+ }
+
+ @Override
+ public String getProviderName() {
+ return ChatModeType.OPEN_AI.getCode();
+ }
+
+ private String postJson(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();
+ String responseText = body == null ? "" : body.string();
+ if (!response.isSuccessful()) {
+ throw new IllegalArgumentException("OpenAI Images API调用失败: " + response.code() + " - " + responseText);
+ }
+ return responseText;
+ }
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java
index 090ca327..c188cae7 100644
--- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java
@@ -33,7 +33,7 @@ public class TongYiWanxImageServiceImpl extends AbstractImageGenerationService {
private final static String IMAGE_DEFAULT_RESOLUTION = "1280*1280";
@Override
- protected String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed) {
+ protected String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) {
// 构建万相模型对象
var param = (ImageSynthesisParam) buildImageModel(chatModelVo);
// 设置图片大小和提示词以及随机数种子
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasMediaSupport.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasMediaSupport.java
new file mode 100644
index 00000000..b99aa3b6
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasMediaSupport.java
@@ -0,0 +1,38 @@
+package org.ruoyi.service.media;
+
+import cn.hutool.core.util.StrUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import okhttp3.MediaType;
+
+public final class AtlasMediaSupport {
+
+ public static final MediaType JSON = MediaType.get("application/json");
+ public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private AtlasMediaSupport() {
+ }
+
+ public static String endpoint(String apiHost, String path) {
+ if (StrUtil.isBlank(apiHost)) {
+ throw new IllegalArgumentException("apiHost不能为空");
+ }
+ String host = StrUtil.removeSuffix(apiHost.trim(), "/");
+ String normalizedPath = path.startsWith("/") ? path : "/" + path;
+ if (host.endsWith(normalizedPath)) {
+ return host;
+ }
+ if (host.endsWith("/api/v1")) {
+ return host + normalizedPath;
+ }
+ if (host.endsWith("/v1")) {
+ return StrUtil.removeSuffix(host, "/v1") + "/api/v1" + normalizedPath;
+ }
+ return host + "/api/v1" + normalizedPath;
+ }
+
+ public static String text(JsonNode node, String field) {
+ JsonNode value = node == null ? null : node.get(field);
+ return value == null || value.isNull() ? null : value.asText();
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java
new file mode 100644
index 00000000..bdf4f616
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java
@@ -0,0 +1,82 @@
+package org.ruoyi.service.media;
+
+import cn.hutool.core.util.StrUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
+import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+public class AtlasPredictionService {
+
+ private final OkHttpClient okHttpClient = new OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(180, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .build();
+
+ public MediaGenerationResponse retrieve(ChatModelVo model, String predictionId) {
+ if (StrUtil.isBlank(predictionId)) {
+ throw new IllegalArgumentException("predictionId不能为空");
+ }
+ Request request = new Request.Builder()
+ .url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/prediction/" + predictionId))
+ .addHeader("Authorization", "Bearer " + model.getApiKey())
+ .get()
+ .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 toResponse(responseText, mediaType(model.getCategory()));
+ } catch (IOException e) {
+ throw new RuntimeException("Atlas Cloud生成结果查询失败: " + e.getMessage(), e);
+ }
+ }
+
+ public MediaGenerationResponse toResponse(String raw, String type) throws IOException {
+ JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(raw);
+ JsonNode data = root.path("data");
+ return MediaGenerationResponse.builder()
+ .type(type)
+ .mimeType("image".equals(type) ? "image/png" : "video/mp4")
+ .id(AtlasMediaSupport.text(data, "id"))
+ .status(AtlasMediaSupport.text(data, "status"))
+ .url(firstOutput(data))
+ .rawResponse(raw)
+ .build();
+ }
+
+ private String firstOutput(JsonNode data) {
+ JsonNode outputs = data.path("outputs");
+ if (outputs.isArray() && !outputs.isEmpty()) {
+ JsonNode first = outputs.get(0);
+ // 字符串格式:outputs[0] = "https://..."
+ if (first.isTextual()) return first.asText();
+ // 对象格式:outputs[0] = {"url": "https://..."}
+ if (first.isObject()) {
+ String url = first.path("url").asText(null);
+ if (url != null) return url;
+ url = first.path("video_url").asText(null);
+ if (url != null) return url;
+ log.warn("outputs[0] 为对象但未找到 url 字段: {}", first);
+ }
+ }
+ return null;
+ }
+
+ private String mediaType(String category) {
+ return "image".equals(category) ? "image" : "video";
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/OpenAiMediaSupport.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/OpenAiMediaSupport.java
new file mode 100644
index 00000000..ef99555a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/OpenAiMediaSupport.java
@@ -0,0 +1,45 @@
+package org.ruoyi.service.media;
+
+import cn.hutool.core.util.StrUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import okhttp3.MediaType;
+
+import java.util.Base64;
+
+public final class OpenAiMediaSupport {
+
+ public static final MediaType JSON = MediaType.get("application/json");
+ public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private OpenAiMediaSupport() {
+ }
+
+ public static String endpoint(String apiHost, String path) {
+ if (StrUtil.isBlank(apiHost)) {
+ throw new IllegalArgumentException("apiHost不能为空");
+ }
+ String host = StrUtil.removeSuffix(apiHost.trim(), "/");
+ String normalizedPath = path.startsWith("/") ? path : "/" + path;
+ if (host.endsWith(normalizedPath)) {
+ return host;
+ }
+ if (host.endsWith("/v1")) {
+ return host + normalizedPath;
+ }
+ return host + "/v1" + normalizedPath;
+ }
+
+ public static String text(JsonNode node, String field) {
+ JsonNode value = node == null ? null : node.get(field);
+ return value == null || value.isNull() ? null : value.asText();
+ }
+
+ public static String dataUrl(String mimeType, byte[] bytes) {
+ return "data:" + mimeType + ";base64," + Base64.getEncoder().encodeToString(bytes);
+ }
+
+ public static String dataUrl(String mimeType, String b64Json) {
+ return StrUtil.isBlank(b64Json) ? null : "data:" + mimeType + ";base64," + b64Json;
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java
new file mode 100644
index 00000000..b383f567
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java
@@ -0,0 +1,116 @@
+package org.ruoyi.service.shortdrama;
+
+import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
+import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterBo;
+import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo;
+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.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.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+public interface IShortDramaService {
+
+ List listProjects(Long userId);
+
+ ShortDramaDetailVo getDetail(Long projectId, Long userId);
+
+ ShortDramaDetailVo createFromIdea(ShortDramaIdeaBo bo, Long userId);
+
+ /** 流式创建短剧,通过 SSE 推送各阶段进度 */
+ SseEmitter createFromIdeaStream(ShortDramaIdeaBo bo, Long userId);
+
+ Long saveProject(ShortDramaProjectBo bo, Long userId);
+
+ ShortDramaScriptVo saveScript(ShortDramaScriptBo bo, Long userId);
+
+ List generateStoryboards(Long projectId, Long scriptId, String model, Long userId);
+
+ ShortDramaStoryboardVo saveStoryboard(ShortDramaStoryboardBo bo, Long userId);
+
+ ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId);
+
+ ShortDramaStoryboardVo retrieveVideo(Long storyboardId, String videoModel, Long userId);
+
+ /** 代理流式输出视频,绕过 OSS 强制下载 header */
+ StreamingResponseBody streamVideo(Long storyboardId, Long userId);
+
+ List generateAllVideos(Long projectId, String videoModel, Long userId);
+
+ ShortDramaDetailVo analyzeAssets(Long projectId, Long scriptId, Long userId);
+
+ /** Phase 1: 剧本打磨,重新生成更丰富的剧本内容 */
+ ShortDramaDetailVo polishScript(Long projectId, Long userId);
+
+ /** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */
+ List planStoryboard(Long projectId, Long scriptId, String model, Long userId);
+
+ /** Phase 3-6: 流式生成分镜并推送实时进度 */
+ SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId);
+
+ /** Phase 4: 重新生成摄影规则 */
+ List generatePhotographyRules(Long projectId, Long scriptId, Long userId);
+
+ /** Phase 5: 重新生成表演指导 */
+ List generateActingDirections(Long projectId, Long scriptId, Long userId);
+
+ ShortDramaCharacterVo saveCharacter(ShortDramaCharacterBo bo, Long userId);
+
+ ShortDramaLocationVo saveLocation(ShortDramaLocationBo bo, Long userId);
+
+ ShortDramaCharacterVo generateCharacterImage(Long characterId, String imageModel, String referenceImageUrl, Long userId);
+
+ ShortDramaLocationVo generateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId);
+
+ Boolean deleteCharacter(Long characterId, Long userId);
+
+ Boolean deleteLocation(Long locationId, Long userId);
+
+ ShortDramaCharacterAppearanceVo saveAppearance(ShortDramaCharacterAppearanceBo bo, Long userId);
+
+ Boolean deleteAppearance(Long appearanceId, Long userId);
+
+ ShortDramaCharacterAppearanceVo generateAppearanceImage(Long appearanceId, String imageModel, String referenceImageUrl, Long userId);
+
+ ShortDramaCharacterAppearanceVo regenerateAppearanceImage(Long appearanceId, String imageModel, String referenceImageUrl, Long userId);
+
+ ShortDramaCharacterAppearanceVo selectAppearanceImage(Long appearanceId, Integer index, Long userId);
+
+ ShortDramaCharacterAppearanceVo deleteAppearanceImage(Long appearanceId, Integer index, Long userId);
+
+ ShortDramaCharacterAppearanceVo undoAppearanceImage(Long appearanceId, Long userId);
+
+ ShortDramaLocationVo regenerateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId);
+
+ ShortDramaLocationVo selectLocationImage(Long locationId, Integer index, Long userId);
+
+ ShortDramaLocationVo deleteLocationImage(Long locationId, Integer index, Long userId);
+
+ ShortDramaLocationVo undoLocationImage(Long locationId, Long userId);
+
+ /** 异步启动图片生成,返回 prediction 信息供前端轮询 */
+ MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl, Long userId);
+
+ /** 上传到图片模型供应商,返回仅用于当前生成会话的临时参考图 URL。 */
+ String uploadReferenceImage(MultipartFile file, String model, Long userId);
+
+ /** 轮询确认形象图片生成结果并保存 */
+ ShortDramaCharacterAppearanceVo confirmAppearanceImage(Long appearanceId, String predictionId, String model, Long userId);
+
+ /** 轮询确认场景图片生成结果并保存 */
+ ShortDramaLocationVo confirmLocationImage(Long locationId, String predictionId, String model, Long userId);
+
+ Boolean deleteProject(Long projectId, Long userId);
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaVideoComposeService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaVideoComposeService.java
new file mode 100644
index 00000000..178e5ec7
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaVideoComposeService.java
@@ -0,0 +1,19 @@
+package org.ruoyi.service.shortdrama;
+
+import org.ruoyi.domain.bo.shortdrama.ShortDramaComposeVideoBo;
+import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo;
+
+import java.nio.file.Path;
+
+public interface IShortDramaVideoComposeService {
+
+ ShortDramaComposeVideoVo composeVideo(Long projectId, ShortDramaComposeVideoBo bo, Long userId);
+
+ ShortDramaComposeVideoVo getComposedVideo(Long projectId, Long userId);
+
+ Path getLocalComposedVideo(Long projectId, Long userId);
+
+ void invalidateComposition(Long projectId);
+
+ void deleteComposition(Long projectId);
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/AspectRatio.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/AspectRatio.java
new file mode 100644
index 00000000..5c44dfc3
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/AspectRatio.java
@@ -0,0 +1,46 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+
+public enum AspectRatio {
+ PORTRAIT("9:16", 1080, 1920),
+ LANDSCAPE("16:9", 1920, 1080),
+ SQUARE("1:1", 1080, 1080);
+
+ private final String value;
+ private final int width;
+ private final int height;
+
+ AspectRatio(String value, int width, int height) {
+ this.value = value;
+ this.width = width;
+ this.height = height;
+ }
+
+ @JsonValue
+ public String value() {
+ return value;
+ }
+
+ public int width() {
+ return width;
+ }
+
+ public int height() {
+ return height;
+ }
+
+ @JsonCreator
+ public static AspectRatio fromValue(String value) {
+ if (value == null || value.isBlank()) {
+ return PORTRAIT;
+ }
+ for (AspectRatio ratio : values()) {
+ if (ratio.value.equals(value.trim())) {
+ return ratio;
+ }
+ }
+ throw new IllegalArgumentException("Unsupported aspect ratio: " + value);
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionArtifact.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionArtifact.java
new file mode 100644
index 00000000..dd63667a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionArtifact.java
@@ -0,0 +1,18 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import java.nio.file.Path;
+import java.util.Objects;
+
+public record CompositionArtifact(
+ Path path,
+ double durationSeconds,
+ int width,
+ int height,
+ TransitionType transitionType
+) {
+
+ public CompositionArtifact {
+ path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
+ transitionType = Objects.requireNonNull(transitionType, "transitionType");
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSource.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSource.java
new file mode 100644
index 00000000..ccc1fe0a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSource.java
@@ -0,0 +1,15 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import java.nio.file.Path;
+import java.util.Objects;
+
+public record CompositionSource(Path path, Double fallbackDurationSeconds) {
+
+ public CompositionSource {
+ path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
+ if (fallbackDurationSeconds != null
+ && (!Double.isFinite(fallbackDurationSeconds) || fallbackDurationSeconds <= 0)) {
+ throw new IllegalArgumentException("fallbackDurationSeconds must be positive");
+ }
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSpec.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSpec.java
new file mode 100644
index 00000000..57c61985
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSpec.java
@@ -0,0 +1,29 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import java.util.List;
+import java.util.Objects;
+
+public record CompositionSpec(
+ List sources,
+ TransitionType transitionType,
+ double transitionDurationSeconds,
+ AspectRatio aspectRatio
+) {
+
+ public CompositionSpec {
+ sources = List.copyOf(Objects.requireNonNull(sources, "sources"));
+ if (sources.isEmpty()) {
+ throw new IllegalArgumentException("At least one video source is required");
+ }
+ transitionType = transitionType == null ? TransitionType.NONE : transitionType;
+ aspectRatio = aspectRatio == null ? AspectRatio.PORTRAIT : aspectRatio;
+ if (!Double.isFinite(transitionDurationSeconds) || transitionDurationSeconds < 0) {
+ throw new IllegalArgumentException("transitionDurationSeconds must be non-negative");
+ }
+ if (transitionType == TransitionType.NONE) {
+ transitionDurationSeconds = 0;
+ } else if (transitionDurationSeconds <= 0) {
+ throw new IllegalArgumentException("A transition duration is required for " + transitionType.value());
+ }
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilder.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilder.java
new file mode 100644
index 00000000..fc140d67
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCommandBuilder.java
@@ -0,0 +1,72 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import org.springframework.stereotype.Component;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+@Component
+public class FfmpegCommandBuilder {
+
+ private final FfmpegCompositionProperties properties;
+
+ public FfmpegCommandBuilder(FfmpegCompositionProperties properties) {
+ this.properties = properties;
+ }
+
+ public List build(
+ CompositionSpec spec,
+ FfmpegFilterGraph filterGraph,
+ Path filterScript,
+ Path output
+ ) {
+ List command = new ArrayList<>();
+ command.add(properties.getFfmpegPath());
+ command.add("-hide_banner");
+ command.add("-loglevel");
+ command.add("warning");
+ command.add("-nostdin");
+ command.add("-y");
+ for (CompositionSource source : spec.sources()) {
+ command.add("-i");
+ command.add(source.path().toString());
+ }
+ command.add("-filter_complex_script");
+ command.add(filterScript.toAbsolutePath().normalize().toString());
+ command.add("-map");
+ command.add("[" + filterGraph.videoLabel() + "]");
+ command.add("-map");
+ command.add("[" + filterGraph.audioLabel() + "]");
+ command.add("-c:v");
+ command.add(properties.getVideoCodec());
+ command.add("-preset");
+ command.add(properties.getPreset());
+ command.add("-crf");
+ command.add(Integer.toString(properties.getCrf()));
+ command.add("-pix_fmt");
+ command.add("yuv420p");
+ command.add("-r");
+ command.add(Integer.toString(filterGraph.canvas().fps()));
+ command.add("-c:a");
+ command.add(properties.getAudioCodec());
+ command.add("-b:a");
+ command.add(properties.getAudioBitrate());
+ command.add("-ar");
+ command.add(Integer.toString(properties.getAudioSampleRate()));
+ command.add("-ac");
+ command.add("2");
+ command.add("-t");
+ command.add(FfmpegFilterGraphBuilder.seconds(filterGraph.expectedDurationSeconds()));
+ command.add("-map_metadata");
+ command.add("-1");
+ command.add("-map_chapters");
+ command.add("-1");
+ command.add("-max_muxing_queue_size");
+ command.add("1024");
+ command.add("-movflags");
+ command.add("+faststart");
+ command.add(output.toAbsolutePath().normalize().toString());
+ return List.copyOf(command);
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionProperties.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionProperties.java
new file mode 100644
index 00000000..c71bb3e6
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionProperties.java
@@ -0,0 +1,68 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "short-drama.composition")
+public class FfmpegCompositionProperties {
+
+ private String ffmpegPath = "ffmpeg";
+ private String ffprobePath = "ffprobe";
+ private int fps = 30;
+ private int audioSampleRate = 48_000;
+ private String videoCodec = "libx264";
+ private String audioCodec = "aac";
+ private String preset = "medium";
+ private int crf = 20;
+ private String audioBitrate = "192k";
+ private int maxClips = 100;
+ private double maxTransitionSeconds = 2.0;
+ private long minimumOutputBytes = 1_024;
+ private int maxProcessOutputBytes = 1_048_576;
+ private long maxSourceBytes = 512L * 1024 * 1024;
+ private long maxTotalSourceBytes = 2L * 1024 * 1024 * 1024;
+ private Duration probeTimeout = Duration.ofSeconds(30);
+ private Duration processTimeout = Duration.ofMinutes(30);
+ private Duration jobStaleAfter = Duration.ofMinutes(45);
+ private int workerCoreSize = 1;
+ private int workerMaxSize = 2;
+ private int workerQueueCapacity = 8;
+ private String storageMode = "local";
+ private String localOutputDirectory = "logs/short-drama-compositions";
+
+ public BigDecimal normalizeTransitionDuration(TransitionType type, BigDecimal requested) {
+ if (type == null || type == TransitionType.NONE) {
+ return BigDecimal.ZERO;
+ }
+ if (requested == null || requested.signum() <= 0) {
+ throw new IllegalArgumentException("Transition duration must be positive");
+ }
+ if (fps <= 0) {
+ throw new IllegalArgumentException("Composition fps must be positive");
+ }
+ if (!Double.isFinite(maxTransitionSeconds) || maxTransitionSeconds <= 0) {
+ throw new IllegalArgumentException("Maximum transition duration must be positive and finite");
+ }
+
+ BigDecimal normalized = requested.stripTrailingZeros();
+ if (normalized.scale() > 3) {
+ throw new IllegalArgumentException("Transition duration supports at most 3 decimal places");
+ }
+ if (normalized.multiply(BigDecimal.valueOf(fps)).compareTo(BigDecimal.ONE) < 0) {
+ throw new IllegalArgumentException("Transition duration must be at least one output frame");
+ }
+ if (normalized.compareTo(BigDecimal.valueOf(maxTransitionSeconds)) > 0) {
+ throw new IllegalArgumentException(
+ "Transition duration exceeds " + BigDecimal.valueOf(maxTransitionSeconds).stripTrailingZeros().toPlainString()
+ + " seconds"
+ );
+ }
+ return normalized;
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionWorker.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionWorker.java
new file mode 100644
index 00000000..9e18c2c3
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegCompositionWorker.java
@@ -0,0 +1,27 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.nio.file.Path;
+import java.util.concurrent.CompletableFuture;
+
+@Service
+@RequiredArgsConstructor
+public class FfmpegCompositionWorker {
+
+ private final FfmpegVideoComposer videoComposer;
+
+ @Async("videoCompositionExecutor")
+ public CompletableFuture composeAsync(
+ CompositionSpec spec,
+ Path workDirectory
+ ) {
+ try {
+ return CompletableFuture.completedFuture(videoComposer.compose(spec, workDirectory));
+ } catch (Exception ex) {
+ return CompletableFuture.failedFuture(ex);
+ }
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraph.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraph.java
new file mode 100644
index 00000000..cf5202a6
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraph.java
@@ -0,0 +1,10 @@
+package org.ruoyi.service.shortdrama.composition;
+
+public record FfmpegFilterGraph(
+ String value,
+ String videoLabel,
+ String audioLabel,
+ double expectedDurationSeconds,
+ VideoCanvas canvas
+) {
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilder.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilder.java
new file mode 100644
index 00000000..9b8ae055
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegFilterGraphBuilder.java
@@ -0,0 +1,176 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.List;
+
+@Component
+public class FfmpegFilterGraphBuilder {
+
+ private final FfmpegCompositionProperties properties;
+
+ public FfmpegFilterGraphBuilder(FfmpegCompositionProperties properties) {
+ this.properties = properties;
+ }
+
+ public FfmpegFilterGraph build(CompositionSpec spec, List media) {
+ if (media.size() != spec.sources().size()) {
+ throw new IllegalArgumentException("Media metadata count does not match source count");
+ }
+ if (media.size() > properties.getMaxClips()) {
+ throw new IllegalArgumentException("Too many video clips; maximum is " + properties.getMaxClips());
+ }
+
+ VideoCanvas canvas = new VideoCanvas(
+ spec.aspectRatio().width(),
+ spec.aspectRatio().height(),
+ properties.getFps()
+ );
+ double frameSeconds = 1.0 / canvas.fps();
+ List filters = new ArrayList<>();
+
+ for (int index = 0; index < media.size(); index++) {
+ MediaInfo info = media.get(index);
+ if (info.durationSeconds() < frameSeconds) {
+ throw new IllegalArgumentException("Video clip " + (index + 1) + " is shorter than one output frame");
+ }
+ String duration = seconds(info.durationSeconds());
+ filters.add(normalizeVideo(index, info.videoStreamIndex(), duration, canvas));
+ filters.add(normalizeAudio(index, duration, info.hasAudio()));
+ }
+
+ if (spec.transitionType() == TransitionType.NONE) {
+ return hardCut(filters, media, canvas);
+ }
+ return transition(filters, spec, media, canvas, frameSeconds);
+ }
+
+ private FfmpegFilterGraph hardCut(List filters, List media, VideoCanvas canvas) {
+ double totalDuration = media.stream().mapToDouble(MediaInfo::durationSeconds).sum();
+ if (media.size() == 1) {
+ return new FfmpegFilterGraph(
+ String.join(";", filters),
+ "v0",
+ "a0",
+ totalDuration,
+ canvas
+ );
+ }
+
+ StringBuilder inputs = new StringBuilder();
+ for (int index = 0; index < media.size(); index++) {
+ inputs.append("[v").append(index).append("][a").append(index).append("]");
+ }
+ filters.add(inputs + "concat=n=" + media.size() + ":v=1:a=1[vout][aout]");
+ return new FfmpegFilterGraph(
+ String.join(";", filters),
+ "vout",
+ "aout",
+ totalDuration,
+ canvas
+ );
+ }
+
+ private FfmpegFilterGraph transition(
+ List filters,
+ CompositionSpec spec,
+ List media,
+ VideoCanvas canvas,
+ double frameSeconds
+ ) {
+ BigDecimal normalizedTransition = properties.normalizeTransitionDuration(
+ spec.transitionType(),
+ BigDecimal.valueOf(spec.transitionDurationSeconds())
+ );
+ double transitionSeconds = normalizedTransition.doubleValue();
+ BigDecimal twoTransitionWindows = normalizedTransition.multiply(BigDecimal.valueOf(2));
+ for (int index = 1; index < media.size() - 1; index++) {
+ if (BigDecimal.valueOf(media.get(index).durationSeconds()).compareTo(twoTransitionWindows) < 0) {
+ throw new IllegalArgumentException(
+ "Transition windows overlap in internal clip " + (index + 1)
+ );
+ }
+ }
+
+ String currentVideo = "v0";
+ String currentAudio = "a0";
+ double currentDuration = media.get(0).durationSeconds();
+ for (int index = 1; index < media.size(); index++) {
+ double adjacentLimit = Math.min(
+ media.get(index - 1).durationSeconds(),
+ media.get(index).durationSeconds()
+ ) - frameSeconds;
+ if (transitionSeconds > adjacentLimit) {
+ throw new IllegalArgumentException(
+ "Transition is too long for clips " + index + " and " + (index + 1)
+ );
+ }
+
+ double offset = currentDuration - transitionSeconds;
+ String nextVideo = "vx" + index;
+ String nextAudio = "ax" + index;
+ filters.add("[" + currentVideo + "][v" + index + "]xfade=transition="
+ + spec.transitionType().ffmpegName()
+ + ":duration=" + seconds(transitionSeconds)
+ + ":offset=" + seconds(offset)
+ + "[" + nextVideo + "]");
+ filters.add("[" + currentAudio + "][a" + index + "]acrossfade=d="
+ + seconds(transitionSeconds)
+ + ":c1=tri:c2=tri[" + nextAudio + "]");
+ currentVideo = nextVideo;
+ currentAudio = nextAudio;
+ currentDuration += media.get(index).durationSeconds() - transitionSeconds;
+ }
+
+ return new FfmpegFilterGraph(
+ String.join(";", filters),
+ currentVideo,
+ currentAudio,
+ currentDuration,
+ canvas
+ );
+ }
+
+ private String normalizeVideo(int inputIndex, int videoStreamIndex, String duration, VideoCanvas canvas) {
+ return "[" + inputIndex + ":" + videoStreamIndex + "]"
+ + "scale=" + canvas.width() + ":" + canvas.height()
+ + ":force_original_aspect_ratio=decrease:force_divisible_by=2,"
+ + "pad=" + canvas.width() + ":" + canvas.height()
+ + ":(ow-iw)/2:(oh-ih)/2:color=black,"
+ + "fps=" + canvas.fps() + ","
+ + "setsar=1,format=yuv420p,"
+ + "tpad=stop_mode=clone:stop_duration=" + duration + ","
+ + "trim=start=0:duration=" + duration + ","
+ + "settb=AVTB,setpts=PTS-STARTPTS"
+ + "[v" + inputIndex + "]";
+ }
+
+ private String normalizeAudio(int index, String duration, boolean hasAudio) {
+ String format = "aformat=sample_fmts=fltp:sample_rates=" + properties.getAudioSampleRate()
+ + ":channel_layouts=stereo";
+ if (hasAudio) {
+ return "[" + index + ":a:0]"
+ + "aresample=" + properties.getAudioSampleRate() + ":async=1:first_pts=0,"
+ + format + ","
+ + "apad=pad_dur=" + duration + ","
+ + "atrim=start=0:duration=" + duration + ","
+ + "asetpts=PTS-STARTPTS"
+ + "[a" + index + "]";
+ }
+ return "anullsrc=r=" + properties.getAudioSampleRate() + ":cl=stereo,"
+ + format + ","
+ + "atrim=start=0:duration=" + duration + ","
+ + "asetpts=PTS-STARTPTS"
+ + "[a" + index + "]";
+ }
+
+ static String seconds(double value) {
+ return BigDecimal.valueOf(value)
+ .setScale(6, RoundingMode.HALF_UP)
+ .stripTrailingZeros()
+ .toPlainString();
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegMediaProbe.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegMediaProbe.java
new file mode 100644
index 00000000..4ca125d9
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegMediaProbe.java
@@ -0,0 +1,128 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+public class FfmpegMediaProbe {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private final FfmpegCompositionProperties properties;
+ private final FfmpegProcessRunner processRunner;
+
+ public MediaInfo probe(Path input, Double fallbackDurationSeconds, Path logPath) {
+ List command = new ArrayList<>();
+ command.add(properties.getFfprobePath());
+ command.add("-v");
+ command.add("error");
+ command.add("-show_entries");
+ command.add("stream=index,codec_type,width,height,duration:stream_tags=rotate:stream_side_data=rotation:stream_disposition=attached_pic:format=duration");
+ command.add("-of");
+ command.add("json");
+ command.add(input.toAbsolutePath().normalize().toString());
+
+ MediaProcessResult result = processRunner.run(
+ command,
+ properties.getProbeTimeout(),
+ logPath,
+ properties.getMaxProcessOutputBytes()
+ );
+ return parseProbeOutput(result.output(), fallbackDurationSeconds);
+ }
+
+ static MediaInfo parseProbeOutput(String json, Double fallbackDurationSeconds) {
+ JsonNode root;
+ try {
+ root = OBJECT_MAPPER.readTree(json);
+ } catch (Exception ex) {
+ throw new MediaProcessException("ffprobe returned invalid JSON", ex);
+ }
+ if (root == null) {
+ throw new MediaProcessException("ffprobe returned an empty response");
+ }
+
+ JsonNode video = null;
+ boolean hasAudio = false;
+ JsonNode streams = root.path("streams");
+ if (streams.isArray()) {
+ for (JsonNode stream : streams) {
+ String codecType = stream.path("codec_type").asText("");
+ if ("audio".equals(codecType)) {
+ hasAudio = true;
+ } else if (video == null && "video".equals(codecType)
+ && stream.path("disposition").path("attached_pic").asInt(0) != 1) {
+ video = stream;
+ }
+ }
+ }
+ if (video == null) {
+ throw new MediaProcessException("Input does not contain a video stream");
+ }
+ int videoStreamIndex = video.path("index").asInt(-1);
+ if (videoStreamIndex < 0) {
+ throw new MediaProcessException("ffprobe did not report the selected video stream index");
+ }
+
+ int width = video.path("width").asInt(0);
+ int height = video.path("height").asInt(0);
+ int rotation = readRotation(video);
+ if (Math.floorMod(rotation, 180) != 0) {
+ int originalWidth = width;
+ width = height;
+ height = originalWidth;
+ }
+
+ Double duration = positiveNumber(video.path("duration"));
+ if (duration == null) {
+ duration = positiveNumber(root.path("format").path("duration"));
+ }
+ if (duration == null && fallbackDurationSeconds != null
+ && Double.isFinite(fallbackDurationSeconds) && fallbackDurationSeconds > 0) {
+ duration = fallbackDurationSeconds;
+ }
+ if (duration == null) {
+ throw new MediaProcessException("Unable to determine input video duration");
+ }
+ return new MediaInfo(duration, width, height, hasAudio, videoStreamIndex);
+ }
+
+ private static int readRotation(JsonNode video) {
+ JsonNode sideData = video.path("side_data_list");
+ if (sideData.isArray()) {
+ for (JsonNode item : sideData) {
+ if (item.has("rotation")) {
+ return item.path("rotation").asInt(0);
+ }
+ }
+ }
+ String tag = video.path("tags").path("rotate").asText(null);
+ if (tag != null) {
+ try {
+ return Integer.parseInt(tag);
+ } catch (NumberFormatException ignored) {
+ return 0;
+ }
+ }
+ return 0;
+ }
+
+ private static Double positiveNumber(JsonNode node) {
+ if (node == null || node.isMissingNode() || node.isNull()) {
+ return null;
+ }
+ try {
+ double value = node.isNumber() ? node.asDouble() : Double.parseDouble(node.asText());
+ return Double.isFinite(value) && value > 0 ? value : null;
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegProcessRunner.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegProcessRunner.java
new file mode 100644
index 00000000..951c6ad8
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegProcessRunner.java
@@ -0,0 +1,96 @@
+package org.ruoyi.service.shortdrama.composition;
+
+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.StandardOpenOption;
+import java.time.Duration;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+@Component
+public class FfmpegProcessRunner {
+
+ public MediaProcessResult run(List command, Duration timeout, Path logPath, int maxOutputBytes) {
+ Objects.requireNonNull(command, "command");
+ Objects.requireNonNull(timeout, "timeout");
+ Objects.requireNonNull(logPath, "logPath");
+ if (command.isEmpty()) {
+ throw new IllegalArgumentException("Command must not be empty");
+ }
+ if (timeout.isZero() || timeout.isNegative()) {
+ throw new IllegalArgumentException("Process timeout must be positive");
+ }
+ if (maxOutputBytes <= 0) {
+ throw new IllegalArgumentException("maxOutputBytes must be positive");
+ }
+
+ Process process = null;
+ try {
+ Path absoluteLog = logPath.toAbsolutePath().normalize();
+ Files.createDirectories(absoluteLog.getParent());
+ ProcessBuilder builder = new ProcessBuilder(List.copyOf(command));
+ builder.redirectErrorStream(true);
+ builder.redirectOutput(absoluteLog.toFile());
+ process = builder.start();
+
+ if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
+ stop(process);
+ String tail = readTail(absoluteLog, maxOutputBytes);
+ throw new MediaProcessException(command.get(0) + " timed out after " + timeout + formatOutput(tail));
+ }
+
+ int exitCode = process.exitValue();
+ String output = readTail(absoluteLog, maxOutputBytes);
+ if (exitCode != 0) {
+ throw new MediaProcessException(command.get(0) + " exited with code " + exitCode + formatOutput(output));
+ }
+ return new MediaProcessResult(exitCode, output);
+ } catch (InterruptedException ex) {
+ if (process != null) {
+ process.destroyForcibly();
+ }
+ Thread.currentThread().interrupt();
+ throw new MediaProcessException(command.get(0) + " was interrupted", ex);
+ } catch (IOException ex) {
+ if (process != null) {
+ process.destroyForcibly();
+ }
+ throw new MediaProcessException("Unable to execute " + command.get(0) + ": " + ex.getMessage(), ex);
+ }
+ }
+
+ private static void stop(Process process) throws InterruptedException {
+ process.destroy();
+ if (!process.waitFor(2, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ process.waitFor(2, TimeUnit.SECONDS);
+ }
+ }
+
+ 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) {
+ // Keep reading until the selected tail is complete.
+ }
+ }
+ return new String(buffer.array(), 0, buffer.position(), StandardCharsets.UTF_8).trim();
+ }
+
+ private static String formatOutput(String output) {
+ return output == null || output.isBlank() ? "" : System.lineSeparator() + output;
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegVideoComposer.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegVideoComposer.java
new file mode 100644
index 00000000..49727e0c
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegVideoComposer.java
@@ -0,0 +1,136 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class FfmpegVideoComposer {
+
+ private final FfmpegCompositionProperties properties;
+ private final FfmpegMediaProbe mediaProbe;
+ private final FfmpegFilterGraphBuilder filterGraphBuilder;
+ private final FfmpegCommandBuilder commandBuilder;
+ private final FfmpegProcessRunner processRunner;
+
+ public CompositionArtifact compose(CompositionSpec spec, Path workDirectory) {
+ validateSources(spec);
+ Path workDir = workDirectory.toAbsolutePath().normalize();
+ try {
+ Files.createDirectories(workDir);
+ } catch (IOException ex) {
+ throw new MediaProcessException("Unable to create composition work directory", ex);
+ }
+
+ String jobToken = UUID.randomUUID().toString();
+ List media = probeInputs(spec, workDir, jobToken);
+ FfmpegFilterGraph filterGraph = filterGraphBuilder.build(spec, media);
+ Path filterScript = workDir.resolve("filter-" + jobToken + ".txt");
+ Path partialOutput = workDir.resolve("composed-" + jobToken + ".part.mp4");
+ Path finalOutput = workDir.resolve("composed-" + jobToken + ".mp4");
+ Path composeLog = workDir.resolve("ffmpeg-" + jobToken + ".log");
+
+ boolean complete = false;
+ try {
+ Files.writeString(filterScript, filterGraph.value(), StandardCharsets.UTF_8);
+ List command = commandBuilder.build(spec, filterGraph, filterScript, partialOutput);
+ processRunner.run(
+ command,
+ properties.getProcessTimeout(),
+ composeLog,
+ properties.getMaxProcessOutputBytes()
+ );
+ validateOutputFile(partialOutput);
+
+ MediaInfo outputInfo = mediaProbe.probe(
+ partialOutput,
+ filterGraph.expectedDurationSeconds(),
+ workDir.resolve("probe-output-" + jobToken + ".log")
+ );
+ validateOutputMedia(outputInfo, filterGraph);
+ moveCompletedFile(partialOutput, finalOutput);
+ complete = true;
+ return new CompositionArtifact(
+ finalOutput,
+ outputInfo.durationSeconds(),
+ outputInfo.width(),
+ outputInfo.height(),
+ spec.transitionType()
+ );
+ } catch (IOException ex) {
+ throw new MediaProcessException("Unable to write composed video artifact", ex);
+ } finally {
+ if (!complete) {
+ try {
+ Files.deleteIfExists(partialOutput);
+ } catch (IOException ignored) {
+ // Keep the original composition failure as the primary error.
+ }
+ }
+ }
+ }
+
+ private void validateSources(CompositionSpec spec) {
+ if (spec.sources().size() > properties.getMaxClips()) {
+ throw new IllegalArgumentException("Too many video clips; maximum is " + properties.getMaxClips());
+ }
+ for (int index = 0; index < spec.sources().size(); index++) {
+ Path path = spec.sources().get(index).path();
+ if (!Files.isRegularFile(path) || !Files.isReadable(path)) {
+ throw new IllegalArgumentException("Video source " + (index + 1) + " is not a readable file");
+ }
+ }
+ }
+
+ private List probeInputs(CompositionSpec spec, Path workDir, String jobToken) {
+ List result = new ArrayList<>(spec.sources().size());
+ for (int index = 0; index < spec.sources().size(); index++) {
+ CompositionSource source = spec.sources().get(index);
+ result.add(mediaProbe.probe(
+ source.path(),
+ source.fallbackDurationSeconds(),
+ workDir.resolve("probe-input-" + jobToken + "-" + index + ".log")
+ ));
+ }
+ return result;
+ }
+
+ private void validateOutputFile(Path output) throws IOException {
+ if (!Files.isRegularFile(output) || Files.size(output) < properties.getMinimumOutputBytes()) {
+ throw new MediaProcessException("FFmpeg did not produce a valid output file");
+ }
+ }
+
+ private void validateOutputMedia(MediaInfo output, FfmpegFilterGraph expected) {
+ if (output.width() != expected.canvas().width() || output.height() != expected.canvas().height()) {
+ throw new MediaProcessException("Composed video dimensions do not match the requested aspect ratio");
+ }
+ if (!output.hasAudio()) {
+ throw new MediaProcessException("Composed video is missing its normalized audio track");
+ }
+ double tolerance = Math.max(0.25, 3.0 / expected.canvas().fps());
+ if (Math.abs(output.durationSeconds() - expected.expectedDurationSeconds()) > tolerance) {
+ throw new MediaProcessException(
+ "Composed video duration differs from the expected timeline duration"
+ );
+ }
+ }
+
+ private static void moveCompletedFile(Path source, Path target) throws IOException {
+ try {
+ Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException ex) {
+ Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaInfo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaInfo.java
new file mode 100644
index 00000000..62e72b29
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaInfo.java
@@ -0,0 +1,22 @@
+package org.ruoyi.service.shortdrama.composition;
+
+public record MediaInfo(
+ double durationSeconds,
+ int width,
+ int height,
+ boolean hasAudio,
+ int videoStreamIndex
+) {
+
+ public MediaInfo {
+ if (!Double.isFinite(durationSeconds) || durationSeconds <= 0) {
+ throw new IllegalArgumentException("Video duration must be positive");
+ }
+ if (width <= 0 || height <= 0) {
+ throw new IllegalArgumentException("Video dimensions must be positive");
+ }
+ if (videoStreamIndex < 0) {
+ throw new IllegalArgumentException("Video stream index must be non-negative");
+ }
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessException.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessException.java
new file mode 100644
index 00000000..25dc5692
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessException.java
@@ -0,0 +1,12 @@
+package org.ruoyi.service.shortdrama.composition;
+
+public class MediaProcessException extends RuntimeException {
+
+ public MediaProcessException(String message) {
+ super(message);
+ }
+
+ public MediaProcessException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessResult.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessResult.java
new file mode 100644
index 00000000..de69f937
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/MediaProcessResult.java
@@ -0,0 +1,4 @@
+package org.ruoyi.service.shortdrama.composition;
+
+public record MediaProcessResult(int exitCode, String output) {
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/TransitionType.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/TransitionType.java
new file mode 100644
index 00000000..3189ef1b
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/TransitionType.java
@@ -0,0 +1,40 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+
+import java.util.Locale;
+
+public enum TransitionType {
+ NONE(null),
+ DISSOLVE("dissolve"),
+ FADE("fadeblack"),
+ SLIDE("slideleft");
+
+ private final String ffmpegName;
+
+ TransitionType(String ffmpegName) {
+ this.ffmpegName = ffmpegName;
+ }
+
+ public String ffmpegName() {
+ return ffmpegName;
+ }
+
+ @JsonValue
+ public String value() {
+ return name().toLowerCase(Locale.ROOT);
+ }
+
+ @JsonCreator
+ public static TransitionType fromValue(String value) {
+ if (value == null || value.isBlank()) {
+ return NONE;
+ }
+ try {
+ return valueOf(value.trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException ex) {
+ throw new IllegalArgumentException("Unsupported transition type: " + value, ex);
+ }
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCanvas.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCanvas.java
new file mode 100644
index 00000000..88c1ef8d
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCanvas.java
@@ -0,0 +1,10 @@
+package org.ruoyi.service.shortdrama.composition;
+
+public record VideoCanvas(int width, int height, int fps) {
+
+ public VideoCanvas {
+ if (width <= 0 || height <= 0 || fps <= 0 || width % 2 != 0 || height % 2 != 0) {
+ throw new IllegalArgumentException("Canvas dimensions must be positive even numbers and fps must be positive");
+ }
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCompositionExecutorConfig.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCompositionExecutorConfig.java
new file mode 100644
index 00000000..664f1ddb
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/VideoCompositionExecutorConfig.java
@@ -0,0 +1,34 @@
+package org.ruoyi.service.shortdrama.composition;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+@Configuration
+@RequiredArgsConstructor
+public class VideoCompositionExecutorConfig {
+
+ private final FfmpegCompositionProperties properties;
+
+ @Bean(name = "videoCompositionExecutor")
+ public ThreadPoolTaskExecutor videoCompositionExecutor() {
+ if (properties.getWorkerCoreSize() <= 0
+ || properties.getWorkerMaxSize() < properties.getWorkerCoreSize()
+ || properties.getWorkerQueueCapacity() < 0) {
+ throw new IllegalArgumentException("Invalid video composition executor configuration");
+ }
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(properties.getWorkerCoreSize());
+ executor.setMaxPoolSize(properties.getWorkerMaxSize());
+ executor.setQueueCapacity(properties.getWorkerQueueCapacity());
+ executor.setThreadNamePrefix("video-compose-");
+ executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
+ executor.setWaitForTasksToCompleteOnShutdown(true);
+ executor.setAwaitTerminationSeconds(30);
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/SafeVideoSourceDownloader.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/SafeVideoSourceDownloader.java
new file mode 100644
index 00000000..2274102e
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/SafeVideoSourceDownloader.java
@@ -0,0 +1,363 @@
+package org.ruoyi.service.shortdrama.download;
+
+import okhttp3.Dns;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.IDN;
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.Proxy;
+import java.net.URI;
+import java.net.UnknownHostException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+@Component
+public class SafeVideoSourceDownloader {
+
+ private final VideoSourceDownloadProperties properties;
+
+ public SafeVideoSourceDownloader(VideoSourceDownloadProperties properties) {
+ this.properties = properties;
+ validateProperties(properties);
+ }
+
+ public long download(String sourceUrl, Path target, long maxSourceBytes, long remainingTotalBytes) {
+ if (maxSourceBytes <= 0 || remainingTotalBytes <= 0) {
+ throw new VideoSourceDownloadException("Video download size limit is invalid");
+ }
+ URI current = parseUri(sourceUrl);
+
+ try {
+ for (int redirectCount = 0; ; redirectCount++) {
+ ResolvedTarget resolved = resolvePublicTarget(current);
+ OkHttpClient client = clientFor(resolved);
+ try (Response response = client.newCall(new Request.Builder()
+ .url(current.toASCIIString())
+ .header("User-Agent", "ruoyi-ai-short-drama-composer")
+ .get()
+ .build()).execute()) {
+ if (isRedirect(response.code())) {
+ if (redirectCount >= properties.getMaxRedirects()) {
+ throw new VideoSourceDownloadException("Video source exceeded the redirect limit");
+ }
+ String location = response.header("Location");
+ if (location == null || location.isBlank()) {
+ throw new VideoSourceDownloadException("Video source redirect has no Location header");
+ }
+ URI redirected = parseUri(current.resolve(location).toString());
+ if ("https".equalsIgnoreCase(current.getScheme())
+ && "http".equalsIgnoreCase(redirected.getScheme())) {
+ throw new VideoSourceDownloadException("HTTPS video source cannot redirect to HTTP");
+ }
+ current = redirected;
+ continue;
+ }
+ if (!response.isSuccessful()) {
+ throw new VideoSourceDownloadException("Video source download failed with HTTP " + response.code());
+ }
+ ResponseBody body = response.body();
+ if (body == null) {
+ throw new VideoSourceDownloadException("Video source response is empty");
+ }
+ assertSize(body.contentLength(), maxSourceBytes, remainingTotalBytes);
+ try (InputStream input = body.byteStream()) {
+ long downloaded = copyWithLimit(input, target, maxSourceBytes, remainingTotalBytes);
+ if (downloaded == 0) {
+ throw new VideoSourceDownloadException("Downloaded video source is empty");
+ }
+ return downloaded;
+ }
+ } finally {
+ client.connectionPool().evictAll();
+ client.dispatcher().executorService().shutdown();
+ }
+ }
+ } catch (IOException ex) {
+ deletePartial(target);
+ throw new VideoSourceDownloadException("Video source download failed", ex);
+ } catch (RuntimeException ex) {
+ deletePartial(target);
+ throw ex;
+ }
+ }
+
+ private OkHttpClient clientFor(ResolvedTarget target) {
+ Dns pinnedDns = hostname -> {
+ String requested = normalizeHost(hostname);
+ if (!target.host().equals(requested)) {
+ throw new UnknownHostException("Unvalidated redirect destination: " + hostname);
+ }
+ return target.addresses();
+ };
+ return new OkHttpClient.Builder()
+ .proxy(Proxy.NO_PROXY)
+ .dns(pinnedDns)
+ .followRedirects(false)
+ .followSslRedirects(false)
+ .connectTimeout(properties.getConnectTimeout())
+ .callTimeout(properties.getCallTimeout())
+ .readTimeout(properties.getCallTimeout())
+ .build();
+ }
+
+ private ResolvedTarget resolvePublicTarget(URI uri) {
+ String host = normalizeHost(uri.getHost());
+ enforceHostAllowlist(host);
+ final List addresses;
+ try {
+ addresses = List.copyOf(Arrays.asList(InetAddress.getAllByName(host)));
+ } catch (UnknownHostException ex) {
+ throw new VideoSourceDownloadException("Video source host cannot be resolved", ex);
+ }
+ boolean fakeIpAllowed = matchesHostRules(host, properties.getFakeIpAllowedHosts());
+ if (addresses.isEmpty() || addresses.stream().anyMatch(address ->
+ !isPublicAddress(address) && !(fakeIpAllowed && isFakeProxyAddress(address)))) {
+ throw new VideoSourceDownloadException("Video source resolves to a private or special-use address");
+ }
+ return new ResolvedTarget(host, addresses);
+ }
+
+ private boolean matchesHostRules(String host, List rules) {
+ if (rules == null || rules.isEmpty()) return false;
+ for (String rule : rules) {
+ if (rule == null || rule.isBlank()) continue;
+ String normalizedRule = normalizeHostRule(rule);
+ if (normalizedRule.startsWith("*.")) {
+ String suffix = normalizedRule.substring(1);
+ if (host.endsWith(suffix) && host.length() > suffix.length()) return true;
+ } else if (host.equals(normalizedRule)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ private void enforceHostAllowlist(String host) {
+ List rules = properties.getAllowedHosts();
+ if (rules == null || rules.stream().allMatch(rule -> rule == null || rule.isBlank())) {
+ return;
+ }
+ for (String rule : rules) {
+ if (rule == null || rule.isBlank()) {
+ continue;
+ }
+ String normalizedRule = normalizeHostRule(rule);
+ if (normalizedRule.startsWith("*.")) {
+ String suffix = normalizedRule.substring(1);
+ if (host.endsWith(suffix) && host.length() > suffix.length()) {
+ return;
+ }
+ } else if (host.equals(normalizedRule)) {
+ return;
+ }
+ }
+ throw new VideoSourceDownloadException("Video source host is not allowlisted");
+ }
+
+ private static URI parseUri(String value) {
+ final URI uri;
+ try {
+ uri = URI.create(value).normalize();
+ } catch (RuntimeException ex) {
+ throw new VideoSourceDownloadException("Video source URL is invalid", ex);
+ }
+ if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) {
+ throw new VideoSourceDownloadException("Video source URL must use HTTP or HTTPS");
+ }
+ if (uri.getHost() == null || uri.getHost().isBlank() || uri.getRawUserInfo() != null) {
+ throw new VideoSourceDownloadException("Video source URL authority is invalid");
+ }
+ if (uri.getPort() < -1 || uri.getPort() == 0 || uri.getPort() > 65_535) {
+ throw new VideoSourceDownloadException("Video source URL port is invalid");
+ }
+ return uri;
+ }
+
+ static boolean isFakeProxyAddress(InetAddress address) {
+ if (!(address instanceof Inet4Address)) return false;
+ byte[] bytes = address.getAddress();
+ int first = bytes[0] & 0xFF;
+ int second = bytes[1] & 0xFF;
+ return first == 198 && (second == 18 || second == 19);
+ }
+ static boolean isPublicAddress(InetAddress address) {
+ if (address.isAnyLocalAddress()
+ || address.isLoopbackAddress()
+ || address.isLinkLocalAddress()
+ || address.isSiteLocalAddress()
+ || address.isMulticastAddress()) {
+ return false;
+ }
+ byte[] bytes = address.getAddress();
+ if (address instanceof Inet4Address) {
+ return isPublicIpv4(bytes);
+ }
+ if (address instanceof Inet6Address) {
+ return isPublicIpv6(bytes);
+ }
+ return false;
+ }
+
+ private static boolean isPublicIpv6(byte[] bytes) {
+ // Only 2000::/3 global unicast is eligible. Mapped/compatible and standard NAT64 are outside it.
+ if (!matchesPrefix(bytes, 3, 0x20)) {
+ return false;
+ }
+
+ // IANA special-purpose entries within 2000::/3, plus the retired 6bone block.
+ // The 2001::/23 parent also covers Teredo, benchmarking and both ORCHID ranges.
+ return !matchesPrefix(bytes, 23, 0x20, 0x01, 0x00)
+ && !matchesPrefix(bytes, 32, 0x20, 0x01, 0x0D, 0xB8)
+ && !matchesPrefix(bytes, 16, 0x20, 0x02)
+ && !matchesPrefix(bytes, 48, 0x26, 0x20, 0x00, 0x4F, 0x80, 0x00)
+ && !matchesPrefix(bytes, 16, 0x3F, 0xFE)
+ && !matchesPrefix(bytes, 20, 0x3F, 0xFF, 0x00);
+ }
+
+ private static boolean isPublicIpv4(byte[] bytes) {
+ int a = bytes[0] & 0xFF;
+ int b = bytes[1] & 0xFF;
+ int c = bytes[2] & 0xFF;
+ if (a == 0 || a == 10 || a == 127 || a >= 224) {
+ return false;
+ }
+ if (a == 100 && b >= 64 && b <= 127) {
+ return false;
+ }
+ if (a == 169 && b == 254) {
+ return false;
+ }
+ if (a == 172 && b >= 16 && b <= 31) {
+ return false;
+ }
+ if (a == 192 && (b == 168 || (b == 0 && c == 0) || (b == 0 && c == 2))) {
+ return false;
+ }
+ if (a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100))) {
+ return false;
+ }
+ return !(a == 203 && b == 0 && c == 113);
+ }
+
+ private static boolean matchesPrefix(byte[] address, int prefixLength, int... prefixBytes) {
+ int fullBytes = prefixLength / Byte.SIZE;
+ int remainingBits = prefixLength % Byte.SIZE;
+ int requiredBytes = fullBytes + (remainingBits == 0 ? 0 : 1);
+ if (address.length < requiredBytes || prefixBytes.length < requiredBytes) {
+ return false;
+ }
+ for (int index = 0; index < fullBytes; index++) {
+ if ((address[index] & 0xFF) != prefixBytes[index]) {
+ return false;
+ }
+ }
+ if (remainingBits == 0) {
+ return true;
+ }
+ int mask = 0xFF << (Byte.SIZE - remainingBits);
+ return ((address[fullBytes] & 0xFF) & mask) == (prefixBytes[fullBytes] & mask);
+ }
+
+ private static String normalizeHost(String host) {
+ if (host == null) {
+ throw new VideoSourceDownloadException("Video source host is missing");
+ }
+ String unwrapped = host.startsWith("[") && host.endsWith("]")
+ ? host.substring(1, host.length() - 1)
+ : host;
+ if (unwrapped.indexOf(':') >= 0) {
+ return unwrapped.toLowerCase(Locale.ROOT);
+ }
+ try {
+ return IDN.toASCII(unwrapped, IDN.USE_STD3_ASCII_RULES)
+ .toLowerCase(Locale.ROOT)
+ .replaceFirst("\\.$", "");
+ } catch (IllegalArgumentException ex) {
+ throw new VideoSourceDownloadException("Video source host is invalid", ex);
+ }
+ }
+
+ private static String normalizeHostRule(String rule) {
+ String trimmed = rule.trim().toLowerCase(Locale.ROOT);
+ if (trimmed.startsWith("*.")) {
+ return "*." + normalizeHost(trimmed.substring(2));
+ }
+ return normalizeHost(trimmed);
+ }
+
+ private static boolean isRedirect(int status) {
+ return status == 301 || status == 302 || status == 303 || status == 307 || status == 308;
+ }
+
+ private static long copyWithLimit(
+ InputStream input,
+ Path target,
+ long maxSourceBytes,
+ long remainingTotalBytes
+ ) throws IOException {
+ long downloaded = 0;
+ byte[] buffer = new byte[64 * 1024];
+ try (OutputStream output = Files.newOutputStream(
+ target,
+ StandardOpenOption.CREATE_NEW,
+ StandardOpenOption.WRITE
+ )) {
+ int read;
+ while ((read = input.read(buffer)) >= 0) {
+ if (read == 0) {
+ continue;
+ }
+ downloaded += read;
+ assertSize(downloaded, maxSourceBytes, remainingTotalBytes);
+ output.write(buffer, 0, read);
+ }
+ }
+ return downloaded;
+ }
+
+ private static void assertSize(long bytes, long maxSourceBytes, long remainingTotalBytes) {
+ if (bytes < 0) {
+ return;
+ }
+ if (bytes > maxSourceBytes) {
+ throw new VideoSourceDownloadException("A video source exceeds the per-file size limit");
+ }
+ if (bytes > remainingTotalBytes) {
+ throw new VideoSourceDownloadException("Video sources exceed the total download size limit");
+ }
+ }
+
+ private static void deletePartial(Path target) {
+ try {
+ Files.deleteIfExists(target);
+ } catch (IOException ignored) {
+ // The enclosing composition job also removes its complete work directory.
+ }
+ }
+
+ private static void validateProperties(VideoSourceDownloadProperties properties) {
+ Duration connectTimeout = properties.getConnectTimeout();
+ Duration callTimeout = properties.getCallTimeout();
+ if (connectTimeout == null || connectTimeout.isZero() || connectTimeout.isNegative()
+ || callTimeout == null || callTimeout.isZero() || callTimeout.isNegative()
+ || properties.getMaxRedirects() < 0 || properties.getMaxRedirects() > 10) {
+ throw new IllegalArgumentException("Invalid secure video download configuration");
+ }
+ }
+
+ private record ResolvedTarget(String host, List addresses) {
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadException.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadException.java
new file mode 100644
index 00000000..0fbcf2fd
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadException.java
@@ -0,0 +1,12 @@
+package org.ruoyi.service.shortdrama.download;
+
+public class VideoSourceDownloadException extends RuntimeException {
+
+ public VideoSourceDownloadException(String message) {
+ super(message);
+ }
+
+ public VideoSourceDownloadException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadProperties.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadProperties.java
new file mode 100644
index 00000000..692c9acf
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/download/VideoSourceDownloadProperties.java
@@ -0,0 +1,30 @@
+package org.ruoyi.service.shortdrama.download;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "short-drama.composition.download")
+public class VideoSourceDownloadProperties {
+
+ private Duration connectTimeout = Duration.ofSeconds(30);
+ private Duration callTimeout = Duration.ofMinutes(10);
+ private int maxRedirects = 5;
+
+ /**
+ * Optional exact hosts or wildcard suffixes such as *.example.com.
+ * When empty, any public destination is accepted.
+ */
+ private List allowedHosts = new ArrayList<>();
+ /**
+ * Hosts allowed to resolve into a TUN proxy fake-IP range (198.18.0.0/15).
+ * This exception never applies to loopback, RFC1918 or link-local addresses.
+ */
+ private List fakeIpAllowedHosts = new ArrayList<>();
+}
diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java
new file mode 100644
index 00000000..91f9f000
--- /dev/null
+++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java
@@ -0,0 +1,3795 @@
+package org.ruoyi.service.shortdrama.impl;
+
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import dev.langchain4j.data.message.ChatMessage;
+import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.StreamingChatModel;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
+
+import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
+import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
+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.ImageServiceFactory;
+import org.ruoyi.common.chat.factory.VideoServiceFactory;
+import org.ruoyi.common.chat.service.chat.IChatModelService;
+import org.ruoyi.common.core.utils.MapstructUtils;
+import org.ruoyi.common.core.utils.StringUtils;
+import org.ruoyi.common.json.utils.JsonUtils;
+import org.ruoyi.constant.ShortDramaImageConstants;
+import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo;
+import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo;
+import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo;
+import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptResult;
+import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo;
+import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter;
+import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacterAppearance;
+import org.ruoyi.domain.entity.shortdrama.ShortDramaLocation;
+import org.ruoyi.domain.entity.shortdrama.ShortDramaProject;
+import org.ruoyi.domain.entity.shortdrama.ShortDramaScript;
+import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard;
+import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo;
+import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo;
+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.domain.bo.shortdrama.ShortDramaCharacterBo;
+import org.ruoyi.domain.bo.shortdrama.ShortDramaCharacterAppearanceBo;
+import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo;
+import org.ruoyi.mapper.shortdrama.ShortDramaCharacterMapper;
+import org.ruoyi.mapper.shortdrama.ShortDramaCharacterAppearanceMapper;
+import org.ruoyi.mapper.shortdrama.ShortDramaLocationMapper;
+import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper;
+import org.ruoyi.mapper.shortdrama.ShortDramaScriptMapper;
+import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper;
+import org.ruoyi.factory.ChatServiceFactory;
+import org.ruoyi.service.chat.AbstractChatService;
+import org.ruoyi.service.media.AtlasPredictionService;
+import org.ruoyi.service.shortdrama.IShortDramaService;
+import org.ruoyi.service.shortdrama.IShortDramaVideoComposeService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ShortDramaServiceImpl implements IShortDramaService {
+
+ private final ShortDramaProjectMapper projectMapper;
+ private final ShortDramaScriptMapper scriptMapper;
+ private final ShortDramaStoryboardMapper storyboardMapper;
+ private final ShortDramaCharacterMapper characterMapper;
+ private final ShortDramaCharacterAppearanceMapper characterAppearanceMapper;
+ private final ShortDramaLocationMapper locationMapper;
+ private final IChatModelService chatModelService;
+ private final ChatServiceFactory chatServiceFactory;
+ private final VideoServiceFactory videoServiceFactory;
+ private final ImageServiceFactory imageServiceFactory;
+ private final AtlasPredictionService atlasPredictionService;
+ private final IShortDramaVideoComposeService videoComposeService;
+ private final java.util.Map activeEmitters = new ConcurrentHashMap<>();
+ private final java.util.Map storyboardGenerationStates = new ConcurrentHashMap<>();
+
+ /**
+ * 代理流式输出视频,绕过 OSS 强制下载 header。
+ * TODO: 待实现,当前为占位以满足接口契约。
+ */
+ @Override
+ public StreamingResponseBody streamVideo(Long storyboardId, Long userId) {
+ throw new UnsupportedOperationException("streamVideo 尚未实现");
+ }
+
+ // ==================== 查询 ====================
+
+ @Override
+ public List listProjects(Long userId) {
+ return projectMapper.selectVoList(new LambdaQueryWrapper()
+ .eq(ShortDramaProject::getUserId, userId)
+ .orderByDesc(ShortDramaProject::getId));
+ }
+
+ @Override
+ public ShortDramaDetailVo getDetail(Long projectId, Long userId) {
+ ShortDramaProject project = projectMapper.selectOne(new LambdaQueryWrapper()
+ .eq(ShortDramaProject::getId, projectId)
+ .eq(ShortDramaProject::getUserId, userId));
+ if (project == null) {
+ return null;
+ }
+ ShortDramaScript script = scriptMapper.selectOne(new LambdaQueryWrapper()
+ .eq(ShortDramaScript::getProjectId, projectId)
+ .orderByDesc(ShortDramaScript::getId)
+ .last("limit 1"));
+ ShortDramaDetailVo detailVo = new ShortDramaDetailVo();
+ detailVo.setProject(MapstructUtils.convert(project, ShortDramaProjectVo.class));
+ detailVo.setScript(script == null ? null : MapstructUtils.convert(script, ShortDramaScriptVo.class));
+
+ List characters = characterMapper.selectVoList(new LambdaQueryWrapper()
+ .eq(ShortDramaCharacter::getProjectId, projectId));
+ for (ShortDramaCharacterVo characterVo : characters) {
+ List appearances = characterAppearanceMapper.selectVoList(
+ new LambdaQueryWrapper()
+ .eq(ShortDramaCharacterAppearance::getCharacterId, characterVo.getId())
+ .orderByAsc(ShortDramaCharacterAppearance::getAppearanceIndex));
+ characterVo.setAppearances(appearances);
+ }
+ detailVo.setCharacters(characters);
+
+ List locations = locationMapper.selectVoList(new LambdaQueryWrapper()
+ .eq(ShortDramaLocation::getProjectId, projectId));
+ detailVo.setLocations(locations);
+
+ List storyboards = storyboardMapper.selectVoList(new LambdaQueryWrapper()
+ .eq(ShortDramaStoryboard::getProjectId, projectId)
+ .orderByAsc(ShortDramaStoryboard::getSceneNo));
+ detailVo.setStoryboards(storyboards);
+ return detailVo;
+ }
+
+ // ==================== 核心:六阶段流水线 ====================
+
+ @Override
+ public ShortDramaDetailVo createFromIdea(ShortDramaIdeaBo bo, Long userId) {
+ ChatModelVo modelVo = validateAndGetModel(bo.getModel());
+ AbstractChatService chatService = getChatService(modelVo);
+ ChatModel chatModel = chatService.buildChatModel(modelVo);
+
+ // Phase 1: 剧本打磨
+ ShortDramaScriptResult polishResult = executePhase1_ScriptPolish(chatModel, bo);
+ ShortDramaProject project = buildAndInsertProject(userId, polishResult, bo);
+ ShortDramaScript script = buildAndInsertScript(project.getId(), polishResult);
+
+ // Phase 2: 资产分析(角色+场景提取)
+ executePhase2_AssetAnalysis(chatModel, project.getId(), script);
+
+ // Phase 3-6: 分镜流水线
+ List panels = executeStoryboardPipeline(chatModel, script, project.getId());
+
+ return getDetail(project.getId(), userId);
+ }
+
+ @Override
+ public SseEmitter createFromIdeaStream(ShortDramaIdeaBo 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(() -> {
+ Long projectId = null;
+ try {
+ ChatModelVo modelVo = validateAndGetModel(bo.getModel());
+ AbstractChatService chatService = getChatService(modelVo);
+ ChatModel chatModel = chatService.buildChatModel(modelVo);
+ StreamingChatModel streamingModel = chatService.buildStreamingChatModel(modelVo, new ChatRequest());
+
+ // Phase 1: 单次流式调用(JSON 元信息 → 分隔符 → 剧本正文逐字推送)
+ emit(emitter, "polish", "running", "正在生成故事大纲...");
+ ShortDramaScriptResult polishResult = executePhase1_Streaming(streamingModel, bo, emitter);
+ ShortDramaProject project = buildAndInsertProject(userId, polishResult, bo);
+ projectId = project.getId();
+ ShortDramaScript script = buildAndInsertScript(projectId, polishResult);
+ emit(emitter, "polish", "done", "剧本打磨完成");
+
+ // Phase 2: 资产分析(角色 + 场景 并发,流式输出)
+ emit(emitter, "assets", "running", "正在分析角色和场景...");
+ StreamingChatModel assetsStreamModel = chatService.buildStreamingChatModel(modelVo, new ChatRequest());
+ executePhase2_AssetAnalysis(chatModel, assetsStreamModel, projectId, script, emitter);
+ long charCount = characterMapper.selectCount(new LambdaQueryWrapper()
+ .eq(ShortDramaCharacter::getProjectId, projectId));
+ long locCount = locationMapper.selectCount(new LambdaQueryWrapper()
+ .eq(ShortDramaLocation::getProjectId, projectId));
+ emit(emitter, "assets", "done", "提取了 " + charCount + " 个角色、" + locCount + " 个场景");
+
+ // Phase 3-6: 分镜流水线(各子阶段内部创建 StreamingChatModel 实现流式输出)
+ List panels = executeStoryboardPipeline(chatModel, script, projectId, emitter);
+ emit(emitter, "storyboard", "done", "分镜完成,共 " + panels.size() + " 个镜头");
+
+ // 完成
+ sendEmitterEvent(emitter, SseEmitter.event()
+ .name("complete")
+ .data("{\"projectId\":\"" + projectId + "\"}"));
+ completeEmitter(emitter);
+ } catch (Exception e) {
+ log.error("流式创建短剧失败", e);
+ String errorData = projectId != null
+ ? "{\"message\":\"" + escapeJson(e.getMessage()) + "\",\"projectId\":\"" + projectId + "\"}"
+ : "{\"message\":\"" + escapeJson(e.getMessage()) + "\"}";
+ sendEmitterEvent(emitter, SseEmitter.event().name("error").data(errorData));
+ completeEmitterWithError(emitter, e);
+ }
+ });
+
+ return emitter;
+ }
+
+ 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) { }
+ }
+
+ private void emit(SseEmitter emitter, String phase, String status, String message) {
+ String data = "{\"phase\":\"" + phase + "\",\"status\":\"" + status + "\",\"message\":\"" + escapeJson(message) + "\"}";
+ sendEmitterEvent(emitter, SseEmitter.event().name("phase").data(data));
+ }
+
+ private void emitStream(SseEmitter emitter, String phase, String text) {
+ String data = "{\"phase\":\"" + phase + "\",\"text\":\"" + escapeJson(text) + "\"}";
+ sendEmitterEvent(emitter, SseEmitter.event().name("stream").data(data));
+ }
+
+ private void emitStreamDone(SseEmitter emitter) {
+ sendEmitterEvent(emitter, SseEmitter.event().name("stream")
+ .data("{\"phase\":\"script\",\"status\":\"done\"}"));
+ }
+
+ private static String escapeJson(String s) {
+ if (s == null) return "";
+ return s.replace("\\", "\\\\").replace("\"", "\\\"");
+ }
+
+ private ShortDramaProject buildAndInsertProject(Long userId, ShortDramaScriptResult polishResult, ShortDramaIdeaBo bo) {
+ ShortDramaProject project = new ShortDramaProject();
+ project.setId(IdUtil.getSnowflakeNextId());
+ project.setUserId(userId);
+ project.setProjectName(firstNotBlank(bo.getProjectName(), polishResult.getProjectName(), "短剧项目"));
+ project.setDescription(firstNotBlank(polishResult.getDescription(), ""));
+ project.setArtStyle(firstNotBlank(bo.getArtStyle(), ShortDramaImageConstants.DEFAULT_ART_STYLE));
+ project.setComposeAspectRatio(normalizeAspectRatio(bo.getAspectRatio()));
+ project.setStatus("draft");
+ projectMapper.insert(project);
+ return project;
+ }
+
+ private ShortDramaScript buildAndInsertScript(Long projectId, ShortDramaScriptResult polishResult) {
+ ShortDramaScript script = new ShortDramaScript();
+ script.setId(IdUtil.getSnowflakeNextId());
+ script.setProjectId(projectId);
+ script.setScriptName(firstNotBlank(polishResult.getScriptName(), "剧本"));
+ script.setOutlineText(firstNotBlank(polishResult.getOutlineText(), ""));
+ script.setScriptText(firstNotBlank(polishResult.getScriptText(), ""));
+ script.setTone(firstNotBlank(polishResult.getTone(), "短剧"));
+ script.setSourceType("llm");
+ scriptMapper.insert(script);
+ return script;
+ }
+
+ private List executeStoryboardPipeline(ChatModel chatModel, ShortDramaScript script, Long projectId) {
+ return executeStoryboardPipeline(chatModel, script, projectId, null);
+ }
+
+ private List executeStoryboardPipeline(ChatModel chatModel, ShortDramaScript script, Long projectId, SseEmitter emitter) {
+ // Phase 3: 分镜规划(流式输出由 executePhase3_StoryboardPlan 内部处理)
+ if (emitter != null) emit(emitter, "storyboard_plan", "running", "正在规划分镜镜头...");
+ List panels = executePhase3_StoryboardPlan(chatModel, script, projectId, emitter);
+ // Phase 3 的成功/失败由子方法内部 emit(因为流式输出的 done/error 在 streamingChat 回调中处理)
+ if (emitter != null && !panels.isEmpty()) emit(emitter, "storyboard_plan", "done", "分镜规划完成,共 " + panels.size() + " 个镜头");
+
+ if (!panels.isEmpty()) {
+ List planPanels = panels;
+
+ // 摄影规则与表演指导使用确定性本地规则生成,避免为整组镜头额外发起两次大模型请求。
+ if (emitter != null) emit(emitter, "photography", "running", "正在生成摄影规则...");
+ List photographyRules = buildLocalPhotographyRules(planPanels);
+ if (emitter != null) emit(emitter, "photography", "done", "摄影规则生成完成");
+
+ if (emitter != null) emit(emitter, "acting", "running", "正在生成表演指导...");
+ List actingDirections = buildLocalActingDirections(planPanels);
+ if (emitter != null) emit(emitter, "acting", "done", "表演指导生成完成");
+
+ // 分镜细化保留一次模型调用,统一生成景别、运镜和时长匹配的视频/图片提示词。
+ if (emitter != null) emit(emitter, "storyboard_detail", "running", "正在细化分镜详情...");
+ executePhase6_StoryboardDetail(chatModel, planPanels, projectId, emitter);
+ normalizeContinuityChain(planPanels);
+ panels = mergePanelsWithRules(planPanels, photographyRules, actingDirections);
+ }
+ persistStoryboards(projectId, script.getId(), panels, script);
+ return panels;
+ }
+
+ @Override
+ public ShortDramaDetailVo polishScript(Long projectId, Long userId) {
+ ShortDramaProject project = validateProjectOwner(projectId, userId);
+ ShortDramaScript script = scriptMapper.selectOne(new LambdaQueryWrapper()
+ .eq(ShortDramaScript::getProjectId, projectId)
+ .orderByDesc(ShortDramaScript::getId).last("limit 1"));
+ String idea = script != null ? firstNotBlank(script.getScriptText(), script.getOutlineText(), project.getDescription()) : project.getDescription();
+ ChatModelVo modelVo = findChatModel();
+ AbstractChatService chatService = getChatService(modelVo);
+ ChatModel chatModel = chatService.buildChatModel(modelVo);
+
+ ShortDramaIdeaBo bo = new ShortDramaIdeaBo();
+ bo.setIdea(idea);
+ bo.setProjectName(project.getProjectName());
+ ShortDramaScriptResult result = executePhase1_ScriptPolish(chatModel, bo);
+
+ if (script == null) {
+ script = new ShortDramaScript();
+ script.setId(IdUtil.getSnowflakeNextId());
+ script.setProjectId(projectId);
+ script.setSourceType("llm");
+ }
+ project.setProjectName(firstNotBlank(result.getProjectName(), project.getProjectName()));
+ project.setDescription(firstNotBlank(result.getDescription(), project.getDescription()));
+ projectMapper.updateById(project);
+ script.setScriptName(firstNotBlank(result.getScriptName(), script.getScriptName()));
+ script.setOutlineText(firstNotBlank(result.getOutlineText(), script.getOutlineText()));
+ script.setScriptText(firstNotBlank(result.getScriptText(), script.getScriptText()));
+ script.setTone(firstNotBlank(result.getTone(), script.getTone()));
+ if (script.getId() != null && scriptMapper.selectById(script.getId()) != null) {
+ scriptMapper.updateById(script);
+ } else {
+ scriptMapper.insert(script);
+ }
+ return getDetail(projectId, userId);
+ }
+
+ // ==================== 项目/剧本 CRUD ====================
+
+ @Override
+ public Long saveProject(ShortDramaProjectBo bo, Long userId) {
+ ShortDramaProject entity = MapstructUtils.convert(bo, ShortDramaProject.class);
+ entity.setUserId(userId);
+ if (entity.getId() == null) {
+ entity.setStatus(StrUtil.blankToDefault(entity.getStatus(), "draft"));
+ projectMapper.insert(entity);
+ } else {
+ projectMapper.updateById(entity);
+ }
+ return entity.getId();
+ }
+
+ @Override
+ public ShortDramaScriptVo saveScript(ShortDramaScriptBo bo, Long userId) {
+ ShortDramaProject project = projectMapper.selectById(bo.getProjectId());
+ if (project == null || !userId.equals(project.getUserId())) {
+ throw new IllegalArgumentException("项目不存在或无权限");
+ }
+ ShortDramaScript entity = MapstructUtils.convert(bo, ShortDramaScript.class);
+ entity.setSourceType(StrUtil.blankToDefault(entity.getSourceType(), "manual"));
+ if (entity.getId() == null) {
+ scriptMapper.insert(entity);
+ } else {
+ scriptMapper.updateById(entity);
+ }
+ return MapstructUtils.convert(entity, ShortDramaScriptVo.class);
+ }
+
+ // ==================== 分镜生成与规划 ====================
+
+ @Override
+ public List generateStoryboards(Long projectId, Long scriptId, String model, Long userId) {
+ if (!beginStoryboardGeneration(scriptId)) {
+ throw new IllegalStateException("该剧本正在生成分镜,请勿重复提交");
+ }
+ try {
+ ShortDramaProject project = validateProjectOwner(projectId, userId);
+ ShortDramaScript script = scriptMapper.selectById(scriptId);
+ if (script == null || !projectId.equals(script.getProjectId())) {
+ throw new IllegalArgumentException("剧本不存在");
+ }
+ videoComposeService.invalidateComposition(projectId);
+ // 删除旧分镜
+ storyboardMapper.delete(new LambdaQueryWrapper()
+ .eq(ShortDramaStoryboard::getScriptId, scriptId));
+
+ ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel();
+ AbstractChatService chatService = getChatService(modelVo);
+ ChatModel chatModel = chatService.buildChatModel(modelVo);
+
+ List panels = executePhase3_StoryboardPlan(chatModel, script, projectId);
+ if (panels.isEmpty()) {
+ panels = fallbackPanels(script);
+ }
+ if (!panels.isEmpty()) {
+ List photographyRules = buildLocalPhotographyRules(panels);
+ List actingDirections = buildLocalActingDirections(panels);
+ executePhase6_StoryboardDetail(chatModel, panels, projectId);
+ normalizeContinuityChain(panels);
+ panels = mergePanelsWithRules(panels, photographyRules, actingDirections);
+ }
+ return persistStoryboards(projectId, scriptId, panels, script);
+ } finally {
+ endStoryboardGeneration(scriptId);
+ }
+ }
+
+ @Override
+ public List planStoryboard(Long projectId, Long scriptId, String model, Long userId) {
+ return generateStoryboards(projectId, scriptId, model, userId);
+ }
+
+ @Override
+ public SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, 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(() -> {
+ if (!beginStoryboardGeneration(scriptId)) {
+ sendEmitterEvent(emitter, SseEmitter.event().name("error")
+ .data("{\"message\":\"该剧本正在生成分镜,请勿重复提交\"}"));
+ completeEmitter(emitter);
+ return;
+ }
+ try {
+ validateProjectOwner(projectId, userId);
+ ShortDramaScript script = scriptMapper.selectById(scriptId);
+ if (script == null || !projectId.equals(script.getProjectId())) {
+ throw new IllegalArgumentException("剧本不存在");
+ }
+
+ videoComposeService.invalidateComposition(projectId);
+ storyboardMapper.delete(new LambdaQueryWrapper()
+ .eq(ShortDramaStoryboard::getScriptId, scriptId));
+
+ ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel();
+ AbstractChatService chatService = getChatService(modelVo);
+ ChatModel chatModel = chatService.buildChatModel(modelVo);
+ StreamingChatModel streamingModel = chatService.buildStreamingChatModel(modelVo, new ChatRequest());
+
+ log.info("开始流式生成分镜: projectId={}, scriptId={}, model={}", projectId, scriptId, modelVo.getModelName());
+ emit(emitter, "storyboard_plan", "running", "正在规划分镜镜头,模型开始输出后会实时显示...");
+ List panels = executePhase3_StoryboardPlan(chatModel, streamingModel, script, projectId, emitter);
+ if (panels.isEmpty()) {
+ throw new IllegalStateException("分镜规划失败,模型未返回有效镜头");
+ }
+ emit(emitter, "storyboard_plan", "done", "分镜规划完成,共 " + panels.size() + " 个镜头");
+
+ emit(emitter, "photography", "running", "正在生成摄影规则...");
+ List photographyRules = buildLocalPhotographyRules(panels);
+ emit(emitter, "photography", "done", "摄影规则生成完成");
+
+ emit(emitter, "acting", "running", "正在生成表演指导...");
+ List actingDirections = buildLocalActingDirections(panels);
+ emit(emitter, "acting", "done", "表演指导生成完成");
+
+ emit(emitter, "storyboard_detail", "running", "正在细化镜头提示词和时长...");
+ executePhase6_StoryboardDetail(chatModel, streamingModel, panels, projectId, emitter);
+ normalizeContinuityChain(panels);
+ panels = mergePanelsWithRules(panels, photographyRules, actingDirections);
+ List storyboards = persistStoryboards(projectId, scriptId, panels, script);
+
+ log.info("流式生成分镜完成: projectId={}, count={}", projectId, storyboards.size());
+ sendEmitterEvent(emitter, SseEmitter.event().name("complete")
+ .data("{\"projectId\":\"" + projectId + "\",\"count\":" + storyboards.size() + "}"));
+ completeEmitter(emitter);
+ } catch (Exception e) {
+ log.error("流式生成分镜失败: projectId={}, scriptId={}", projectId, scriptId, e);
+ sendEmitterEvent(emitter, SseEmitter.event().name("error")
+ .data("{\"message\":\"" + escapeJson(e.getMessage()) + "\"}"));
+ completeEmitterWithError(emitter, e);
+ } finally {
+ endStoryboardGeneration(scriptId);
+ }
+ });
+ return emitter;
+ }
+
+ private boolean beginStoryboardGeneration(Long scriptId) {
+ return storyboardGenerationStates.putIfAbsent(scriptId, new AtomicBoolean(true)) == null;
+ }
+
+ private void endStoryboardGeneration(Long scriptId) {
+ storyboardGenerationStates.remove(scriptId);
+ }
+
+ @Override
+ public List generatePhotographyRules(Long projectId, Long scriptId, Long userId) {
+ validateProjectOwner(projectId, userId);
+ List existing = storyboardMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ShortDramaStoryboard::getScriptId, scriptId)
+ .orderByAsc(ShortDramaStoryboard::getSceneNo));
+ if (existing.isEmpty()) {
+ return List.of();
+ }
+ List panels = toPanelDataList(existing);
+ ChatModelVo modelVo = findChatModel();
+ ChatModel chatModel = getChatService(modelVo).buildChatModel(modelVo);
+ List photographyRules = executePhase4_PhotographyRules(chatModel, panels, projectId);
+ mergePhotographyRules(panels, photographyRules);
+ for (int i = 0; i < existing.size() && i < panels.size(); i++) {
+ existing.get(i).setPhotographyRules(panels.get(i).getPhotographyRules());
+ storyboardMapper.updateById(existing.get(i));
+ }
+ return storyboardMapper.selectVoList(new LambdaQueryWrapper()
+ .eq(ShortDramaStoryboard::getScriptId, scriptId)
+ .orderByAsc(ShortDramaStoryboard::getSceneNo));
+ }
+
+ @Override
+ public List generateActingDirections(Long projectId, Long scriptId, Long userId) {
+ validateProjectOwner(projectId, userId);
+ List existing = storyboardMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ShortDramaStoryboard::getScriptId, scriptId)
+ .orderByAsc(ShortDramaStoryboard::getSceneNo));
+ if (existing.isEmpty()) {
+ return List.of();
+ }
+ List panels = toPanelDataList(existing);
+ ChatModelVo modelVo = findChatModel();
+ ChatModel chatModel = getChatService(modelVo).buildChatModel(modelVo);
+ List actingDirections = executePhase5_ActingDirections(chatModel, panels, projectId);
+ mergeActingDirections(panels, actingDirections);
+ for (int i = 0; i < existing.size() && i < panels.size(); i++) {
+ existing.get(i).setActingNotes(panels.get(i).getActingNotes());
+ storyboardMapper.updateById(existing.get(i));
+ }
+ return storyboardMapper.selectVoList(new LambdaQueryWrapper()
+ .eq(ShortDramaStoryboard::getScriptId, scriptId)
+ .orderByAsc(ShortDramaStoryboard::getSceneNo));
+ }
+
+ // ==================== 分镜 CRUD / 视频 ====================
+
+ @Override
+ public ShortDramaStoryboardVo saveStoryboard(ShortDramaStoryboardBo bo, Long userId) {
+ ShortDramaStoryboard entity = MapstructUtils.convert(bo, ShortDramaStoryboard.class);
+ ShortDramaProject project = projectMapper.selectById(entity.getProjectId());
+ if (project == null || !userId.equals(project.getUserId())) {
+ throw new IllegalArgumentException("项目不存在或无权限");
+ }
+ if (entity.getId() != null) {
+ ShortDramaStoryboard existing = storyboardMapper.selectById(entity.getId());
+ if (existing == null || !entity.getProjectId().equals(existing.getProjectId())) {
+ throw new IllegalArgumentException("分镜不存在或不属于当前项目");
+ }
+ }
+ if (entity.getDurationSeconds() == null || entity.getDurationSeconds() <= 0) {
+ entity.setDurationSeconds(defaultDurationForSceneType(entity.getSceneType()));
+ }
+ if (entity.getId() == null) {
+ entity.setVideoStatus("pending");
+ storyboardMapper.insert(entity);
+ } else {
+ storyboardMapper.updateById(entity);
+ entity = storyboardMapper.selectById(entity.getId());
+ }
+ videoComposeService.invalidateComposition(entity.getProjectId());
+ return MapstructUtils.convert(entity, ShortDramaStoryboardVo.class);
+ }
+
+ @Override
+ public ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId) {
+ ShortDramaStoryboard storyboard = storyboardMapper.selectById(storyboardId);
+ if (storyboard == null) throw new IllegalArgumentException("分镜不存在");
+ ShortDramaProject project = projectMapper.selectById(storyboard.getProjectId());
+ if (project == null || !userId.equals(project.getUserId())) throw new IllegalArgumentException("项目不存在或无权限");
+ ChatModelVo modelVo = chatModelService.selectModelByName(videoModel);
+ if (modelVo == null) throw new IllegalArgumentException("未找到视频模型配置: " + videoModel);
+
+ // 收集所有参考图(角色 + 场景)
+ List referenceImages = findStoryboardReferenceImages(storyboard);
+
+ // 根据参考图数量自动切换模型
+ if (referenceImages != null && !referenceImages.isEmpty()) {
+ String targetModel;
+ if (referenceImages.size() >= 2) {
+ targetModel = videoModel.replace("/text-to-video", "/reference-to-video")
+ .replace("/image-to-video", "/reference-to-video");
+ } else {
+ targetModel = videoModel.replace("/text-to-video", "/image-to-video")
+ .replace("/reference-to-video", "/image-to-video");
+ }
+ if (!targetModel.equals(videoModel)) {
+ ChatModelVo switched = chatModelService.selectModelByName(targetModel);
+ if (switched != null) {
+ modelVo = switched;
+ log.info("参考图({}张) → 切换模型: {}", referenceImages.size(), targetModel);
+ } else {
+ log.info("参考图({}张)但模型未注册: {},保持原模型", referenceImages.size(), targetModel);
+ }
+ }
+ }
+
+ String enrichedPrompt = buildEnrichedVideoPrompt(storyboard, referenceImages);
+
+ VideoContext ctx = VideoContext.builder()
+ .chatModelVo(modelVo)
+ .prompt(enrichedPrompt)
+ .seconds(storyboard.getDurationSeconds())
+ .referenceImages(referenceImages)
+ .build();
+ String generationToken = "local:" + UUID.randomUUID();
+ storyboardMapper.update(null, new LambdaUpdateWrapper()
+ .eq(ShortDramaStoryboard::getId, storyboardId)
+ .set(ShortDramaStoryboard::getVideoUrl, null)
+ .set(ShortDramaStoryboard::getVideoId, generationToken)
+ .set(ShortDramaStoryboard::getVideoStatus, "generating"));
+ videoComposeService.invalidateComposition(project.getId());
+
+ MediaGenerationResponse response;
+ try {
+ response = videoServiceFactory.getOriginalService(modelVo.getProviderCode()).generateVideo(ctx);
+ } catch (RuntimeException ex) {
+ int failed = storyboardMapper.update(null, new LambdaUpdateWrapper()
+ .eq(ShortDramaStoryboard::getId, storyboardId)
+ .eq(ShortDramaStoryboard::getVideoId, generationToken)
+ .set(ShortDramaStoryboard::getVideoId, null)
+ .set(ShortDramaStoryboard::getVideoStatus, "failed"));
+ if (failed > 0) {
+ videoComposeService.invalidateComposition(project.getId());
+ }
+ throw ex;
+ }
+ String videoUrl = null;
+ String videoId = null;
+ String videoStatus;
+ if (response != null && StrUtil.isNotBlank(response.getUrl())) {
+ videoUrl = response.getUrl();
+ videoStatus = "done";
+ } else if (response != null && StrUtil.isNotBlank(response.getId())) {
+ videoId = response.getId();
+ videoStatus = "generating";
+ } else if (response != null && "processing".equals(response.getStatus())) {
+ videoId = response.getId();
+ videoStatus = "generating";
+ } else {
+ videoStatus = "failed";
+ }
+ int completed = storyboardMapper.update(null, new LambdaUpdateWrapper()
+ .eq(ShortDramaStoryboard::getId, storyboardId)
+ .eq(ShortDramaStoryboard::getVideoId, generationToken)
+ .set(ShortDramaStoryboard::getVideoUrl, videoUrl)
+ .set(ShortDramaStoryboard::getVideoId, videoId)
+ .set(ShortDramaStoryboard::getVideoStatus, videoStatus));
+ if (completed > 0) {
+ videoComposeService.invalidateComposition(project.getId());
+ }
+ return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class);
+ }
+
+ /** 收集分镜关联的所有参考图:角色形象图(按出场顺序)+ 场景图 */
+ private List findStoryboardReferenceImages(ShortDramaStoryboard storyboard) {
+ List images = new ArrayList<>();
+ List chars = parseCharacterRefs(storyboard.getCharactersJson());
+ if (chars != null) {
+ for (CharacterRef ref : chars) {
+ String img = findCharacterImageUrl(storyboard.getProjectId(), ref.getName());
+ if (StrUtil.isNotBlank(img) && !images.contains(img)) {
+ images.add(img);
+ }
+ }
+ }
+ if (StrUtil.isNotBlank(storyboard.getLocationName())) {
+ String img = findLocationImageUrl(storyboard.getProjectId(), storyboard.getLocationName());
+ if (StrUtil.isNotBlank(img) && !images.contains(img)) {
+ images.add(img);
+ }
+ }
+ return images.isEmpty() ? null : images;
+ }
+
+ /** 构建增强提示词:融合镜头语言、摄影规则、角色信息、表演指导、场景描述 */
+ private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard) {
+ return buildEnrichedVideoPrompt(storyboard, null);
+ }
+
+ /** 构建增强提示词(含 @imageN 参考图引用,用于 reference-to-video 模型) */
+ private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard, java.util.List refImages) {
+ boolean hasRefImages = refImages != null && !refImages.isEmpty();
+ StringBuilder sb = new StringBuilder();
+
+ // 1. 镜头语言标签
+ sb.append("[镜头: ").append(firstNotBlank(storyboard.getSceneType(), "daily"));
+ if (StrUtil.isNotBlank(storyboard.getShotType())) sb.append(", ").append(storyboard.getShotType());
+ if (StrUtil.isNotBlank(storyboard.getCameraMove())) sb.append(", ").append(storyboard.getCameraMove());
+ sb.append("]\n");
+
+ // 2. 摄影规则
+ if (StrUtil.isNotBlank(storyboard.getPhotographyRules())) {
+ try {
+ JsonNode rules = JsonUtils.parseObject(storyboard.getPhotographyRules(), JsonNode.class);
+ if (rules != null) {
+ StringBuilder photo = new StringBuilder("[摄影:");
+ JsonNode lighting = rules.path("lighting");
+ if (!lighting.isMissingNode()) {
+ String dir = lighting.path("direction").asText(null);
+ String qual = lighting.path("quality").asText(null);
+ if (dir != null) photo.append(" ").append(dir).append(qual != null ? qual : "").append("光");
+ }
+ String dof = rules.path("depth_of_field").asText(null);
+ if (dof != null) photo.append(", 景深:").append(dof);
+ String tone = rules.path("color_tone").asText(null);
+ if (tone != null) photo.append(", 色调:").append(tone);
+ if (photo.length() > 4) { photo.append("]"); sb.append(photo).append("\n"); }
+
+ JsonNode photoChars = rules.path("characters");
+ if (photoChars.isArray() && !photoChars.isEmpty()) {
+ sb.append("[站位] ");
+ for (int i = 0; i < photoChars.size(); i++) {
+ JsonNode pc = photoChars.get(i);
+ if (i > 0) sb.append(";");
+ sb.append(pc.path("name").asText(""))
+ .append("在").append(pc.path("screen_position").asText(""))
+ .append(pc.path("posture").asText(""))
+ .append("面向").append(pc.path("facing").asText(""));
+ }
+ sb.append("\n");
+ }
+ }
+ } catch (Exception e) { log.debug("解析摄影规则失败: {}", e.getMessage()); }
+ }
+
+ // 3. 角色信息(名字→年龄性别,附加站位 + @imageN 参考图标记)
+ List chars = parseCharacterRefs(storyboard.getCharactersJson());
+ if (chars != null && !chars.isEmpty()) {
+ sb.append("[角色] ");
+ for (int i = 0; i < chars.size(); i++) {
+ CharacterRef ref = chars.get(i);
+ if (i > 0) sb.append(";");
+ ShortDramaCharacter ch = findCharacterByName(storyboard.getProjectId(), ref.getName());
+ String desc = ch != null
+ ? firstNotBlank(ch.getAgeRange(), "") + firstNotBlank(ch.getGender(), "")
+ : ref.getName();
+ sb.append(desc).append("(").append(ref.getName()).append(")");
+ // 多参考图模式:附加 @imageN 标记
+ if (hasRefImages) {
+ int imgIdx = findRefImageIndex(refImages, storyboard.getProjectId(), ref.getName());
+ if (imgIdx >= 0) sb.append("@image").append(imgIdx + 1);
+ }
+ if (StrUtil.isNotBlank(ref.getSlot())) sb.append("在").append(ref.getSlot());
+ }
+ sb.append("\n");
+ }
+
+ // 角色参考图很容易被生成模型错误地扩散到背景群众,造成“所有人一张脸”。
+ // 在角色声明之后追加全局身份隔离规则,使其覆盖所有项目和所有镜头。
+ appendCharacterIdentityIsolationPrompt(sb, storyboard, chars, refImages);
+
+ // 4. 场景描述(+ @imageN 参考图标记)
+ if (StrUtil.isNotBlank(storyboard.getLocationName())) {
+ sb.append("[场景] ").append(storyboard.getLocationName());
+ ShortDramaLocation loc = findLocationByName(storyboard.getProjectId(), storyboard.getLocationName());
+ if (loc != null && StrUtil.isNotBlank(loc.getSummary())) {
+ sb.append(" — ").append(loc.getSummary());
+ }
+ // 多参考图模式:附加 @imageN 标记
+ if (hasRefImages) {
+ int imgIdx = findLocationRefImageIndex(refImages, storyboard.getProjectId(), storyboard.getLocationName());
+ if (imgIdx >= 0) sb.append("@image").append(imgIdx + 1);
+ }
+ sb.append("\n");
+ }
+
+ // 5. 表演指导
+ if (StrUtil.isNotBlank(storyboard.getActingNotes())) {
+ try {
+ JsonNode actingArr = JsonUtils.parseObject(storyboard.getActingNotes(), JsonNode.class);
+ if (actingArr != null && actingArr.isArray() && !actingArr.isEmpty()) {
+ sb.append("[表演] ");
+ for (int i = 0; i < actingArr.size(); i++) {
+ JsonNode an = actingArr.get(i);
+ if (i > 0) sb.append(";");
+ sb.append(an.path("name").asText("")).append(":")
+ .append(an.path("acting").asText(""));
+ }
+ sb.append("\n");
+ }
+ } catch (Exception e) { log.debug("解析表演指导失败: {}", e.getMessage()); }
+ }
+
+ // 6. 前后镜头连续性状态
+ appendContinuityPrompt(sb, storyboard);
+
+ // 7. 画面描述(AI 撰写的分镜画面叙述)
+ if (StrUtil.isNotBlank(storyboard.getSceneText())
+ && !storyboard.getSceneText().equals(storyboard.getVideoPrompt())) {
+ sb.append("[画面] ").append(storyboard.getSceneText()).append("\n");
+ }
+
+ // 8. 视觉参考(image_prompt 含光线/氛围/构图细节)
+ if (StrUtil.isNotBlank(storyboard.getImagePrompt())) {
+ sb.append("[视觉] ").append(storyboard.getImagePrompt()).append("\n");
+ }
+
+ // 9. 目标时长与节奏
+ int durationSeconds = storyboard.getDurationSeconds() != null ? storyboard.getDurationSeconds() : 6;
+ sb.append("[时长] ").append(durationSeconds).append("秒,动作与运镜必须完整覆盖全程");
+ if (durationSeconds >= 8) {
+ sb.append(",按前段、中段、后段三个连续节拍展开,避免动作提前结束");
+ }
+ sb.append("\n");
+
+ // 10. 视觉风格
+ String artStyle = artStyleSuffix(storyboard.getProjectId());
+ if (StrUtil.isNotBlank(artStyle)) {
+ sb.append("\n[风格] ").append(artStyle);
+ }
+
+ // 11. 视频描述(核心驱动 prompt)
+ sb.append("\n").append(storyboard.getVideoPrompt());
+ return sb.toString();
+ }
+
+ /**
+ * 为分镜统一追加人物身份隔离规则。
+ *
+ * 参考图只绑定对应具名角色,不能被模型当作群众的通用脸部模板;
+ * 单人近景进一步限制为仅一张可辨认人脸,多角色镜则要求逐人绑定、互不混脸。
+ */
+ private void appendCharacterIdentityIsolationPrompt(StringBuilder sb,
+ ShortDramaStoryboard storyboard,
+ List chars,
+ List refImages) {
+ boolean hasRefImages = refImages != null && !refImages.isEmpty();
+ int characterCount = chars == null ? 0 : chars.size();
+ String shotType = firstNotBlank(storyboard.getShotType(), "");
+ boolean closeShot = shotType.contains("特写") || shotType.contains("近景")
+ || shotType.toLowerCase(Locale.ROOT).contains("close");
+
+ sb.append("[人物身份隔离] ");
+ if (characterCount == 0) {
+ sb.append("本镜没有具名角色参考。若出现群众,每个人必须具有不同的脸型、五官比例、身高、体态、发型和服装细节;")
+ .append("禁止复制脸、镜像人物、双胞胎式重复、同一人物贴图和整排相同表情。");
+ } else if (characterCount == 1) {
+ String characterName = chars.get(0).getName();
+ sb.append("唯一具名角色为").append(characterName).append(";")
+ .append(hasRefImages ? "该角色参考图只绑定此人,绝不能扩散给背景人物;" : "角色身份只属于此人;");
+ if (closeShot) {
+ sb.append("这是单人近景/特写,画面只允许一张可辨认人脸。其他人物必须留在画外,")
+ .append("不得出现背景人头、倒影脸、反射脸、画像脸、脸谱幻影或多重曝光;");
+ } else {
+ sb.append("若剧情必须出现无名群众,群众不得复用").append(characterName)
+ .append("的五官,后景人物应弱化面部并保持彼此差异;");
+ }
+ sb.append("禁止克隆、分身、镜像脸和同脸不同装。");
+ } else {
+ sb.append("本镜有").append(characterCount).append("名具名角色:");
+ for (int i = 0; i < chars.size(); i++) {
+ if (i > 0) sb.append("、");
+ CharacterRef ref = chars.get(i);
+ sb.append(ref.getName());
+ if (hasRefImages) {
+ int imageIndex = findRefImageIndex(refImages, storyboard.getProjectId(), ref.getName());
+ if (imageIndex >= 0) sb.append("仅绑定@image").append(imageIndex + 1);
+ }
+ }
+ sb.append("。各角色必须保持各自独立五官、年龄、性别、发型和服装,禁止互相混脸、交换五官或把第一张参考脸复制给全员;")
+ .append("无名群众同样不得复用任何具名角色的脸。画面中不得额外生成角色副本、镜像分身或重复人头。");
+ }
+ sb.append("\n");
+ }
+
+ /** 在参考图列表中找到指定角色图片的索引 */
+ private int findRefImageIndex(java.util.List