diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiController.java b/src/main/java/com/gxwebsoft/ai/controller/AiController.java
index 478e63a..ef04980 100644
--- a/src/main/java/com/gxwebsoft/ai/controller/AiController.java
+++ b/src/main/java/com/gxwebsoft/ai/controller/AiController.java
@@ -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 = "生成服务繁忙,请稍后重试";
/**
- * 创意助手工作流因上游瞬时故障失败后的自动重试次数。
+ * 图片模式的提示词包装。
*
- *
一次出图要连调两次「即梦AI-图像生成」,单次约 1/5 概率撞上上游容量不足,
- * 不重试时整体失败率约三成(2026-10-13 实测:同一 prompt 连跑 6 次,2 次失败)。
- * 重试一次可把失败率压到约一成,代价是多等一次工作流(实测 45~105 秒),
- * 距离 {@link #AI_REQUEST_TIMEOUT} 还有充足余量。
+ *
端上把「用户选了图片模式」这个事实以 {@code mode=image} 传上来,但上游开始节点
+ * 只有 {@code user_content} 一个文本变量;用户的原话常常只是画面描述(如
+ * 「毕业季,图书馆门口,暖色」),在「问题分类器」里很容易被判成文案或脚本分支,
+ * 于是同一个需求有时出图、有时出一段文字。
*
- *
只对 {@link #isRetryableCreativeFailure(String)} 认定的瞬时故障重试;
- * 配置类失败重试无用,重试反而让用户白等一倍时间。
+ *
这里在用户原话前加一段**固定的意图声明**,把「这是生图任务」摆在分类器
+ * 最先看到的位置;用户原话原样保留,不替用户改写画面描述。
*/
- 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 }。
+ *
+ *
出图工作流的产出就是这种字符串(实测 {@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);
+
+ /**
+ * 一组图片最多取几张。
+ *
+ *
上游换过的出图节点一次给 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 {
}
}
+ /**
+ * 创意助手执行:**只跑一次**,不自动重试。
+ *
+ *
历史上这里对上游瞬时故障({@code 50500 no more instances to retry} 等)
+ * 重跑过一次,用来把出图整体失败率从约三成压到约一成。但出图工作流一次要等
+ * 45~105 秒,重试会把用户的一次失败拖成两次等待,且失败后没有任何界面能告诉
+ * 用户"还在重试"。现在失败即失败:直接给可读文案,由用户自己决定要不要再来一次。
+ *
+ *
失败以 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 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);
- }
-
- /**
- * 创意助手执行外壳:失败可重试时原样重跑一次工作流。
- *
- * 为什么需要它:出图分支要连调两次「即梦AI-图像生成」,而上游火山引擎会
- * 偶发 {@code 50500 no more instances to retry}(实例不足,3 秒内即拒绝),
- * 实测整体失败率约三成。这类失败与内容、参数、凭证都无关,重跑一次通常就成。
- *
- *
每次失败都以 ERROR 记录失败节点与上游错误原文:生产日志级别是
- * {@code com.gxwebsoft: ERROR},原先这里用 {@code logger.warn},于是出现
- * "端上说生成失败、后端日志里却什么都没有"的情况。
- *
- * @param inputs 已构造好的工作流入参
- * @param loginUserId 用户 id
- * @return 成功结果为工作流产出;重试耗尽仍失败时为失败态结果(HTTP 仍是 200,靠 status 表达)
- */
- private ApiResult> executeCreativeAssistantWithRetry(Map inputs, Integer loginUserId) {
- int maxAttempts = CREATIVE_ASSISTANT_MAX_RETRY + 1;
- Map 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 {
/**
* 一次创意助手工作流的执行结论。
*
- * {@code result} 是要下发端上的载荷;其余字段只用于记日志和决定是否重试,
+ *
{@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 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 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 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 inputs, Integer loginUserId)
throws IOException {
@@ -1345,12 +1346,14 @@ public class AiController extends BaseController {
}
/**
- * 上游实例不足(重试有实际收益的那一类)。
+ * 上游实例不足。
*
* 2026-10-13 内网实例实测:同一 prompt 连跑 6 次,2 次失败于
* 「即梦AI-图像生成」节点,原文为
* {@code {"code":50500,...,"status_code":110001,"status_message":"no more instances to retry"}},
* 整节点仅耗时 3.1~3.5 秒即返回——即上游没有可用算力而立即拒绝,与内容无关。
+ *
+ *
这只影响给用户的文案({@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");
}
- /**
- * 这次失败值不值得原样重跑。
- *
- *
只认能明确归因到上游瞬时的故障。凭证失效({@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 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 的形状漂移(不改上游报文,只做向上归一)。
+ *
+ * 2026-10-21 在 dev 实例实测:出图分支的终态是
+ * {@code {"image7": ["", ""]}}。
+ * 变量名({@code image7})和值的类型(数组)都不在端上白名单里,
+ * 于是四个页面全部显示「接口已返回,但未解析到可展示的内容」。
+ * 10-13 同一分支的产物还叫 {@code photo_message} / {@code photo_file},
+ * 节点叫「即梦AI-图像生成」;上游换过一轮节点,两端白名单就同时失效了。
+ *
+ *
与其把 {@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 normalizeCreativeOutputs(Map outputs) {
+ Map normalized = new LinkedHashMap<>(outputs);
+ List