feat(ai): 创意助手出图链路收口——去重试、图片模式包装提示词、输出形状兼容(issue 19)
- 删除 CREATIVE_ASSISTANT_MAX_RETRY / executeCreativeAssistantWithRetry /
isRetryableCreativeFailure,失败即失败;日志仍带 failedNode 与上游 error。
- 图片模式(mode=image)把 user_content 包装成「【任务类型】生成图片…」意图声明,
避免上游「问题分类器」把画面描述判成文案/脚本分支;不带 mode 的入口保持原话直送。
- outputs 形状兼容:image7 这类数组里的 markdown 图片按内容归一到 photo_files
({url,name,type}),findTextValue 支持 List 中的图片字符串;raw 保持上游原样。
- 测试:新增 AiCreativeNoRetryIntegrationTest(2)、AiCreativeImagePromptTest(4)、
AiCreativeImageOutputTest(5),删除两个重试用例;全量 148 跑 0 failures。
This commit is contained in:
@@ -42,8 +42,10 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@@ -76,17 +78,20 @@ public class AiController extends BaseController {
|
||||
private static final String CREATIVE_BUSY_MESSAGE = "生成服务繁忙,请稍后重试";
|
||||
|
||||
/**
|
||||
* 创意助手工作流因上游瞬时故障失败后的自动重试次数。
|
||||
* 图片模式的提示词包装。
|
||||
*
|
||||
* <p>一次出图要连调两次「即梦AI-图像生成」,单次约 1/5 概率撞上上游容量不足,
|
||||
* 不重试时整体失败率约三成(2026-10-13 实测:同一 prompt 连跑 6 次,2 次失败)。
|
||||
* 重试一次可把失败率压到约一成,代价是多等一次工作流(实测 45~105 秒),
|
||||
* 距离 {@link #AI_REQUEST_TIMEOUT} 还有充足余量。
|
||||
* <p>端上把「用户选了图片模式」这个事实以 {@code mode=image} 传上来,但上游开始节点
|
||||
* 只有 {@code user_content} 一个文本变量;用户的原话常常只是画面描述(如
|
||||
* 「毕业季,图书馆门口,暖色」),在「问题分类器」里很容易被判成文案或脚本分支,
|
||||
* 于是同一个需求有时出图、有时出一段文字。
|
||||
*
|
||||
* <p>只对 {@link #isRetryableCreativeFailure(String)} 认定的瞬时故障重试;
|
||||
* 配置类失败重试无用,重试反而让用户白等一倍时间。
|
||||
* <p>这里在用户原话前加一段**固定的意图声明**,把「这是生图任务」摆在分类器
|
||||
* 最先看到的位置;用户原话原样保留,不替用户改写画面描述。
|
||||
*/
|
||||
private static final int CREATIVE_ASSISTANT_MAX_RETRY = 1;
|
||||
private static final String CREATIVE_IMAGE_PROMPT_TEMPLATE =
|
||||
"【任务类型】生成图片\n"
|
||||
+ "【用户需求】%s\n"
|
||||
+ "【输出要求】直接生成图片,不要输出文案、脚本或视频。";
|
||||
|
||||
/**
|
||||
* 写进日志的上游错误原文长度上限。
|
||||
@@ -110,6 +115,31 @@ public class AiController extends BaseController {
|
||||
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s");
|
||||
|
||||
/**
|
||||
* markdown 图片语法:{@code }。
|
||||
*
|
||||
* <p>出图工作流的产出就是这种字符串(实测 {@code image7} 里两个元素都是
|
||||
* {@code })。
|
||||
*/
|
||||
private static final Pattern MARKDOWN_IMAGE_PATTERN =
|
||||
Pattern.compile("!\\[[^\\]]*\\]\\((https?://[^)\\s]+)\\)");
|
||||
|
||||
/**
|
||||
* 裸图片 URL(按扩展名判定)。签名参数里的长串会误伤
|
||||
* {@link #LONG_TOKEN_PATTERN},图片地址必须单独认。
|
||||
*/
|
||||
private static final Pattern IMAGE_URL_PATTERN = Pattern.compile(
|
||||
"https?://[^\\s)]+\\.(?:png|jpe?g|gif|webp|bmp|svg)(?:\\?[^\\s)]*)?",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
/**
|
||||
* 一组图片最多取几张。
|
||||
*
|
||||
* <p>上游换过的出图节点一次给 2 张;给数组的条数设上限,避免某个把整批素材
|
||||
* 都塞进 outputs 的工作流把聊天气泡撑成图片墙。
|
||||
*/
|
||||
private static final int CREATIVE_IMAGE_LIST_MAX_ITEMS = 12;
|
||||
|
||||
/**
|
||||
* loading 气泡文案的长度上限。
|
||||
*
|
||||
@@ -474,7 +504,7 @@ public class AiController extends BaseController {
|
||||
if (isWorkflowFailed(workflowStatus(data))) {
|
||||
// 用 error 而非 warn:生产日志级别是 com.gxwebsoft: ERROR,
|
||||
// warn 会被整个吞掉,就成了「端上说生成失败、后端日志里什么都没有」
|
||||
// (同 executeCreativeAssistantWithRetry 上方的注释)。
|
||||
// (同 executeCreativeAssistant 上方的注释)。
|
||||
logger.error("manuscript workflow failed, taskId={}, workflowRunId={}, {}",
|
||||
taskId, workflowRunId, failure.describe());
|
||||
workflowFailed = true;
|
||||
@@ -1047,69 +1077,43 @@ public class AiController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创意助手执行:**只跑一次**,不自动重试。
|
||||
*
|
||||
* <p>历史上这里对上游瞬时故障({@code 50500 no more instances to retry} 等)
|
||||
* 重跑过一次,用来把出图整体失败率从约三成压到约一成。但出图工作流一次要等
|
||||
* 45~105 秒,重试会把用户的一次失败拖成两次等待,且失败后没有任何界面能告诉
|
||||
* 用户"还在重试"。现在失败即失败:直接给可读文案,由用户自己决定要不要再来一次。
|
||||
*
|
||||
* <p>失败以 ERROR 记录失败节点与上游错误原文:生产日志级别是
|
||||
* {@code com.gxwebsoft: ERROR},用 {@code logger.warn} 会被整个吞掉,
|
||||
* 就成了"端上说生成失败、后端日志里却什么都没有"。
|
||||
*
|
||||
* @param requestBody 端上请求体({@code user_content} / {@code user_files} / {@code mode})
|
||||
* @param loginUserId 用户 id
|
||||
* @return 成功结果为工作流产出;失败时为失败态结果(HTTP 仍是 200,靠 status 表达)
|
||||
*/
|
||||
private ApiResult<?> executeCreativeAssistant(Map<String, Object> requestBody, Integer loginUserId) {
|
||||
String userContent = stringValue(requestBody == null ? null : requestBody.get("user_content"));
|
||||
if (StrUtil.isBlank(userContent)) {
|
||||
return fail("user_content不能为空");
|
||||
}
|
||||
return executeCreativeAssistantWithRetry(buildCreativeAssistantInputs(requestBody), loginUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创意助手执行外壳:失败可重试时原样重跑一次工作流。
|
||||
*
|
||||
* <p>为什么需要它:出图分支要连调两次「即梦AI-图像生成」,而上游火山引擎会
|
||||
* 偶发 {@code 50500 no more instances to retry}(实例不足,3 秒内即拒绝),
|
||||
* 实测整体失败率约三成。这类失败与内容、参数、凭证都无关,重跑一次通常就成。
|
||||
*
|
||||
* <p>每次失败都以 ERROR 记录失败节点与上游错误原文:生产日志级别是
|
||||
* {@code com.gxwebsoft: ERROR},原先这里用 {@code logger.warn},于是出现
|
||||
* "端上说生成失败、后端日志里却什么都没有"的情况。
|
||||
*
|
||||
* @param inputs 已构造好的工作流入参
|
||||
* @param loginUserId 用户 id
|
||||
* @return 成功结果为工作流产出;重试耗尽仍失败时为失败态结果(HTTP 仍是 200,靠 status 表达)
|
||||
*/
|
||||
private ApiResult<?> executeCreativeAssistantWithRetry(Map<String, Object> inputs, Integer loginUserId) {
|
||||
int maxAttempts = CREATIVE_ASSISTANT_MAX_RETRY + 1;
|
||||
Map<String, Object> failedResult = null;
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
CreativeRun run;
|
||||
try {
|
||||
run = runCreativeAssistantOnce(inputs, loginUserId);
|
||||
} catch (CreativeApiFailureException e) {
|
||||
logger.error("creative assistant request rejected, attempt={}/{}, message={}",
|
||||
attempt, maxAttempts, e.getMessage());
|
||||
return e.result;
|
||||
} catch (Exception e) {
|
||||
logger.error("creative assistant execute failed, attempt={}/{}", attempt, maxAttempts, e);
|
||||
if (attempt < maxAttempts) {
|
||||
continue;
|
||||
}
|
||||
return fail("出错了,请晚点再试~");
|
||||
}
|
||||
|
||||
if (!run.failed) {
|
||||
if (attempt > 1) {
|
||||
logger.warn("creative assistant recovered after retry, attempt={}/{}, workflowRunId={}, taskId={}",
|
||||
attempt, maxAttempts, run.workflowRunId, run.taskId);
|
||||
}
|
||||
return success(run.result);
|
||||
}
|
||||
|
||||
logger.error("creative assistant workflow failed, attempt={}/{}, workflowRunId={}, taskId={}, "
|
||||
+ "failedNode={}, retryable={}, upstreamError={}",
|
||||
attempt, maxAttempts, run.workflowRunId, run.taskId,
|
||||
run.failedNode, run.retryable, run.failureReason);
|
||||
failedResult = run.result;
|
||||
|
||||
if (!run.retryable || attempt >= maxAttempts) {
|
||||
break;
|
||||
}
|
||||
logger.warn("creative assistant retrying after upstream failure, nextAttempt={}/{}, failedNode={}",
|
||||
attempt + 1, maxAttempts, run.failedNode);
|
||||
CreativeRun run;
|
||||
try {
|
||||
run = runCreativeAssistantOnce(buildCreativeAssistantInputs(requestBody), loginUserId);
|
||||
} catch (CreativeApiFailureException e) {
|
||||
logger.error("creative assistant request rejected, message={}", e.getMessage());
|
||||
return e.result;
|
||||
} catch (Exception e) {
|
||||
logger.error("creative assistant execute failed", e);
|
||||
return fail("出错了,请晚点再试~");
|
||||
}
|
||||
return success(failedResult);
|
||||
if (run.failed) {
|
||||
logger.error("creative assistant workflow failed, workflowRunId={}, taskId={}, "
|
||||
+ "failedNode={}, upstreamError={}",
|
||||
run.workflowRunId, run.taskId, run.failedNode, run.failureReason);
|
||||
}
|
||||
return success(run.result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1132,7 +1136,7 @@ public class AiController extends BaseController {
|
||||
/**
|
||||
* 一次创意助手工作流的执行结论。
|
||||
*
|
||||
* <p>{@code result} 是要下发端上的载荷;其余字段只用于记日志和决定是否重试,
|
||||
* <p>{@code result} 是要下发端上的载荷;其余字段只用于记日志,
|
||||
* 绝不可混进 {@code result}——失败态一旦带出上游原文,就会泄露
|
||||
* {@code workflow_id} / {@code req_id} 等内部标识符(见 issue 01)。
|
||||
*/
|
||||
@@ -1143,32 +1147,29 @@ public class AiController extends BaseController {
|
||||
private final String failedNode;
|
||||
private final String failureReason;
|
||||
private final boolean failed;
|
||||
private final boolean retryable;
|
||||
|
||||
private CreativeRun(Map<String, Object> result, String workflowRunId, String taskId,
|
||||
String failedNode, String failureReason, boolean failed, boolean retryable) {
|
||||
String failedNode, String failureReason, boolean failed) {
|
||||
this.result = result;
|
||||
this.workflowRunId = workflowRunId;
|
||||
this.taskId = taskId;
|
||||
this.failedNode = failedNode;
|
||||
this.failureReason = briefUpstreamError(failureReason);
|
||||
this.failed = failed;
|
||||
this.retryable = retryable;
|
||||
}
|
||||
|
||||
private static CreativeRun succeeded(Map<String, Object> result, String workflowRunId, String taskId) {
|
||||
return new CreativeRun(result, workflowRunId, taskId, null, null, false, false);
|
||||
return new CreativeRun(result, workflowRunId, taskId, null, null, false);
|
||||
}
|
||||
|
||||
private static CreativeRun failed(Map<String, Object> result, String workflowRunId, String taskId,
|
||||
String failedNode, String failureReason) {
|
||||
return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true,
|
||||
isRetryableCreativeFailure(failureReason));
|
||||
return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一次创意助手工作流(单次尝试,不含重试)。
|
||||
* 跑一次创意助手工作流(单次尝试,失败即返回)。
|
||||
*/
|
||||
private CreativeRun runCreativeAssistantOnce(Map<String, Object> inputs, Integer loginUserId)
|
||||
throws IOException {
|
||||
@@ -1345,12 +1346,14 @@ public class AiController extends BaseController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游实例不足(重试有实际收益的那一类)。
|
||||
* 上游实例不足。
|
||||
*
|
||||
* <p>2026-10-13 内网实例实测:同一 prompt 连跑 6 次,2 次失败于
|
||||
* 「即梦AI-图像生成」节点,原文为
|
||||
* {@code {"code":50500,...,"status_code":110001,"status_message":"no more instances to retry"}},
|
||||
* 整节点仅耗时 3.1~3.5 秒即返回——即上游没有可用算力而立即拒绝,与内容无关。
|
||||
*
|
||||
* <p>这只影响给用户的文案({@link #CREATIVE_BUSY_MESSAGE}),不再触发自动重试。
|
||||
*/
|
||||
private static boolean isUpstreamCapacityFailure(String reason) {
|
||||
if (StrUtil.isBlank(reason)) {
|
||||
@@ -1362,28 +1365,6 @@ public class AiController extends BaseController {
|
||||
|| lower.contains("code: 50500");
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次失败值不值得原样重跑。
|
||||
*
|
||||
* <p>只认能明确归因到上游瞬时的故障。凭证失效({@code 50400 Access Denied})、
|
||||
* 模型被停用({@code Model disabled})、内容审核拒绝重试无用,
|
||||
* 重试只会把用户的一次失败拖成两次等待,必须排除。
|
||||
*/
|
||||
private static boolean isRetryableCreativeFailure(String reason) {
|
||||
if (isUpstreamCapacityFailure(reason)) {
|
||||
return true;
|
||||
}
|
||||
if (StrUtil.isBlank(reason)) {
|
||||
return false;
|
||||
}
|
||||
String lower = reason.toLowerCase(Locale.ROOT);
|
||||
return lower.contains("502 bad gateway")
|
||||
|| lower.contains("503 service unavailable")
|
||||
|| lower.contains("504 gateway")
|
||||
|| lower.contains("connection reset")
|
||||
|| lower.contains("timed out");
|
||||
}
|
||||
|
||||
/**
|
||||
* 把上游原文裁到可写进单行日志的长度。
|
||||
*/
|
||||
@@ -1757,7 +1738,12 @@ public class AiController extends BaseController {
|
||||
}
|
||||
String userContent = stringValue(requestBody.get("user_content"));
|
||||
if (StrUtil.isNotBlank(userContent)) {
|
||||
inputs.put("user_content", userContent);
|
||||
// mode=image 由端上在选择「图片」模式时带上;单页表单入口不带 mode,
|
||||
// 保持原话直送(那类页面由用户自己写清指令)。
|
||||
boolean imageMode = "image".equalsIgnoreCase(stringValue(requestBody.get("mode")));
|
||||
inputs.put("user_content", imageMode
|
||||
? String.format(CREATIVE_IMAGE_PROMPT_TEMPLATE, userContent)
|
||||
: userContent);
|
||||
}
|
||||
Object rawUserFiles = requestBody.get("user_files");
|
||||
if (hasFileValue(rawUserFiles)) {
|
||||
@@ -2094,10 +2080,22 @@ public class AiController extends BaseController {
|
||||
return result;
|
||||
}
|
||||
|
||||
Object data = responseData.get("data");
|
||||
Object outputs = data instanceof Map ? ((Map) data).get("outputs") : null;
|
||||
if (outputs == null) {
|
||||
outputs = responseData.get("outputs");
|
||||
}
|
||||
Map<String, Object> normalizedOutputs = outputs instanceof Map
|
||||
? normalizeCreativeOutputs((Map) outputs)
|
||||
: null;
|
||||
|
||||
String answer = extractWorkflowOutputs(responseData);
|
||||
if (answer == null) {
|
||||
answer = extractAnswer(responseData);
|
||||
}
|
||||
if (answer == null && normalizedOutputs != null) {
|
||||
answer = imageMarkdownFromOutputs(normalizedOutputs);
|
||||
}
|
||||
if (answer != null) {
|
||||
result.put("answer", answer);
|
||||
}
|
||||
@@ -2112,26 +2110,210 @@ public class AiController extends BaseController {
|
||||
result.put("taskId", taskId);
|
||||
}
|
||||
|
||||
Object data = responseData.get("data");
|
||||
if (data instanceof Map) {
|
||||
// status 已在方法开头经 workflowStatus 统一取出,
|
||||
// 这里只负责下发(含顶层 status 的兜底情况)。
|
||||
if (status != null) {
|
||||
result.put("status", status);
|
||||
}
|
||||
Object outputs = ((Map) data).get("outputs");
|
||||
if (outputs != null) {
|
||||
result.put("outputs", outputs);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.containsKey("outputs") && responseData.get("outputs") != null) {
|
||||
result.put("outputs", responseData.get("outputs"));
|
||||
if (normalizedOutputs != null) {
|
||||
result.put("outputs", normalizedOutputs);
|
||||
}
|
||||
result.put("raw", responseData);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容上游 outputs 的形状漂移(不改上游报文,只做向上归一)。
|
||||
*
|
||||
* <p>2026-10-21 在 dev 实例实测:出图分支的终态是
|
||||
* {@code {"image7": ["", ""]}}。
|
||||
* 变量名({@code image7})和值的类型(数组)都不在端上白名单里,
|
||||
* 于是四个页面全部显示「接口已返回,但未解析到可展示的内容」。
|
||||
* 10-13 同一分支的产物还叫 {@code photo_message} / {@code photo_file},
|
||||
* 节点叫「即梦AI-图像生成」;上游换过一轮节点,两端白名单就同时失效了。
|
||||
*
|
||||
* <p>与其把 {@code image7} 写进白名单(下次改名叫 {@code image8} 又要再来一遍),
|
||||
* 不如按**内容**识别:任何 outputs 变量里出现的图片(markdown 图片语法或图片 URL,
|
||||
* 字符串或数组)都收进规范的 {@code photo_files},元素统一成 {@code {url,name,type}}。
|
||||
* 端上四个页面认的正是 {@code photo_files},因此不必逐个页面加字段。
|
||||
*
|
||||
* @param outputs 上游 outputs(不改动原对象,返回值是新 Map)
|
||||
* @return 归一后的 outputs
|
||||
*/
|
||||
private Map<String, Object> normalizeCreativeOutputs(Map<String, Object> outputs) {
|
||||
Map<String, Object> normalized = new LinkedHashMap<>(outputs);
|
||||
List<Object> photoFiles = new ArrayList<>();
|
||||
Set<String> knownUrls = new LinkedHashSet<>();
|
||||
collectPhotoFiles(outputs.get("photo_files"), photoFiles, knownUrls);
|
||||
collectPhotoFiles(outputs.get("photo_file"), photoFiles, knownUrls);
|
||||
|
||||
// 其余变量按内容兜底:老字段(photo_*)已收过的 URL 不重复收。
|
||||
for (Map.Entry<String, Object> entry : outputs.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
if ("photo_files".equals(key) || "photo_file".equals(key)) {
|
||||
continue;
|
||||
}
|
||||
for (String url : extractImageUrls(entry.getValue())) {
|
||||
if (knownUrls.add(url)) {
|
||||
photoFiles.add(imageFileEntry(url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!photoFiles.isEmpty()) {
|
||||
normalized.put("photo_files", photoFiles);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 {@code photo_files} / {@code photo_file} 收进统一列表。
|
||||
*
|
||||
* <p>上游两种写法都出现过:元素是文件对象({@code {url,name,...}}),
|
||||
* 或直接是 markdown 图片 / 图片 URL 字符串。字符串统一转成文件对象,
|
||||
* 端上的 {@code getCreativeFileUrl(item)} 才取得到地址。
|
||||
*/
|
||||
private void collectPhotoFiles(Object raw, List<Object> target, Set<String> knownUrls) {
|
||||
if (raw == null) {
|
||||
return;
|
||||
}
|
||||
List<?> items = raw instanceof List ? (List<?>) raw : Collections.singletonList(raw);
|
||||
for (Object item : items) {
|
||||
if (item instanceof Map) {
|
||||
Map<?, ?> file = (Map<?, ?>) item;
|
||||
String url = creativeFileUrl(file);
|
||||
if (StrUtil.isBlank(url)) {
|
||||
continue;
|
||||
}
|
||||
target.add(file);
|
||||
knownUrls.add(url);
|
||||
continue;
|
||||
}
|
||||
for (String url : extractImageUrls(item)) {
|
||||
if (knownUrls.add(url)) {
|
||||
target.add(imageFileEntry(url));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一个任意形状的 outputs 值里抽出图片地址(保持出现顺序、允许重复)。
|
||||
*/
|
||||
private List<String> extractImageUrls(Object value) {
|
||||
List<String> urls = new ArrayList<>();
|
||||
collectImageUrls(value, urls);
|
||||
return urls;
|
||||
}
|
||||
|
||||
private void collectImageUrls(Object value, List<String> urls) {
|
||||
if (value == null || urls.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) {
|
||||
return;
|
||||
}
|
||||
if (value instanceof String) {
|
||||
String text = ((String) value).trim();
|
||||
if (text.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Matcher markdown = MARKDOWN_IMAGE_PATTERN.matcher(text);
|
||||
boolean found = false;
|
||||
while (markdown.find() && urls.size() < CREATIVE_IMAGE_LIST_MAX_ITEMS) {
|
||||
urls.add(markdown.group(1));
|
||||
found = true;
|
||||
}
|
||||
if (!found && IMAGE_URL_PATTERN.matcher(text).matches()) {
|
||||
urls.add(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value instanceof List) {
|
||||
for (Object item : (List) value) {
|
||||
collectImageUrls(item, urls);
|
||||
if (urls.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
// 嵌套结构(如 files/json)里只认能按扩展名判定为图片的字符串,避免把
|
||||
// 文档/视频地址误当图片;键名不做猜测。
|
||||
for (Object item : ((Map) value).values()) {
|
||||
if (item instanceof String) {
|
||||
String text = ((String) item).trim();
|
||||
if (IMAGE_URL_PATTERN.matcher(text).matches()) {
|
||||
urls.add(text);
|
||||
}
|
||||
} else if (item instanceof List) {
|
||||
collectImageUrls(item, urls);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取文件对象的可用地址,与端上的 {@code getCreativeFileUrl} 同一口径。
|
||||
*/
|
||||
private String creativeFileUrl(Map<?, ?> file) {
|
||||
if (file == null) {
|
||||
return "";
|
||||
}
|
||||
String[] keys = new String[]{"url", "remote_url", "downloadUrl", "download_url", "path"};
|
||||
for (String key : keys) {
|
||||
String value = stringValue(file.get(key));
|
||||
if (StrUtil.isNotBlank(value)) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private Map<String, Object> imageFileEntry(String url) {
|
||||
Map<String, Object> file = new LinkedHashMap<>();
|
||||
file.put("url", url);
|
||||
file.put("name", fileNameFromUrl(url));
|
||||
file.put("type", "image");
|
||||
return file;
|
||||
}
|
||||
|
||||
private String fileNameFromUrl(String url) {
|
||||
String path = url == null ? "" : url.trim();
|
||||
int query = path.indexOf('?');
|
||||
if (query >= 0) {
|
||||
path = path.substring(0, query);
|
||||
}
|
||||
int slash = path.lastIndexOf('/');
|
||||
String name = slash >= 0 ? path.substring(slash + 1) : path;
|
||||
return StrUtil.isBlank(name) ? "生成图片" : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一后的 {@code photo_files} 转成 markdown 图片段落,作为 answer 的最后兜底。
|
||||
*
|
||||
* <p>{@code formatCreativeResult} 虽然会自己渲染图片,但历史会话/其他调用方
|
||||
* 可能只看 {@code answer};这里保证"有图就一定有一段能渲染的正文"。
|
||||
*/
|
||||
private String imageMarkdownFromOutputs(Map<String, Object> outputs) {
|
||||
Object raw = outputs.get("photo_files");
|
||||
if (!(raw instanceof List)) {
|
||||
return null;
|
||||
}
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (Object item : (List) raw) {
|
||||
String url = item instanceof Map ? creativeFileUrl((Map<?, ?>) item) : "";
|
||||
if (StrUtil.isNotBlank(url)) {
|
||||
lines.add("");
|
||||
}
|
||||
if (lines.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lines.isEmpty() ? null : String.join("\n\n", lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 Dify 工作流事件里的执行状态。
|
||||
*
|
||||
@@ -2530,8 +2712,9 @@ public class AiController extends BaseController {
|
||||
return preferred;
|
||||
}
|
||||
for (Object value : source.values()) {
|
||||
if (isDisplayableText(value)) {
|
||||
return (String) value;
|
||||
String text = findDisplayableValueText(value);
|
||||
if (text != null) {
|
||||
return text;
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
String nestedValue = findTextValue((Map) value);
|
||||
@@ -2540,11 +2723,65 @@ public class AiController extends BaseController {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 关键:这里的「任意 String 值」兜底**必须**过 isDisplayableText。
|
||||
// 关键:这里的「任意 String 值」兜底**必须**过可展示判定。
|
||||
// 修复前它无条件返回第一个 String,正是把 data.id(UUID)当正文的放大器(见 issue 06)。
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个 outputs 值的取文。
|
||||
*
|
||||
* <p>除了 String(过可展示判定),还要认 **List**:上游出图节点把一组图片
|
||||
* 直接放进一个数组(实测 {@code image7: ["", ""]}),
|
||||
* 原来的实现只认 String / Map,List 被静默跳过,出图结果就整段丢了。
|
||||
*
|
||||
* <p>List 里**只取字符串元素**,不递归进 Map:{@code video_result} /
|
||||
* {@code photo_files} 这类列表装的是文件对象,递归进去会把文件 URL 当正文,
|
||||
* 挤掉真正的脚本/文案字段。
|
||||
*/
|
||||
private String findDisplayableValueText(Object value) {
|
||||
if (isDisplayableText(value)) {
|
||||
return (String) value;
|
||||
}
|
||||
if (value instanceof List) {
|
||||
List<String> texts = new ArrayList<>();
|
||||
for (Object item : (List) value) {
|
||||
if (texts.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) {
|
||||
break;
|
||||
}
|
||||
if (isDisplayableImageText(item)) {
|
||||
texts.add(((String) item).trim());
|
||||
}
|
||||
}
|
||||
if (!texts.isEmpty()) {
|
||||
return String.join("\n\n", texts);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数组元素里的图片字符串也属于可展示内容。
|
||||
*
|
||||
* <p>markdown 图片({@code })没有空格,且长签名 URL 会命中
|
||||
* {@link #LONG_TOKEN_PATTERN} 的「无空格长 token」拦截;但它是用户要看的产物本身,
|
||||
* 必须放行,否则「列表支持」等于白做。
|
||||
*/
|
||||
private boolean isDisplayableImageText(Object value) {
|
||||
if (!(value instanceof String)) {
|
||||
return false;
|
||||
}
|
||||
String text = ((String) value).trim();
|
||||
if (text.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (isDisplayableText(text)) {
|
||||
return true;
|
||||
}
|
||||
return MARKDOWN_IMAGE_PATTERN.matcher(text).find()
|
||||
|| IMAGE_URL_PATTERN.matcher(text).matches();
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user