diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiController.java b/src/main/java/com/gxwebsoft/ai/controller/AiController.java
index 273c1b7..61c9075 100644
--- a/src/main/java/com/gxwebsoft/ai/controller/AiController.java
+++ b/src/main/java/com/gxwebsoft/ai/controller/AiController.java
@@ -22,6 +22,7 @@ import com.gxwebsoft.ai.config.WebSocketServer;
import com.gxwebsoft.ai.constant.AiChatType;
import com.gxwebsoft.ai.entity.ChatMessage;
import com.gxwebsoft.ai.service.AiGenerationRecorder;
+import com.gxwebsoft.ai.service.CreativeMediaRelay;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.slf4j.Logger;
@@ -163,6 +164,28 @@ public class AiController extends BaseController {
"https?://[^\\s)]+\\.(?:png|jpe?g|gif|webp|bmp|svg)(?:\\?[^\\s)]*)?",
Pattern.CASE_INSENSITIVE);
+ /**
+ * markdown 链接语法:[文字](https://...)。
+ *
+ *
视频没法内联成图片,上游给视频结果时用的是链接形式
+ * ({@code [视频](https://.../a.mp4)}),与 {@link #MARKDOWN_IMAGE_PATTERN} 区分开。
+ */
+ private static final Pattern MARKDOWN_LINK_PATTERN =
+ Pattern.compile("\\[[^\\]]*\\]\\((https?://[^)\\s]+)\\)");
+
+ /**
+ * 裸视频 URL(按扩展名判定)。
+ *
+ *
{@code video_result} 里的视频地址可能直接以字符串给出;与图片同理,
+ * 签名参数里的长串会命中 {@link #LONG_TOKEN_PATTERN},视频地址必须单独认。
+ */
+ private static final Pattern VIDEO_URL_PATTERN = Pattern.compile(
+ "https?://[^\\s)]+\\.(?:mp4|mov|m4v|webm|avi|mkv|flv)(?:\\?[^\\s)]*)?",
+ Pattern.CASE_INSENSITIVE);
+
+ /** 正文/markdown 里抽取任意地址,用于判断「这段媒体链接是否已经写在正文里」。 */
+ private static final Pattern URL_IN_TEXT_PATTERN = Pattern.compile("https?://[^)\\s]+");
+
/**
* 一组图片最多取几张。
*
@@ -209,6 +232,8 @@ public class AiController extends BaseController {
private AiGenerationRecorder aiGenerationRecorder;
@Resource
private DifyProperties difyProperties;
+ @Resource
+ private CreativeMediaRelay creativeMediaRelay;
/**
* 用于把入参实体序列化成 Map,尊重 Jackson 注解
@@ -1517,7 +1542,7 @@ public class AiController extends BaseController {
String resolvedType = StrUtil.blankToDefault(chatType, resolveCreativeChatType(requestBody));
Map snapshot = params == null ? creativeParams(requestBody) : params;
Integer recordId = recordGeneration(loginUserId, resolvedType, userContent, snapshot,
- userContent, stringValue(run.result.get("answer")), null);
+ userContent, creativeReplyForHistory(run.result), null);
if (recordId != null) {
run.result.put("chatId", recordId);
}
@@ -2555,13 +2580,25 @@ public class AiController extends BaseController {
Map normalizedOutputs = outputs instanceof Map
? normalizeCreativeOutputs((Map) outputs)
: null;
+ // 归一之后、拼 answer 之前转存:归一已经给出统一的 {url,...} 元素,
+ // 在这里一处把上游地址换成我们自己的对外地址即可,四个页面无需改动。
+ relayMediaFiles(normalizedOutputs);
String answer = extractWorkflowOutputs(responseData);
if (answer == null) {
answer = extractAnswer(responseData);
}
+ if (normalizedOutputs != null && answerIsOnlyMediaUrls(answer, normalizedOutputs)) {
+ // 上游把 video_result 直接给成地址字符串时,findTextValue 会把它们当正文
+ // 拼进 answer(长签名地址绕过了「无空格长 token」判定),气泡里就是几条
+ // 几百字符的地址。这些地址本次都归一成了文件卡片,正文改用带文件名的
+ // markdown 链接,既不重复也不影响点开。
+ answer = mediaMarkdownFromOutputs(normalizedOutputs);
+ }
if (answer == null && normalizedOutputs != null) {
- answer = imageMarkdownFromOutputs(normalizedOutputs);
+ // 图片与视频都算可展示内容:视频分支常常只有 video_result,
+ // 没有文案字段,这里不兜底就落到「未解析到可展示的内容」。
+ answer = mediaMarkdownFromOutputs(normalizedOutputs);
}
if (answer != null) {
result.put("answer", answer);
@@ -2570,10 +2607,12 @@ public class AiController extends BaseController {
// 而这条路径过去在日志里**没有任何痕迹**——上游形状一变,端上就悄悄退化,
// 排查时只能靠猜。这里把 outputs 的键与图片条数记下来,
// 下一次出现时能直接区分「真没内容」还是「又换了字段名」(issue 19)。
- logger.warn("creative assistant returned no displayable answer, status={}, outputKeys={}, normalizedPhotoFiles={}",
+ logger.warn("creative assistant returned no displayable answer, status={}, outputKeys={}, "
+ + "normalizedPhotoFiles={}, normalizedVideoFiles={}",
status,
normalizedOutputs == null ? "<无 outputs>" : normalizedOutputs.keySet(),
- normalizedOutputs == null ? 0 : photoFileCount(normalizedOutputs.get("photo_files")));
+ normalizedOutputs == null ? 0 : fileCount(normalizedOutputs.get("photo_files")),
+ normalizedOutputs == null ? 0 : fileCount(normalizedOutputs.get("video_result")));
}
String workflowRunId = stringValue(responseData.get("workflow_run_id"));
@@ -2616,6 +2655,13 @@ public class AiController extends BaseController {
* 字符串或数组)都收进规范的 {@code photo_files},元素统一成 {@code {url,name,type}}。
* 端上四个页面认的正是 {@code photo_files},因此不必逐个页面加字段。
*
+ * 视频同理:{@code video_result} 是端上「视频生成结果」区块认的字段
+ * (四个页面都按 {@code video_result} 取文件),但上游给的元素可能是
+ * 字符串(裸地址 / markdown 链接)、也可能是文件对象,键名同样会漂移。
+ * 这里一并按内容收进规范的 {@code video_result},元素统一成
+ * {@code {url,name,type:video}} —— 端上的 {@code resolveFileUrl} 才取得到地址,
+ * 否则视频卡片既没有播放地址、复制链接也永远是空的。
+ *
* @param outputs 上游 outputs(不改动原对象,返回值是新 Map)
* @return 归一后的 outputs
*/
@@ -2626,10 +2672,17 @@ public class AiController extends BaseController {
collectPhotoFiles(outputs.get("photo_files"), photoFiles, knownUrls);
collectPhotoFiles(outputs.get("photo_file"), photoFiles, knownUrls);
- // 其余变量按内容兜底:老字段(photo_*)已收过的 URL 不重复收。
+ List