mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-11 21:40:41 +00:00
feat: 完善短剧创作、技能目录与媒体任务恢复
保存原始创作输入,整合剧本打磨、分场规划与流式进度,接入审美及导演技能、角色声音和音乐;保持已批准媒体并完善请求去重与恢复。 统一增量 SQL 目录,补齐新安装字段并修正示例插入列,更新迁移说明;修复测试夹具与当前审阅、重启恢复行为的断言。验证:相关模块编译通过,短剧及 Atlas 回归 337 项、SSE 回归 3 项均通过。
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
mysql -uroot -p < ruoyi-ai.sql
|
||||
```
|
||||
|
||||
主 SQL 已合并下表中的全部更新,无需再次执行 `update` 目录中的脚本。[snail_job_mysql.sql](snail_job_mysql.sql) 仅用于单独初始化调度库,执行主 SQL 后无需再执行它。
|
||||
主 SQL 已合并下表列出的基础更新,以及原始想法、技能绑定、剧本打磨字段、模型优先级和 PC 登录超时结构。新安装仍需执行下面“后续功能更新”中注明的补充脚本。[snail_job_mysql.sql](snail_job_mysql.sql) 仅用于单独初始化调度库,执行主 SQL 后无需再执行它。
|
||||
|
||||
全量初始化包含 `DROP TABLE` 和调度库的 `DROP DATABASE`,已有数据库升级应使用对应的增量脚本。
|
||||
|
||||
@@ -43,3 +43,18 @@ mysql -uroot -p < ruoyi-ai.sql
|
||||
| [2026-09-01-sys-url.sql](update/2026-09-01-sys-url.sql) | `sys_url` 表、5 个菜单权限和 2 条公开链接 |
|
||||
|
||||
后续增加更新脚本时,同步修改主 SQL 中对应的表结构或初始化数据,并更新本清单及主 SQL 文件头。
|
||||
|
||||
## 后续功能更新
|
||||
|
||||
以下脚本统一位于 `update/`。已有数据库按日期升级,保留原始输入、已批准媒体和提供商密钥;不要执行全量初始化。
|
||||
|
||||
| 脚本 | 内容与新安装要求 |
|
||||
| --- | --- |
|
||||
| [知识库检索节点](update/2026-09-11-knowledge-retrieval-node.sql) | 补充节点注册,新安装也需执行 |
|
||||
| [短剧视觉资产](update/2026-09-30-short-drama-visual-asset.sql) | 补充异步视觉资产表,新安装也需执行 |
|
||||
| [剧本打磨与 Seedance 2.5](update/2026-09-30-script-refinement-seedance-25.sql) | 打磨字段已并入全量结构;新安装执行以补充视频模型记录 |
|
||||
| [技能绑定](update/2026-10-01-short-drama-skill-bindings.sql) | 项目审美与导演绑定,幂等;全量结构已包含 |
|
||||
| [模型优先级](update/2026-10-02-chat-model-sort-order.sql) | 幂等增加优先级列,保留已自定义优先级;全量结构已包含 |
|
||||
| [PC 登录空闲时间](update/2026-10-02-pc-login-idle-timeout.sql) | 仅调整原 30 分钟配置;全量默认已更新,已签发令牌需重新登录 |
|
||||
| [文字模型默认优先级](update/2026-10-03-short-drama-flash-default.sql) | 先应用模型优先级迁移,再调整已有 Flash / Seed 模型排序,不新增模型或密钥 |
|
||||
| [原始创作输入](update/2026-10-03-short-drama-original-idea.sql) | 幂等增加独立原文列,不用生成概要回填;全量结构已包含 |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
-- Run after the base ruoyi-ai.sql. Preserves existing scripts and provider keys.
|
||||
DROP PROCEDURE IF EXISTS migrate_drama_preparation;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE migrate_drama_preparation()
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='short_drama_script' AND column_name='creation_mode') THEN
|
||||
ALTER TABLE short_drama_script ADD creation_mode varchar(32) DEFAULT 'standard', ADD source_materials longtext, ADD worldbuilding longtext, ADD revision_notes longtext;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL migrate_drama_preparation();
|
||||
DROP PROCEDURE migrate_drama_preparation;
|
||||
|
||||
INSERT INTO chat_model (id,category,model_name,provider_code,model_describe,model_show,api_host,api_key,create_dept,create_by,create_time,update_by,update_time,remark,tenant_id)
|
||||
SELECT 2106002500000000001,'video','bytedance/seedance-2.5/reference-to-video','atlas','Seedance 2.5 多模态参考生视频(字节跳动)','Y',
|
||||
COALESCE((SELECT api_host FROM (SELECT api_host FROM chat_model WHERE provider_code='atlas' AND category='video' AND tenant_id=0 ORDER BY id DESC LIMIT 1) h),'https://api.atlascloud.ai/api/v1'),
|
||||
COALESCE((SELECT api_key FROM (SELECT api_key FROM chat_model WHERE provider_code='atlas' AND category='video' AND tenant_id=0 ORDER BY id DESC LIMIT 1) k),''),
|
||||
103,1,NOW(),1,NOW(),'Atlas Seedance 2.5 reference-to-video; 4-30s, up to 30 images and 10 audio references',0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM chat_model WHERE model_name='bytedance/seedance-2.5/reference-to-video' AND tenant_id=0);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- MySQL idempotent migration; run in the configured application database.
|
||||
SET @skill_aesthetic_sql = (SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE short_drama_project ADD COLUMN aesthetic_skill_name VARCHAR(64) NULL COMMENT ''Selected aesthetic skill catalog name''',
|
||||
'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'aesthetic_skill_name');
|
||||
PREPARE skill_aesthetic_stmt FROM @skill_aesthetic_sql;
|
||||
EXECUTE skill_aesthetic_stmt;
|
||||
DEALLOCATE PREPARE skill_aesthetic_stmt;
|
||||
SET @skill_director_sql = (SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE short_drama_project ADD COLUMN director_skill_name VARCHAR(64) NULL COMMENT ''Selected director skill catalog name''',
|
||||
'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'director_skill_name');
|
||||
PREPARE skill_director_stmt FROM @skill_director_sql;
|
||||
EXECUTE skill_director_stmt;
|
||||
DEALLOCATE PREPARE skill_director_stmt;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Idempotent schema upgrade; preserve credentials and explicitly customized priorities.
|
||||
SET @sort_order_sql = (SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE chat_model ADD COLUMN sort_order INT NOT NULL DEFAULT 100 COMMENT ''默认模型优先级,升序'' AFTER category',
|
||||
'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'chat_model' AND column_name = 'sort_order');
|
||||
PREPARE sort_order_stmt FROM @sort_order_sql;
|
||||
EXECUTE sort_order_stmt;
|
||||
DEALLOCATE PREPARE sort_order_stmt;
|
||||
UPDATE chat_model SET sort_order = 0 WHERE provider_code = 'atlas' AND sort_order = 100 AND model_name IN ('bytedance/doubao-seed-2.1-pro-260628', 'openai/gpt-image-2.5-flare/text-to-image', 'bytedance/seedance-2.0-mini/reference-to-video');
|
||||
@@ -0,0 +1,7 @@
|
||||
-- PC 管理后台和短剧工作台:无操作 12 小时后登录失效(单位:秒)。
|
||||
-- 仅迁移原 30 分钟策略,保留已自定义的客户端设置。
|
||||
UPDATE sys_client
|
||||
SET active_timeout = 43200
|
||||
WHERE device_type = 'pc' AND active_timeout = 1800;
|
||||
-- 已部署环境通过 /system/client 更新相同字段以同步清理客户端缓存。
|
||||
-- 已签发的令牌需重新登录后使用新策略。
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Existing Atlas keys/hosts remain intact. Default priority is maintained through model management.
|
||||
-- Apply after 2026-10-02-chat-model-sort-order.sql.
|
||||
UPDATE chat_model SET sort_order = 10
|
||||
WHERE provider_code = 'atlas' AND category = 'chat' AND model_name = 'bytedance/doubao-seed-2.1-pro-260628' AND sort_order = 0;
|
||||
UPDATE chat_model SET sort_order = 0
|
||||
WHERE provider_code = 'atlas' AND category = 'chat' AND model_name = 'deepseek-ai/deepseek-v4.1-flash';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Store the exact creation input separately from generated project descriptions.
|
||||
-- Existing rows stay NULL unless their original request can be recovered.
|
||||
SET @original_idea_sql = (SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE short_drama_project ADD COLUMN original_idea LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''Original story creation input'' AFTER description',
|
||||
'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'original_idea');
|
||||
PREPARE original_idea_stmt FROM @original_idea_sql;
|
||||
EXECUTE original_idea_stmt;
|
||||
DEALLOCATE PREPARE original_idea_stmt;
|
||||
@@ -415,4 +415,4 @@ short-drama:
|
||||
max-redirects: ${SHORT_DRAMA_DOWNLOAD_MAX_REDIRECTS:5}
|
||||
# 生产环境建议配置为视频供应商或 OSS/CDN 域名,多个值用逗号分隔。
|
||||
allowed-hosts: ${SHORT_DRAMA_DOWNLOAD_ALLOWED_HOSTS:}
|
||||
fake-ip-allowed-hosts: ${SHORT_DRAMA_DOWNLOAD_FAKE_IP_ALLOWED_HOSTS:atlas-media.oss-us-west-1.aliyuncs.com}
|
||||
fake-ip-allowed-hosts: ${SHORT_DRAMA_DOWNLOAD_FAKE_IP_ALLOWED_HOSTS:atlas-media.oss-us-west-1.aliyuncs.com,ark-acg-ap-southeast-1.tos-ap-southeast-1.volces.com}
|
||||
|
||||
@@ -35,6 +35,9 @@ public class ChatModelBo extends BaseEntity {
|
||||
@NotBlank(message = "模型分类不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
private String category;
|
||||
|
||||
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/**
|
||||
* 模型名称
|
||||
*/
|
||||
|
||||
@@ -8,4 +8,7 @@ public class ChatModelSelectQuery {
|
||||
|
||||
/** Optional model category; defaults to chat at the controller boundary. */
|
||||
private String category;
|
||||
|
||||
/** Optional exact provider filter. */
|
||||
private String providerCode;
|
||||
}
|
||||
|
||||
@@ -29,9 +29,14 @@ public class ChatModelSelectVo implements Serializable {
|
||||
/** Provider identifier used for display. */
|
||||
private String providerCode;
|
||||
|
||||
private String category;
|
||||
private Integer sortOrder;
|
||||
|
||||
public static ChatModelSelectVo from(ChatModelVo source) {
|
||||
ChatModelSelectVo target = new ChatModelSelectVo();
|
||||
target.setId(source.getId());
|
||||
target.setCategory(source.getCategory());
|
||||
target.setSortOrder(source.getSortOrder());
|
||||
target.setModelName(source.getModelName());
|
||||
target.setModelDescribe(source.getModelDescribe());
|
||||
target.setProviderCode(source.getProviderCode());
|
||||
|
||||
@@ -39,6 +39,9 @@ public class ChatModelVo implements Serializable {
|
||||
@ExcelProperty(value = "模型分类")
|
||||
private String category;
|
||||
|
||||
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/**
|
||||
* 模型名称
|
||||
*/
|
||||
|
||||
@@ -34,6 +34,9 @@ public class ChatModel extends TenantEntity {
|
||||
*/
|
||||
private String category;
|
||||
|
||||
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/**
|
||||
* 模型名称
|
||||
*/
|
||||
|
||||
@@ -25,6 +25,9 @@ public class VideoContext {
|
||||
|
||||
private String quality;
|
||||
|
||||
/** Provider output resolution; distinct from aspect ratio and quality. */
|
||||
private String resolution;
|
||||
|
||||
/** 参考图 URL(图生视频模式,单图) */
|
||||
private String imageUrl;
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@ public class SaTokenExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(NotLoginException.class)
|
||||
public R<Void> handleNotLoginException(NotLoginException e, HttpServletRequest request) {
|
||||
log.error("authorization_failed category=NOT_LOGIN method={} path={} exceptionType={}",
|
||||
log.debug("authorization_failed category=NOT_LOGIN method={} path={} exceptionType={}",
|
||||
request.getMethod(), safePath(request), e.getClass().getName());
|
||||
return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "认证失败,无法访问系统资源");
|
||||
return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "请先登录");
|
||||
}
|
||||
|
||||
private static String safePath(HttpServletRequest request) {
|
||||
|
||||
@@ -111,25 +111,36 @@ public class SseEmitterManager {
|
||||
* @param eventDto SSE事件对象
|
||||
*/
|
||||
public void sendEvent(String sessionId, SseEventDto eventDto) {
|
||||
if (!sendLocalEvent(sessionId, eventDto)) {
|
||||
log.warn("sse_delivery routeType=SESSION status=SKIPPED reason=NO_ACTIVE_CONNECTION activeSessionCount={}",
|
||||
SESSION_EMITTERS.size());
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver locally in order; a terminal event is flushed before its connection is removed. */
|
||||
public boolean sendLocalEvent(String sessionId, SseEventDto eventDto) {
|
||||
if (sessionId == null || eventDto == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
SseEmitter emitter = SESSION_EMITTERS.get(sessionId);
|
||||
if (emitter == null) {
|
||||
log.warn("sse_delivery routeType=SESSION status=SKIPPED reason=NO_ACTIVE_CONNECTION activeSessionCount={}",
|
||||
SESSION_EMITTERS.size());
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
log.debug("sse_delivery routeType=SESSION status=SENDING eventType={} payloadChars={}",
|
||||
safeEventType(eventDto), eventPayloadLength(eventDto));
|
||||
emitter.send(SseEmitter.event()
|
||||
.name(eventDto.getEvent())
|
||||
.data(JSONUtil.toJsonStr(eventDto)));
|
||||
synchronized (emitter) {
|
||||
if (SESSION_EMITTERS.get(sessionId) != emitter) return false;
|
||||
emitter.send(SseEmitter.event()
|
||||
.name(eventDto.getEvent())
|
||||
.data(JSONUtil.toJsonStr(eventDto)));
|
||||
if (Boolean.TRUE.equals(eventDto.getDone())) removeSessionEmitter(sessionId, emitter);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("sse_delivery routeType=SESSION status=FAILED errorType={}", errorType(e));
|
||||
removeSessionEmitter(sessionId, emitter);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void removeSessionEmitter(String sessionId, SseEmitter emitter) {
|
||||
|
||||
@@ -126,6 +126,8 @@ public class SseMessageUtils {
|
||||
if (!isEnable() || sessionId == null) {
|
||||
return;
|
||||
}
|
||||
// The producer normally owns this emitter. Do not race a Redis delivery against disconnect.
|
||||
if (MANAGER.sendLocalEvent(sessionId, eventDto)) return;
|
||||
SseMessageDto dto = new SseMessageDto();
|
||||
dto.setSessionId(sessionId);
|
||||
dto.setEventDto(eventDto);
|
||||
@@ -210,6 +212,13 @@ public class SseMessageUtils {
|
||||
sendEvent(sessionId, SseEventDto.error(SAFE_ERROR_MESSAGE));
|
||||
}
|
||||
|
||||
/** Final error only: unlike recoverable error events, this flushes and closes the stream. */
|
||||
public static void sendErrorAndComplete(String sessionId, String error) {
|
||||
SseEventDto terminal = SseEventDto.error(SAFE_ERROR_MESSAGE);
|
||||
terminal.setDone(true);
|
||||
sendEvent(sessionId, terminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否开启
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.ruoyi.common.sse.core;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ruoyi.common.sse.dto.SseEventDto;
|
||||
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@Tag("dev")
|
||||
class SseTerminalDeliveryTest {
|
||||
private SseEmitterManager manager;
|
||||
private Map<String, SseEmitter> emitters;
|
||||
@BeforeEach @SuppressWarnings("unchecked") void setup() {
|
||||
var beans = new DefaultListableBeanFactory();
|
||||
beans.registerSingleton("scheduler", mock(ScheduledExecutorService.class));
|
||||
new SpringUtil().postProcessBeanFactory(beans);
|
||||
manager = new SseEmitterManager();
|
||||
emitters = (Map<String, SseEmitter>) ReflectionTestUtils.getField(SseEmitterManager.class, "SESSION_EMITTERS");
|
||||
emitters.clear();
|
||||
}
|
||||
|
||||
@Test void doneIsSentBeforeRemovalAndCompletion() throws Exception { assertTerminal(SseEventDto.done(), false); }
|
||||
@Test void redisTerminalErrorIsSentBeforeRemovalAndCompletion() throws Exception {
|
||||
var error = SseEventDto.error("safe failure"); error.setDone(true); assertTerminal(error, true);
|
||||
}
|
||||
|
||||
private void assertTerminal(SseEventDto terminal, boolean viaRedisListener) throws Exception {
|
||||
var emitter = mock(SseEmitter.class); emitters.put("s", emitter);
|
||||
doAnswer(call -> { assertSame(emitter, emitters.get("s")); return null; }).when(emitter).send(any(SseEmitter.SseEventBuilder.class));
|
||||
doAnswer(call -> { assertFalse(emitters.containsKey("s")); return null; }).when(emitter).complete();
|
||||
if (viaRedisListener) manager.sendEvent("s", terminal); else assertTrue(manager.sendLocalEvent("s", terminal));
|
||||
var ordered = inOrder(emitter); ordered.verify(emitter).send(any(SseEmitter.SseEventBuilder.class)); ordered.verify(emitter).complete();
|
||||
assertFalse(manager.sendLocalEvent("s", terminal));
|
||||
}
|
||||
|
||||
@Test void normalContentAndRecoverableErrorKeepTheConnectionOpen() throws Exception {
|
||||
var emitter = mock(SseEmitter.class); emitters.put("s", emitter);
|
||||
assertTrue(manager.sendLocalEvent("s", SseEventDto.content("正文")));
|
||||
assertTrue(manager.sendLocalEvent("s", SseEventDto.error("recoverable")));
|
||||
assertSame(emitter, emitters.get("s")); verify(emitter, never()).complete();
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package org.ruoyi.agent;
|
||||
|
||||
import dev.langchain4j.service.SystemMessage;
|
||||
import dev.langchain4j.service.UserMessage;
|
||||
import dev.langchain4j.service.V;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptResult;
|
||||
|
||||
/**
|
||||
* 短剧剧本打磨 Agent —— 使用 langchain4j AiServices 结构化输出
|
||||
* <p>
|
||||
* 框架自动生成 JSON Schema 并强制 LLM 返回符合结构的数据,无需手工 parse。
|
||||
*
|
||||
* @author ageerle
|
||||
*/
|
||||
public interface ShortDramaScriptAgent {
|
||||
|
||||
@SystemMessage("""
|
||||
你是顶级短剧编剧和创意总监。请根据用户的一个创意想法,创作完整的短剧剧本。
|
||||
|
||||
【核心原则 - 最高优先级】
|
||||
1. 剧本必须完整、有张力、有画面感
|
||||
2. 角色要有鲜明性格,不是工具人
|
||||
3. 情节紧凑,每句台词都推动剧情
|
||||
4. 场景描写具体,让分镜师能直接画出画面
|
||||
5. 对话自然有力,避免废话
|
||||
|
||||
【剧本格式】
|
||||
使用标准剧本格式,包含以下元素:
|
||||
1. 场景头(Scene Heading):内景/外景+地点+时间,如"内景 客厅 清晨"
|
||||
2. 场景描述(Scene Description):简洁描述场景环境、布局、关键道具
|
||||
3. 动作描述(Action):描述角色的动作、表情、行为,连续段落形式
|
||||
4. 对话(Dialogue):角色名: 台词内容
|
||||
5. 画外音(Voiceover):旁白、独白、回忆中的声音
|
||||
|
||||
【剧本长度要求】
|
||||
- scriptText:1000-3000字,含完整的开场、冲突发展、高潮、结尾
|
||||
- outlineText:400-800字,概述完整故事线
|
||||
|
||||
【角色塑造要求】
|
||||
- 每个角色要有明确的性格标签(如:霸道总裁、温柔女医、腹黑谋士)
|
||||
- 角色之间要有清晰的关系和冲突
|
||||
- 对话要符合角色性格
|
||||
|
||||
【情节要求】
|
||||
- 必须有清晰的冲突和反转
|
||||
- 情绪节奏要有起伏(紧张→舒缓→爆发)
|
||||
- 结尾要有记忆点(反转/留白/情感升华)
|
||||
""")
|
||||
@UserMessage("用户期望项目名:{{projectName}}\n用户创意:{{idea}}")
|
||||
ShortDramaScriptResult polish(@V("projectName") String projectName, @V("idea") String idea);
|
||||
}
|
||||
@@ -44,9 +44,10 @@ public final class ShortDramaImageConstants {
|
||||
/** 项目视觉风格 → 生图 prompt 后缀映射,确保同项目所有图片风格一致 */
|
||||
public static final java.util.Map<String, String> ART_STYLE_PROMPTS = java.util.Map.of(
|
||||
"american-comic", "美式漫画风格,粗线条,高饱和度色彩,强烈光影对比",
|
||||
"chinese-3d", "国风电影级三维动画,Chinese donghua cinematic 3D animation,统一的角色三维建模和辨识度,东方人物面部结构与自然身体比例,PBR衣料、木石金属材质和细腻服饰纹理,立体体积光与可解释的环境灯光,克制的次表面散射,自然表情和电影镜头景深,国风建筑与服饰遵循当前故事年代,色彩清透且光色统一;不使用真人摄影实拍、2D赛璐璐平涂、黑色漫画线稿、塑料玩偶或油亮蜡像肤质",
|
||||
"chinese-comic", "现代国漫动画风格,Chinese donghua 2D comic style,赛璐璐平涂上色,干净锐利的黑色线稿,平面化光影无真实景深,动漫人物比例(略放大双眼、修长身形),皮肤平滑无毛孔无写实肤质,国风服饰剪裁与材质细节清晰,色彩饱满通透,画面精致干净;禁止真人写实、摄影实拍、3D渲染、CGI、厚涂油画、写实皮肤纹理、景深虚化",
|
||||
"japanese-anime", "现代日系动漫风格,赛璐璐上色,清晰干净的线条,视觉小说CG感,高质量2D风格",
|
||||
"realistic", "真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感"
|
||||
"realistic", "真人影视写实,自然人物比例与未磨皮肤质,真实衣料与环境材质,可解释的光源和丰富中间调,色彩与维护程度服从故事,不使用美颜滤镜、塑料CG或电商棚拍质感"
|
||||
);
|
||||
|
||||
/** 默认视觉风格 */
|
||||
|
||||
@@ -67,6 +67,7 @@ public class ChatModelController extends BaseController {
|
||||
} else {
|
||||
internalQuery.setCategory(query.getCategory());
|
||||
}
|
||||
if (query != null) internalQuery.setProviderCode(query.getProviderCode());
|
||||
return R.ok(chatModelService.queryAvailableList(internalQuery).stream()
|
||||
.map(ChatModelSelectVo::from)
|
||||
.toList());
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.common.satoken.utils.LoginHelper;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaCharacterVoiceService;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}")
|
||||
public class ShortDramaCharacterVoiceController {
|
||||
private final ShortDramaCharacterVoiceService service;
|
||||
@GetMapping("/characters/{characterId}/voice")
|
||||
public R<?> get(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId) throws Exception {return R.ok(service.get(projectId,characterId,LoginHelper.getUserId()));}
|
||||
@PutMapping("/characters/{characterId}/voice")
|
||||
public R<?> settings(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Settings request) throws Exception {return R.ok(service.settings(projectId,characterId,LoginHelper.getUserId(),request));}
|
||||
@PostMapping("/characters/{characterId}/voice/generate")
|
||||
public R<?> generate(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Generate request) throws Exception {return R.ok(service.generate(projectId,characterId,LoginHelper.getUserId(),request));}
|
||||
@GetMapping("/characters/{characterId}/voice/jobs/{id}")
|
||||
public R<?> poll(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@PathVariable("id") String id) throws Exception {return R.ok(service.poll(projectId,characterId,LoginHelper.getUserId(),id));}
|
||||
@PostMapping("/characters/{characterId}/voice/upload")
|
||||
public R<?> upload(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestPart("file") MultipartFile file) throws Exception {return R.ok(service.upload(projectId,characterId,LoginHelper.getUserId(),file));}
|
||||
@PostMapping("/characters/{characterId}/voice/select")
|
||||
public R<?> select(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Selection request) throws Exception {return R.ok(service.select(projectId,characterId,LoginHelper.getUserId(),request));}
|
||||
@GetMapping("/characters/{characterId}/voice/samples/{id}")
|
||||
public ResponseEntity<byte[]> audio(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@PathVariable("id") String id) throws Exception {
|
||||
return ResponseEntity.ok().contentType(MediaType.valueOf("audio/mpeg")).cacheControl(CacheControl.noStore()).body(Files.readAllBytes(service.audio(projectId,characterId,LoginHelper.getUserId(),id)));
|
||||
}
|
||||
@GetMapping("/voices/storyboards/{storyboardId}")
|
||||
public R<?> preview(@PathVariable("projectId") Long projectId,@PathVariable("storyboardId") Long storyboardId,@RequestParam(value="model",required=false) String model) throws Exception {
|
||||
var plan=service.preview(projectId,storyboardId,LoginHelper.getUserId(),model);
|
||||
return R.ok(Map.of("bindings",plan.bindings(),"direction",plan.direction(),"issues",plan.issues(),"explicit",plan.explicit(),"references",plan.fingerprints(),"speakerIds",plan.speakerIds()));
|
||||
}
|
||||
@PutMapping("/voices/storyboards/{storyboardId}")
|
||||
public R<?> speakers(@PathVariable("projectId") Long projectId,@PathVariable("storyboardId") Long storyboardId,@RequestBody ShortDramaCharacterVoiceService.SpeakersRequest request) throws Exception {
|
||||
return R.ok(Map.of("continuityJson",service.speakers(projectId,storyboardId,LoginHelper.getUserId(),request).getContinuityJson()));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
|
||||
@@ -18,8 +20,10 @@ import org.ruoyi.domain.bo.shortdrama.ShortDramaAudioBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptRevisionBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaImageRevisionBo;
|
||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo;
|
||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo;
|
||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo;
|
||||
@@ -31,8 +35,10 @@ 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.ruoyi.service.shortdrama.impl.ShortDramaTiming;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -57,6 +63,11 @@ import java.util.List;
|
||||
@RequestMapping("/short-drama")
|
||||
public class ShortDramaController {
|
||||
|
||||
@ExceptionHandler(ShortDramaTiming.ReviewException.class)
|
||||
public R<Void> handleTimingReview(ShortDramaTiming.ReviewException error) {
|
||||
return R.fail(error.getMessage());
|
||||
}
|
||||
|
||||
private final IShortDramaService shortDramaService;
|
||||
|
||||
private final IShortDramaVideoComposeService videoComposeService;
|
||||
@@ -131,10 +142,30 @@ public class ShortDramaController {
|
||||
return R.ok(shortDramaService.saveStoryboard(bo, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/storyboard/add")
|
||||
public R<ShortDramaStoryboardVo> addStoryboard(@RequestParam Long projectId, @RequestParam Long scriptId,
|
||||
@RequestParam(required = false) Long afterId) {
|
||||
return R.ok(shortDramaService.addStoryboard(projectId, scriptId, afterId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/storyboard/{storyboardId}")
|
||||
public R<Void> deleteStoryboard(@PathVariable Long storyboardId) {
|
||||
shortDramaService.deleteStoryboard(storyboardId, LoginHelper.getUserId());
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/storyboard/{storyboardId}/generate-video")
|
||||
public R<ShortDramaStoryboardVo> generateVideo(@NotNull @PathVariable Long storyboardId,
|
||||
@NotBlank @RequestParam String model) {
|
||||
return R.ok(shortDramaService.generateVideo(storyboardId, model, LoginHelper.getUserId()));
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String requestId,
|
||||
@RequestParam(defaultValue = "false") boolean regenerate) {
|
||||
return R.ok(shortDramaService.generateVideo(storyboardId, model, LoginHelper.getUserId(), requestId, regenerate));
|
||||
}
|
||||
|
||||
@GetMapping("/storyboard/{storyboardId}/video-submissions/{requestId}")
|
||||
public R<org.ruoyi.service.shortdrama.impl.ShortDramaVideoSubmissionStore.Submission> videoSubmission(
|
||||
@NotNull @PathVariable Long storyboardId, @PathVariable String requestId) {
|
||||
return R.ok(shortDramaService.videoSubmission(storyboardId, requestId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@GetMapping("/storyboard/{storyboardId}/video-result")
|
||||
@@ -145,8 +176,10 @@ public class ShortDramaController {
|
||||
|
||||
@PostMapping("/{projectId}/generate-all-videos")
|
||||
public R<List<ShortDramaStoryboardVo>> generateAllVideos(@NotNull @PathVariable Long projectId,
|
||||
@NotBlank @RequestParam String model) {
|
||||
return R.ok(shortDramaService.generateAllVideos(projectId, model, LoginHelper.getUserId()));
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) Integer sceneStart,
|
||||
@RequestParam(required = false) Integer sceneCount) {
|
||||
return R.ok(shortDramaService.generateAllVideos(projectId, model, LoginHelper.getUserId(), sceneStart, sceneCount));
|
||||
}
|
||||
|
||||
@PostMapping("/{projectId}/compose-video")
|
||||
@@ -180,10 +213,11 @@ public class ShortDramaController {
|
||||
|
||||
// ==================== 阶段式流水线端点 ====================
|
||||
|
||||
/** Phase 1: 剧本打磨 */
|
||||
/** 按修改意见重新生成固定格式剧本 */
|
||||
@PostMapping("/{projectId}/polish-script")
|
||||
public R<ShortDramaDetailVo> polishScript(@NotNull @PathVariable Long projectId, @RequestParam(required = false) String model) {
|
||||
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), model));
|
||||
public R<ShortDramaDetailVo> polishScript(@NotNull @PathVariable Long projectId,
|
||||
@Valid @RequestBody ShortDramaScriptRevisionBo revision) {
|
||||
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), revision.getInstruction()));
|
||||
}
|
||||
|
||||
// ==================== 资产分析 ====================
|
||||
@@ -196,27 +230,73 @@ public class ShortDramaController {
|
||||
}
|
||||
|
||||
// ==================== 分镜流水线 ====================
|
||||
@PostMapping("/{projectId}/analyze-assets/stream")
|
||||
public SseEmitter analyzeAssetsStream(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model,
|
||||
@RequestParam(required = false) String requestId) {
|
||||
return shortDramaService.analyzeAssetsStream(projectId, scriptId, LoginHelper.getUserId(), model, requestId);
|
||||
}
|
||||
|
||||
@GetMapping("/{projectId}/analyze-assets/status")
|
||||
public R<java.util.Map<String, Object>> assetAnalysisStatus(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String requestId) {
|
||||
return R.ok(shortDramaService.assetAnalysisStatus(projectId, scriptId, requestId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
|
||||
/** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */
|
||||
@PostMapping("/{projectId}/plan-storyboard")
|
||||
public R<List<ShortDramaStoryboardVo>> planStoryboard(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId,
|
||||
@RequestParam(required = false) String model) {
|
||||
return R.ok(shortDramaService.planStoryboard(projectId, scriptId, model, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds) {
|
||||
return R.ok(shortDramaService.planStoryboard(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds));
|
||||
}
|
||||
|
||||
/** Phase 3-6: SSE 流式生成分镜,持续推送规划和细化进度 */
|
||||
@PostMapping("/{projectId}/plan-storyboard/review")
|
||||
@PostMapping({"/{projectId}/plan-storyboard/review", "/{projectId}/import-reviewed-plan"})
|
||||
public R<Integer> importReviewedPlan(@PathVariable Long projectId,
|
||||
@Valid @RequestBody org.ruoyi.domain.bo.shortdrama.ShortDramaReviewedPlanBo review) {
|
||||
return R.ok(shortDramaService.importReviewedPlan(projectId,review,LoginHelper.getUserId()));
|
||||
try {
|
||||
return R.ok(shortDramaService.importReviewedPlan(projectId,review,LoginHelper.getUserId()));
|
||||
} catch (IllegalArgumentException | IllegalStateException rejected) {
|
||||
// The global handler intentionally hides even ServiceException messages. This normal,
|
||||
// owner-checked endpoint returns only known domain validation feedback, without a stack/cause.
|
||||
String message = org.ruoyi.service.shortdrama.impl.ShortDramaReviewedImportMessages.failureMessage(rejected);
|
||||
if (message == null) throw rejected;
|
||||
return R.fail(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only recovery proof; it never submits a model request or promotes an unchecked draft. */
|
||||
@GetMapping("/{projectId}/checkpoint-status")
|
||||
public R<java.util.Map<String, Object>> storyboardCheckpointStatus(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds) {
|
||||
return R.ok(shortDramaService.storyboardCheckpointStatus(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds));
|
||||
}
|
||||
|
||||
/** Unreviewed native output only; viewing it does not validate or import any panel. */
|
||||
@GetMapping("/{projectId}/plan-storyboard/candidates")
|
||||
public R<java.util.Map<String, Object>> storyboardCandidates(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "4") Integer minimumShotSeconds) {
|
||||
return R.ok(shortDramaService.storyboardCandidates(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds));
|
||||
}
|
||||
|
||||
@GetMapping("/{projectId}/plan-storyboard/status")
|
||||
public R<java.util.Map<String, Object>> storyboardPlanningStatus(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String requestId) {
|
||||
return R.ok(shortDramaService.storyboardPlanningStatus(projectId, scriptId, requestId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@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());
|
||||
@RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds,
|
||||
@RequestParam(required = false) String requestId) {
|
||||
return shortDramaService.planStoryboardStream(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds, requestId);
|
||||
}
|
||||
|
||||
/** Phase 4: 重新生成摄影规则 */
|
||||
@@ -328,15 +408,19 @@ public class ShortDramaController {
|
||||
@PostMapping("/location/{locationId}/generate-image")
|
||||
public R<ShortDramaLocationVo> generateLocationImage(@NotNull @PathVariable Long locationId,
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String referenceImageUrl) {
|
||||
return R.ok(shortDramaService.generateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String referenceImageUrl,
|
||||
@Valid @RequestBody(required = false) org.ruoyi.domain.bo.shortdrama.ShortDramaLocationImageRevisionBo revision) {
|
||||
return R.ok(shortDramaService.generateLocationImage(locationId, model, referenceImageUrl,
|
||||
revision == null ? null : revision.getRevisionRequirements(), LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/location/{locationId}/regenerate")
|
||||
public R<ShortDramaLocationVo> regenerateLocationImage(@NotNull @PathVariable Long locationId,
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String referenceImageUrl) {
|
||||
return R.ok(shortDramaService.regenerateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String referenceImageUrl,
|
||||
@Valid @RequestBody(required = false) org.ruoyi.domain.bo.shortdrama.ShortDramaLocationImageRevisionBo revision) {
|
||||
return R.ok(shortDramaService.regenerateLocationImage(locationId, model, referenceImageUrl,
|
||||
revision == null ? null : revision.getRevisionRequirements(), LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/location/{locationId}/select-image")
|
||||
@@ -400,8 +484,12 @@ public class ShortDramaController {
|
||||
public R<MediaGenerationResponse> startImage(@NotBlank @RequestParam String assetType,
|
||||
@NotNull @RequestParam Long assetId,
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String referenceImageUrl) {
|
||||
return R.ok(shortDramaService.startImageGeneration(assetType, assetId, model, referenceImageUrl, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String referenceImageUrl,
|
||||
@Valid @RequestBody(required = false) ShortDramaImageRevisionBo revision) {
|
||||
return R.ok(shortDramaService.startImageGeneration(assetType, assetId, model, referenceImageUrl,
|
||||
revision == null ? null : revision.getReferencePurpose(),
|
||||
revision == null ? null : revision.getRevisionRequirements(),
|
||||
revision == null ? null : revision.getStyleReferenceImageUrls(), LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
/** 轮询确认形象图片并保存 */
|
||||
|
||||
@@ -87,7 +87,7 @@ public class ShortDramaMaterialController {
|
||||
ObjectNode image = replace >= 0 ? ((ObjectNode)list.get(replace)).deepCopy() : JSON.createObjectNode();
|
||||
image.remove("items");
|
||||
image.put("url", "/short-drama/"+projectId+"/materials/file/"+name);
|
||||
image.put("filename", Objects.toString(file.getOriginalFilename(), "真实素材"));
|
||||
image.put("filename", Objects.toString(file.getOriginalFilename(), "素材"));
|
||||
image.put("type", type);
|
||||
if (replace >= 0) list.set(replace, image); else list.add(image);
|
||||
}
|
||||
@@ -155,8 +155,8 @@ public class ShortDramaMaterialController {
|
||||
owner(projectId);var shot=boards.selectById(shotId);
|
||||
if(shot==null || !projectId.equals(shot.getProjectId()) || "generating".equals(shot.getVideoStatus()))throw new IllegalArgumentException("镜头不存在或正在生成");
|
||||
var continuity=(ObjectNode)JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}"));
|
||||
if(!"direct_insert".equals(continuity.path("source_media").path("mode").asText()))throw new IllegalArgumentException("仅支持真实素材镜头");
|
||||
var images=items(continuity);if(images.isEmpty())throw new IllegalArgumentException("请先上传真实素材");
|
||||
if(!"direct_insert".equals(continuity.path("source_media").path("mode").asText()))throw new IllegalArgumentException("仅支持素材镜头");
|
||||
var images=items(continuity);if(images.isEmpty())throw new IllegalArgumentException("请先上传素材");
|
||||
Path dir=directory(projectId);String job=UUID.randomUUID().toString();
|
||||
List<String> command=new ArrayList<>(List.of("ffmpeg","-v","error","-y"));List<String> filters=new ArrayList<>();StringBuilder concat=new StringBuilder();
|
||||
double duration=shot.getDurationSeconds(), part=duration/images.size();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.common.satoken.utils.LoginHelper;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaMusicService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/music")
|
||||
public class ShortDramaMusicController {
|
||||
private final ShortDramaMusicService service;
|
||||
@GetMapping public R<?> list(@PathVariable Long projectId) throws Exception {return R.ok(service.list(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping public R<?> generate(@PathVariable Long projectId,@RequestBody ShortDramaMusicService.MusicRequest request) throws Exception {return R.ok(service.generate(projectId,LoginHelper.getUserId(),request));}
|
||||
@GetMapping("/{id}") public R<?> poll(@PathVariable Long projectId,@PathVariable String id) throws Exception {return R.ok(service.poll(projectId,LoginHelper.getUserId(),id));}
|
||||
@PostMapping("/{id}/use") public R<?> use(@PathVariable Long projectId,@PathVariable String id,@RequestBody ShortDramaMusicService.Usage usage) throws Exception {return R.ok(service.use(projectId,LoginHelper.getUserId(),id,usage));}
|
||||
@PostMapping("/write") public R<?> write(@PathVariable Long projectId,@RequestBody ShortDramaMusicService.BriefRequest request) {return R.ok(service.write(projectId,LoginHelper.getUserId(),request));}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaSkillCatalog;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaSkillMarket;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
|
||||
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/skills")
|
||||
public class ShortDramaSkillController {
|
||||
private final ShortDramaSkillCatalog catalog;
|
||||
private final ShortDramaSkillMarket market;
|
||||
@GetMapping("/market") public R<List<ShortDramaSkillMarket.Entry>> market() { return R.ok(market.list()); }
|
||||
@GetMapping public R<List<ShortDramaSkillCatalog.Option>> list(@RequestParam(required=false) String type,
|
||||
@RequestParam(defaultValue="false") boolean includeDisabled) { return R.ok(catalog.list(type, includeDisabled)); }
|
||||
@GetMapping("/{name}") public R<ShortDramaSkillCatalog.Skill> detail(@PathVariable String name) { return R.ok(catalog.detail(name)); }
|
||||
@PostMapping @SaCheckPermission("shortDrama:skill:manage")
|
||||
public R<ShortDramaSkillCatalog.Skill> create(@RequestBody ShortDramaSkillCatalog.Save body) { return R.ok(catalog.save(body.name(), body, true)); }
|
||||
@PutMapping("/{name}") @SaCheckPermission("shortDrama:skill:manage")
|
||||
public R<ShortDramaSkillCatalog.Skill> update(@PathVariable String name, @RequestBody ShortDramaSkillCatalog.Save body) { return R.ok(catalog.save(name, body, false)); }
|
||||
}
|
||||
@@ -11,12 +11,20 @@ public class ShortDramaVisualAssetController {
|
||||
private final ShortDramaVisualAssetService service;
|
||||
public record ReviseFrame(String prompt,String model) { }
|
||||
@PostMapping("/{assetId}/revise") public R<Map<String,Object>> revise(@PathVariable Long projectId,@PathVariable Long assetId,@RequestBody ReviseFrame body) {return R.ok(service.reviseFrame(projectId,assetId,body.prompt(),body.model(),LoginHelper.getUserId()));}
|
||||
public record StartFrame(String imageUrl) { }
|
||||
@PostMapping("/{storyboardId}/start-frame") public R<Map<String,Object>> saveStartFrame(@PathVariable Long projectId,@PathVariable Long storyboardId,@RequestBody StartFrame body) {return R.ok(service.saveManualStartFrame(projectId,storyboardId,body.imageUrl(),LoginHelper.getUserId()));}
|
||||
@DeleteMapping("/{storyboardId}/start-frame") public R<Map<String,Object>> removeStartFrame(@PathVariable Long projectId,@PathVariable Long storyboardId) {return R.ok(service.removeManualStartFrame(projectId,storyboardId,LoginHelper.getUserId()));}
|
||||
public record Prop(String title,String prompt,java.util.List<Integer> shotNumbers,String imageUrl) { }
|
||||
@PostMapping("/prop") public R<Map<String,Object>> prop(@PathVariable Long projectId,@RequestBody Prop body) {return R.ok(service.saveProp(projectId,body.title(),body.prompt(),body.shotNumbers(),body.imageUrl(),LoginHelper.getUserId()));}
|
||||
public record PropUpdate(String prompt,String referenceImageUrl) { }
|
||||
@PutMapping("/prop/{assetId}") public R<Map<String,Object>> updateProp(@PathVariable Long projectId,@PathVariable Long assetId,@RequestBody PropUpdate body) {return R.ok(service.updateProp(projectId,assetId,body.prompt(),body.referenceImageUrl(),LoginHelper.getUserId()));}
|
||||
@PostMapping("/prop/{assetId}/regenerate") public R<Map<String,Object>> regenerateProp(@PathVariable Long projectId,@PathVariable Long assetId,@RequestParam String model) {return R.ok(service.regenerateProp(projectId,assetId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/prop/{assetId}/undo") public R<Map<String,Object>> undoProp(@PathVariable Long projectId,@PathVariable Long assetId) {return R.ok(service.undoProp(projectId,assetId,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate-opening") public R<Map<String,Object>> opening(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generateOpening(projectId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate-range") public R<Map<String,Object>> range(@PathVariable Long projectId,@RequestParam String model,@RequestParam int start,@RequestParam int end) {return R.ok(service.generateRange(projectId,model,LoginHelper.getUserId(),start,end));}
|
||||
@GetMapping public R<Map<String,Object>> status(@PathVariable Long projectId) {return R.ok(service.status(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping("/plan") public R<Map<String,Object>> plan(@PathVariable Long projectId,@RequestParam(required=false) String model) {return R.ok(service.plan(projectId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/recover") public R<Map<String,Object>> recover(@PathVariable Long projectId) {return R.ok(service.recover(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate-props") public R<Map<String,Object>> missingProps(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generateMissingProps(projectId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate") public R<Map<String,Object>> generate(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generate(projectId,model,LoginHelper.getUserId()));}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaVoiceException;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestControllerAdvice(assignableTypes={ShortDramaCharacterVoiceController.class,ShortDramaController.class})
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class ShortDramaVoiceErrors {
|
||||
@ExceptionHandler(ShortDramaVoiceException.class)
|
||||
public R<Void> validation(ShortDramaVoiceException error) {return R.fail(error.getMessage());}
|
||||
}
|
||||
@@ -15,9 +15,13 @@ public class ShortDramaIdeaBo {
|
||||
private String projectName;
|
||||
|
||||
private String artStyle;
|
||||
private String aestheticSkillName;
|
||||
private String directorSkillName;
|
||||
|
||||
private String aspectRatio;
|
||||
|
||||
/** Stop after script so the author can review before asset/shot planning. */
|
||||
private Boolean scriptOnly = false;
|
||||
/** 兼容旧请求字段;创建接口始终只生成剧本,该值不再控制后续阶段。 */
|
||||
@Deprecated
|
||||
private Boolean scriptOnly = true;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.ruoyi.domain.bo.shortdrama;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/** Optional request-only edit intent; omission preserves approved identity/location continuity. */
|
||||
@Data
|
||||
public class ShortDramaImageRevisionBo {
|
||||
@Pattern(regexp = "identity|identity_revision|location|location_revision")
|
||||
private String referencePurpose;
|
||||
|
||||
@Size(max = 4000)
|
||||
private String revisionRequirements;
|
||||
|
||||
/** Separate look references; the primary reference still owns identity or geometry. */
|
||||
@Size(max = 3)
|
||||
private java.util.List<@Pattern(regexp = "https?://.+") String> styleReferenceImageUrls;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.ruoyi.domain.bo.shortdrama;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/** Request-only image changes. Never persisted into the frozen location description. */
|
||||
@Data
|
||||
public class ShortDramaLocationImageRevisionBo {
|
||||
@Size(max = 4000)
|
||||
private String revisionRequirements;
|
||||
}
|
||||
@@ -19,9 +19,13 @@ public class ShortDramaProjectBo extends BaseEntity {
|
||||
|
||||
private String description;
|
||||
|
||||
private String originalIdea;
|
||||
|
||||
private String status;
|
||||
|
||||
private String artStyle;
|
||||
private String aestheticSkillName;
|
||||
private String directorSkillName;
|
||||
|
||||
private String composeAspectRatio;
|
||||
}
|
||||
|
||||
@@ -10,5 +10,10 @@ public class ShortDramaReviewedPlanBo {
|
||||
@NotNull private Long scriptId;
|
||||
@NotBlank private String expectedScriptText;
|
||||
private String model;
|
||||
@NotNull @Min(1) @Max(15) private Integer minimumShotSeconds = 1;
|
||||
/** Absent means a full reviewed plan; present imports only explicitly reviewed scenes. */
|
||||
@Size(min=1, max=100) private List<@NotNull @Min(1) Integer> sceneNumbers;
|
||||
/** Returned by checkpoint-status. Required for partial imports, ignores temporary image URLs. */
|
||||
@Pattern(regexp="[a-f0-9]{64}") private String expectedAssetSignature;
|
||||
@NotEmpty @Size(max=500) private List<StoryboardPanelData> panels;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ public class ShortDramaRevisionBo {
|
||||
@NotBlank private String expectedScriptText;
|
||||
@NotBlank private String scriptText;
|
||||
private String outlineText;
|
||||
private String tone;
|
||||
private String worldbuilding;
|
||||
private String revisionNotes;
|
||||
@NotEmpty private List<ShortDramaStoryboardBo> storyboards;
|
||||
private List<ShortDramaCharacterBo> characters;
|
||||
private List<ShortDramaLocationBo> locations;
|
||||
|
||||
@@ -24,4 +24,12 @@ public class ShortDramaScriptBo extends BaseEntity {
|
||||
private String tone;
|
||||
|
||||
private String sourceType;
|
||||
|
||||
private String creationMode;
|
||||
|
||||
private String sourceMaterials;
|
||||
|
||||
private String worldbuilding;
|
||||
|
||||
private String revisionNotes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.ruoyi.domain.bo.shortdrama;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ShortDramaScriptRevisionBo {
|
||||
|
||||
@NotBlank(message = "修改意见不能为空")
|
||||
@Size(max = 10000, message = "修改意见不能超过10000字")
|
||||
private String instruction;
|
||||
}
|
||||
@@ -27,9 +27,14 @@ public class ShortDramaProject extends BaseEntity {
|
||||
|
||||
private String description;
|
||||
|
||||
/** Original user input, independent of generated summaries and script revisions. */
|
||||
private String originalIdea;
|
||||
|
||||
private String status;
|
||||
|
||||
private String artStyle;
|
||||
private String aestheticSkillName;
|
||||
private String directorSkillName;
|
||||
|
||||
private Long composedVideoOssId;
|
||||
|
||||
|
||||
@@ -30,4 +30,12 @@ public class ShortDramaScript extends BaseEntity {
|
||||
private String tone;
|
||||
|
||||
private String sourceType;
|
||||
|
||||
private String creationMode;
|
||||
|
||||
private String sourceMaterials;
|
||||
|
||||
private String worldbuilding;
|
||||
|
||||
private String revisionNotes;
|
||||
}
|
||||
|
||||
@@ -24,9 +24,13 @@ public class ShortDramaProjectVo implements Serializable {
|
||||
|
||||
private String description;
|
||||
|
||||
private String originalIdea;
|
||||
|
||||
private String status;
|
||||
|
||||
private String artStyle;
|
||||
private String aestheticSkillName;
|
||||
private String directorSkillName;
|
||||
|
||||
private Long composedVideoOssId;
|
||||
|
||||
|
||||
@@ -32,4 +32,12 @@ public class ShortDramaScriptVo implements Serializable {
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
|
||||
private String creationMode;
|
||||
|
||||
private String sourceMaterials;
|
||||
|
||||
private String worldbuilding;
|
||||
|
||||
private String revisionNotes;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.ruoyi.service.audio.provider;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -20,13 +19,11 @@ import org.ruoyi.service.media.AtlasPredictionService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Atlas Cloud 音频生成(bytedance/seed-audio-1.0)。异步:提交 /model/generateAudio 返回 predictionId,
|
||||
* 轮询 /model/prediction/{id} 拿音频 URL。支持 references(speaker 音色 + 参考音频)做多角色对白配音。
|
||||
* Atlas Cloud TTS:Gemini 单音色与 Seed Audio references 使用各自的请求字段。
|
||||
* 提交返回 predictionId,轮询 /model/prediction/{id} 获取音频;Gemini 付费 POST 不自动重试。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("atlasAudio")
|
||||
@@ -41,37 +38,17 @@ public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationServ
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
private final OkHttpClient geminiHttpClient = okHttpClient.newBuilder()
|
||||
.retryOnConnectionFailure(false)
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build();
|
||||
|
||||
@Override
|
||||
protected MediaGenerationResponse doGenerateSpeech(AudioContext audioContext) {
|
||||
ChatModelVo model = audioContext.getChatModelVo();
|
||||
ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
|
||||
payload.put("model", model.getModelName());
|
||||
payload.put("text", audioContext.getInput());
|
||||
String format = StrUtil.blankToDefault(audioContext.getResponseFormat(), "mp3");
|
||||
payload.put("format", format);
|
||||
|
||||
// 参考资源:多角色音色 + 参考音频
|
||||
List<Map<String, String>> refs = audioContext.getReferences();
|
||||
if (refs != null && !refs.isEmpty()) {
|
||||
ArrayNode arr = payload.putArray("references");
|
||||
for (Map<String, String> ref : refs) {
|
||||
ObjectNode r = arr.addObject();
|
||||
if (StrUtil.isNotBlank(ref.get("speaker"))) r.put("speaker", ref.get("speaker"));
|
||||
if (StrUtil.isNotBlank(ref.get("audioUrl"))) r.put("audio_url", ref.get("audioUrl"));
|
||||
if (StrUtil.isNotBlank(ref.get("audioData"))) r.put("audio_data", ref.get("audioData"));
|
||||
if (StrUtil.isNotBlank(ref.get("imageData"))) r.put("image_data", ref.get("imageData"));
|
||||
}
|
||||
} else if (StrUtil.isNotBlank(audioContext.getVoice())) {
|
||||
// 没有显式 references 但指定了 voice 音色名:作为单一 speaker
|
||||
ArrayNode arr = payload.putArray("references");
|
||||
ObjectNode r = arr.addObject();
|
||||
r.put("speaker", audioContext.getVoice());
|
||||
}
|
||||
|
||||
if (audioContext.getSampleRate() != null) payload.put("sample_rate", audioContext.getSampleRate());
|
||||
if (audioContext.getPitchRate() != null) payload.put("pitch_rate", audioContext.getPitchRate());
|
||||
if (audioContext.getSpeechRate() != null) payload.put("speech_rate", audioContext.getSpeechRate());
|
||||
if (audioContext.getLoudnessRate() != null) payload.put("loudness_rate", audioContext.getLoudnessRate());
|
||||
boolean gemini = AtlasSpeechPayloadBuilder.isGeminiTts(model.getModelName());
|
||||
ObjectNode payload = AtlasSpeechPayloadBuilder.build(audioContext);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateAudio"))
|
||||
@@ -79,15 +56,26 @@ public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationServ
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON))
|
||||
.build();
|
||||
try (Response response = okHttpClient.newCall(request).execute()) {
|
||||
try (Response response = (gemini ? geminiHttpClient : okHttpClient).newCall(request).execute()) {
|
||||
ResponseBody body = response.body();
|
||||
String responseText = body == null ? "" : body.string();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IllegalArgumentException("Atlas Cloud 音频生成任务创建失败: " + response.code() + " - " + responseText);
|
||||
}
|
||||
return atlasPredictionService.toResponse(responseText, "audio");
|
||||
if (!gemini) return atlasPredictionService.toResponse(responseText, "audio");
|
||||
// Public documentation also shows a root-level prediction object.
|
||||
var root = AtlasMediaSupport.OBJECT_MAPPER.readTree(responseText);
|
||||
String normalized = root != null && root.isObject() && !root.has("data") && root.has("id")
|
||||
? AtlasMediaSupport.OBJECT_MAPPER.createObjectNode().set("data", root).toString() : responseText;
|
||||
MediaGenerationResponse result = atlasPredictionService.toResponse(normalized, "audio");
|
||||
if (result == null || (StrUtil.isBlank(result.getId()) && StrUtil.isBlank(result.getUrl()))) {
|
||||
throw new IllegalStateException("Atlas 音频提交结果未知:未返回任务ID或音频URL,请先核对上游任务,禁止重复提交");
|
||||
}
|
||||
result.setRawResponse(responseText);
|
||||
return result;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Atlas Cloud 音频生成任务创建失败: " + e.getMessage(), e);
|
||||
if (!gemini) throw new RuntimeException("Atlas Cloud 音频生成任务创建失败: " + e.getMessage(), e);
|
||||
throw new RuntimeException("Atlas 音频提交结果未知,请先核对上游任务,禁止重复提交: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package org.ruoyi.service.audio.provider;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.ruoyi.common.chat.entity.audio.AudioContext;
|
||||
import org.ruoyi.service.media.AtlasMediaSupport;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Model-specific Atlas speech contracts; Gemini is not a Seed Audio reference request. */
|
||||
public final class AtlasSpeechPayloadBuilder {
|
||||
public static final String GEMINI_MODEL = "google/gemini-3.1-flash-tts";
|
||||
public static final String DEFAULT_GEMINI_VOICE = "Charon";
|
||||
public static final String DRAMA_INSTRUCTIONS = "使用自然、有力的中文男声,保持同一人的声线;"
|
||||
+ "根据原句表达情绪,语速适合短剧表演,不过度播报,不增加台词或旁白。";
|
||||
private static final Set<String> GEMINI_VOICES = Set.of(
|
||||
"Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda", "Orus", "Aoede", "Callirrhoe", "Autonoe",
|
||||
"Enceladus", "Iapetus", "Umbriel", "Algieba", "Despina", "Erinome", "Algenib", "Rasalgethi",
|
||||
"Laomedeia", "Achernar", "Alnilam", "Schedar", "Gacrux", "Pulcherrima", "Achird", "Zubenelgenubi",
|
||||
"Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat");
|
||||
private static final Pattern AUDIO_LABEL = Pattern.compile("(?i)@audio\\d+\\b\\s*[::]?[\\t ]*");
|
||||
|
||||
private AtlasSpeechPayloadBuilder() { }
|
||||
|
||||
public static boolean isGeminiTts(String model) {
|
||||
return GEMINI_MODEL.equals(model);
|
||||
}
|
||||
|
||||
public static ObjectNode build(AudioContext context) {
|
||||
String model = context.getChatModelVo().getModelName();
|
||||
ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
|
||||
payload.put("model", model);
|
||||
if (isGeminiTts(model)) {
|
||||
String voice = StrUtil.blankToDefault(context.getVoice(), DEFAULT_GEMINI_VOICE);
|
||||
if (!GEMINI_VOICES.contains(voice)) {
|
||||
throw new IllegalArgumentException("Gemini TTS 音色必须是官方 prebuilt voice,不能使用 Seed Audio 的 speaker 名称");
|
||||
}
|
||||
String spoken = spokenText(context.getInput(), List.of());
|
||||
if (StrUtil.isBlank(spoken)) throw new IllegalArgumentException("语音正文不能为空");
|
||||
String direction = StrUtil.blankToDefault(context.getInstructions(), DRAMA_INSTRUCTIONS);
|
||||
// Atlas exposes one text field. Gemini accepts natural-language performance instructions in it.
|
||||
payload.put("text", "单人配音任务。仅朗读下面“正文”中的原句,不朗读任务说明、表演指令、角色名或标签。\n"
|
||||
+ "表演指令:" + direction + "\n正文:\n" + spoken);
|
||||
payload.put("voice", voice);
|
||||
return payload;
|
||||
}
|
||||
|
||||
payload.put("text", context.getInput());
|
||||
payload.put("format", StrUtil.blankToDefault(context.getResponseFormat(), "mp3"));
|
||||
List<Map<String, String>> refs = context.getReferences();
|
||||
if (refs != null && !refs.isEmpty()) {
|
||||
if (refs.size() > 3) throw new IllegalArgumentException("Seed Audio最多3条声音参考或1张图像参考");
|
||||
ArrayNode arr = payload.putArray("references");
|
||||
boolean image = false;
|
||||
for (Map<String, String> ref : refs) {
|
||||
long sources = List.of("speaker", "audioUrl", "audioData", "imageUrl", "imageData").stream()
|
||||
.filter(key -> StrUtil.isNotBlank(ref.get(key))).count();
|
||||
if (sources != 1) throw new IllegalArgumentException("每条 Seed Audio 参考必须且只能包含一种来源");
|
||||
image |= StrUtil.isNotBlank(ref.get("imageUrl")) || StrUtil.isNotBlank(ref.get("imageData"));
|
||||
ObjectNode r = arr.addObject();
|
||||
if (StrUtil.isNotBlank(ref.get("speaker"))) r.put("speaker", ref.get("speaker"));
|
||||
if (StrUtil.isNotBlank(ref.get("audioUrl"))) r.put("audio_url", ref.get("audioUrl"));
|
||||
if (StrUtil.isNotBlank(ref.get("audioData"))) r.put("audio_data", ref.get("audioData"));
|
||||
if (StrUtil.isNotBlank(ref.get("imageData"))) r.put("image_data", ref.get("imageData"));
|
||||
if (StrUtil.isNotBlank(ref.get("imageUrl"))) r.put("image_url", ref.get("imageUrl"));
|
||||
}
|
||||
if (image && refs.size() != 1) throw new IllegalArgumentException("Seed Audio图像参考最多1张,不能与声音参考混用");
|
||||
} else if (StrUtil.isNotBlank(context.getVoice())) {
|
||||
payload.putArray("references").addObject().put("speaker", context.getVoice());
|
||||
}
|
||||
if (context.getSampleRate() != null) payload.put("sample_rate", context.getSampleRate());
|
||||
if (context.getPitchRate() != null) payload.put("pitch_rate", context.getPitchRate());
|
||||
if (context.getSpeechRate() != null) payload.put("speech_rate", context.getSpeechRate());
|
||||
if (context.getLoudnessRate() != null) payload.put("loudness_rate", context.getLoudnessRate());
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Remove production speaker labels, never arbitrary text preceding a colon inside a spoken sentence. */
|
||||
public static String spokenText(String input, Collection<String> speakerNames) {
|
||||
if (input == null) return "";
|
||||
String text = AUDIO_LABEL.matcher(input).replaceAll("");
|
||||
for (String alias : List.of("旁白", "画外音", "Narrator", "Narration")) {
|
||||
text = removeSpeakerPrefix(text, alias);
|
||||
}
|
||||
text = text.replaceAll("(?im)^[\\t ]*Speaker[\\t ]*\\d+[\\t ]*[::][\\t ]*", "");
|
||||
if (speakerNames != null) {
|
||||
for (String name : speakerNames) {
|
||||
if (StrUtil.isNotBlank(name)) text = removeSpeakerPrefix(text, name);
|
||||
}
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
private static String removeSpeakerPrefix(String text, String name) {
|
||||
return text.replaceAll("(?m)^[\\t ]*" + Pattern.quote(name)
|
||||
+ "[\\t ]*(?:[((][^))\\r\\n]*[))][\\t ]*)?[::][\\t ]*", "");
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ public interface AbstractChatService {
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Optional request parameters; native providers keep their existing adapter behavior. */
|
||||
default ChatModel buildChatModel(ChatModelVo chatModelVo, ChatRequest request) {
|
||||
return buildChatModel(chatModelVo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务提供商名称
|
||||
*/
|
||||
|
||||
@@ -110,10 +110,10 @@ public class ChatModelServiceImpl implements IChatModelService {
|
||||
|
||||
private LambdaQueryWrapper<ChatModel> buildQueryWrapper(ChatModelBo bo) {
|
||||
LambdaQueryWrapper<ChatModel> lqw = Wrappers.lambdaQuery();
|
||||
lqw.orderByAsc(ChatModel::getId);
|
||||
lqw.orderByAsc(ChatModel::getSortOrder, ChatModel::getId);
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getCategory()), ChatModel::getCategory, bo.getCategory());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getModelName()), ChatModel::getModelName, bo.getModelName());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getProviderCode()), ChatModel::getProviderCode, bo.getProviderCode());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getProviderCode()), ChatModel::getProviderCode, bo.getProviderCode());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getModelDescribe()), ChatModel::getModelDescribe, bo.getModelDescribe());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getModelShow()), ChatModel::getModelShow, bo.getModelShow());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getApiHost()), ChatModel::getApiHost, bo.getApiHost());
|
||||
@@ -181,6 +181,7 @@ public class ChatModelServiceImpl implements IChatModelService {
|
||||
}
|
||||
target.setId(source.getId());
|
||||
target.setCategory(source.getCategory());
|
||||
target.setSortOrder(source.getSortOrder());
|
||||
target.setModelName(source.getModelName());
|
||||
target.setProviderCode(source.getProviderCode());
|
||||
target.setModelDescribe(source.getModelDescribe());
|
||||
|
||||
@@ -262,8 +262,8 @@ public class ChatServiceFacade implements IChatService {
|
||||
llmSpan.detach();
|
||||
}
|
||||
finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, e);
|
||||
SseMessageUtils.sendError(String.valueOf(chatRequest.getSessionId()), SAFE_CHAT_ERROR_MESSAGE);
|
||||
SseMessageUtils.completeConnection(String.valueOf(chatRequest.getSessionId()));
|
||||
saveModelChatFailure(chatRequest, "");
|
||||
SseMessageUtils.sendErrorAndComplete(String.valueOf(chatRequest.getSessionId()), SAFE_CHAT_ERROR_MESSAGE);
|
||||
log.error("chat_operation operation=MODEL_CHAT status=FAILED errorType={}", errorType(e));
|
||||
}
|
||||
return chatRequest.getEmitter();
|
||||
@@ -807,20 +807,24 @@ public class ChatServiceFacade implements IChatService {
|
||||
return new StreamingChatResponseHandler() {
|
||||
|
||||
private final StringBuilder messageBuffer = new StringBuilder();
|
||||
private final AtomicBoolean terminal = new AtomicBoolean();
|
||||
|
||||
@Override
|
||||
public void onPartialResponse(String partialResponse) {
|
||||
if (terminal.get()) return;
|
||||
messageBuffer.append(partialResponse);
|
||||
SseMessageUtils.sendContent(sessionId, partialResponse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPartialThinking(PartialThinking partialThinking) {
|
||||
if (terminal.get()) return;
|
||||
SseMessageUtils.sendReasoning(sessionId, partialThinking.text());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleteResponse(ChatResponse completeResponse) {
|
||||
if (!terminal.compareAndSet(false, true)) return;
|
||||
try {
|
||||
String fullMessage = messageBuffer.toString();
|
||||
if (StringUtils.isNotBlank(fullMessage)) {
|
||||
@@ -832,7 +836,7 @@ public class ChatServiceFacade implements IChatService {
|
||||
chatRequest.getModel()
|
||||
);
|
||||
} else {
|
||||
log.warn("chat_stream status=EMPTY_RESPONSE");
|
||||
throw new IllegalStateException("Model completed without text content");
|
||||
}
|
||||
if (llmSpan != null) {
|
||||
llmSpan.finishSuccess(RagTracePayloadBuilder.streamOutputSummary(fullMessage.length()));
|
||||
@@ -844,30 +848,43 @@ public class ChatServiceFacade implements IChatService {
|
||||
llmSpan.finishError(e);
|
||||
}
|
||||
finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, e);
|
||||
SseMessageUtils.sendError(sessionId, SAFE_CHAT_ERROR_MESSAGE);
|
||||
saveModelChatFailure(chatRequest, messageBuffer.toString());
|
||||
SseMessageUtils.sendErrorAndComplete(sessionId, SAFE_CHAT_ERROR_MESSAGE);
|
||||
log.error("chat_stream operation=COMPLETE status=FAILED errorType={}", errorType(e));
|
||||
} finally {
|
||||
if (llmSpan != null) {
|
||||
llmSpan.detach();
|
||||
}
|
||||
SseMessageUtils.completeConnection(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
if (llmSpan != null) {
|
||||
llmSpan.finishError(error);
|
||||
llmSpan.detach();
|
||||
if (!terminal.compareAndSet(false, true)) return;
|
||||
try {
|
||||
if (llmSpan != null) llmSpan.finishError(error);
|
||||
finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, error);
|
||||
saveModelChatFailure(chatRequest, messageBuffer.toString());
|
||||
} finally {
|
||||
if (llmSpan != null) llmSpan.detach();
|
||||
SseMessageUtils.sendErrorAndComplete(sessionId, SAFE_CHAT_ERROR_MESSAGE);
|
||||
}
|
||||
finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, error);
|
||||
SseMessageUtils.sendError(sessionId, SAFE_CHAT_ERROR_MESSAGE);
|
||||
SseMessageUtils.completeConnection(sessionId);
|
||||
log.error("chat_stream operation=MODEL_STREAM status=FAILED errorType={}", errorType(error));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void saveModelChatFailure(ChatRequest request, String partialText) {
|
||||
try {
|
||||
String saved = StringUtils.isBlank(partialText) ? SAFE_CHAT_ERROR_MESSAGE
|
||||
: partialText + "\n\n[响应未完成] " + SAFE_CHAT_ERROR_MESSAGE;
|
||||
chatMessageService.saveChatMessage(request.getUserId(), request.getSessionId(), saved,
|
||||
RoleType.ASSISTANT.getName(), request.getModel());
|
||||
} catch (Exception failure) {
|
||||
log.error("chat_stream operation=SAVE_ERROR status=FAILED errorType={}", errorType(failure));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建组合响应处理器 - 同时发送到 SSE 和外部 handler
|
||||
*
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.model.chat.ChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -10,9 +12,11 @@ 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.ruoyi.service.chat.impl.provider.atlas.AtlasDeadlineHttpClientBuilder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Atlas Cloud服务调用
|
||||
@@ -28,14 +32,38 @@ public class AtlaServiceImpl implements AbstractChatService {
|
||||
@Override
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
|
||||
return OpenAiStreamingChatModel.builder()
|
||||
.httpClientBuilder(new AtlasDeadlineHttpClientBuilder())
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(Duration.ofMinutes(6))
|
||||
.maxTokens(16384)
|
||||
.reasoningEffort(chatRequest.getReasoningEffort())
|
||||
.customParameters(SeedThinkingParameters.forRequest(chatModelVo.getModelName(), chatRequest))
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.returnThinking(chatRequest.getEnableThinking())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo chatModelVo) {
|
||||
return buildChatModel(chatModelVo, new ChatRequest());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo chatModelVo, ChatRequest request) {
|
||||
return OpenAiChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(Duration.ofMinutes(20))
|
||||
.maxRetries(0)
|
||||
.maxTokens(16384)
|
||||
.reasoningEffort(request.getReasoningEffort())
|
||||
.customParameters(SeedThinkingParameters.forRequest(chatModelVo.getModelName(), request))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProviderName() {
|
||||
return ChatModeType.ATLAS.getCode();
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.model.chat.ChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -29,6 +31,23 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class DeepseekServiceImpl implements AbstractChatService {
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo model) {
|
||||
return buildChatModel(model, new ChatRequest());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo model, ChatRequest request) {
|
||||
boolean thinking = Boolean.TRUE.equals(request.getEnableThinking());
|
||||
var builder = OpenAiChatModel.builder().baseUrl(model.getApiHost()).apiKey(model.getApiKey())
|
||||
.modelName(model.getModelName()).listeners(List.of(new MyChatModelListener()))
|
||||
.customParameters(thinkingParameters(thinking)).returnThinking(thinking)
|
||||
.sendThinking(thinking || Boolean.TRUE.equals(request.getReplayThinking()))
|
||||
.timeout(Duration.ofMinutes(3)).maxRetries(0);
|
||||
if (thinking) builder.reasoningEffort("high");
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
|
||||
boolean thinkingEnabled = Boolean.TRUE.equals(chatRequest.getEnableThinking());
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.model.chat.ChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -28,12 +30,24 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class OpenAIServiceImpl implements AbstractChatService {
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo config, ChatRequest request) {
|
||||
return OpenAiChatModel.builder()
|
||||
.baseUrl(config.getApiHost()).apiKey(config.getApiKey()).modelName(config.getModelName())
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.reasoningEffort(request.getReasoningEffort())
|
||||
.customParameters(SeedThinkingParameters.forRequest(config.getModelName(), request))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo,ChatRequest chatRequest) {
|
||||
return OpenAiStreamingChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.reasoningEffort(chatRequest.getReasoningEffort())
|
||||
.customParameters(SeedThinkingParameters.forRequest(chatModelVo.getModelName(), chatRequest))
|
||||
.timeout(Duration.ofMinutes(30))
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.returnThinking(chatRequest.getEnableThinking())
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
import java.util.Map;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
|
||||
/** Ark's explicit upstream thinking switch is independent of SDK returnThinking. */
|
||||
final class SeedThinkingParameters {
|
||||
private SeedThinkingParameters() {}
|
||||
static Map<String, Object> forRequest(String model, ChatRequest request) {
|
||||
boolean seed21 = model != null && (model.endsWith("doubao-seed-2.1-pro-260628") || model.endsWith("doubao-seed-2-1-pro-260628"));
|
||||
return seed21 && "none".equals(request.getReasoningEffort())
|
||||
? Map.of("thinking", Map.of("type", "disabled")) : Map.of();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user