fix(ai): 创意生成工作流失败时不再把原始报文吐给用户

Dify 以 workflow_finished + data.status=failed + outputs={} 收场时,后端只搬运
字段、从不读 status,把一个失败结果包成 success 下发,且把整包报文塞进 raw;
前端 formatCreativeResult 取不到业务字段就落到 text || stringifyUnknown(payload),
整段原始 JSON(含 workflow_id / task_id / req_id 与上游错误栈)进了聊天气泡。

- 新增 workflowStatus / isWorkflowFailed:失败时只回 status + 可读 answer,
  不下发 outputs / raw,不携带上游错误文本
- executeCreativeAssistant 的 workflow_finished 分支先判失败,命中则直接返回,
  不再用流式片段或 taskId 覆盖失败文案
- 失败报文顶层 answer 是 workflow_run_id(UUID),故失败文案用固定字符串,
  不取自 answer
- 新增 AiCreativeFailureResultTest:13 个用例,样本为 issue 原文的真实失败报文

Refs: .scratch/ai-assistant/issues/01-creative-assistant-raw-json-bubble.md
This commit is contained in:
2026-09-24 03:44:08 +08:00
parent cbbe3b87b4
commit 23a7f001cc
2 changed files with 280 additions and 1 deletions
@@ -53,6 +53,14 @@ public class AiController extends BaseController {
private static final Logger logger = LoggerFactory.getLogger(AiController.class);
private static final long AI_REQUEST_TIMEOUT = 10 * 60 * 1000L;
/**
* 创意生成工作流以失败收场时给用户看的文案。
*
* <p>上游的真实失败原因(如 {@code Model disabled})属于 Dify 侧配置问题,
* 既不面向用户也不该泄露;这里统一给一句可读提示。
*/
private static final String CREATIVE_FAILED_MESSAGE = "生成失败,请稍后重试";
@Resource
private WebSocketServer webSocketServer;
@Resource
@@ -847,6 +855,14 @@ public class AiController extends BaseController {
}
if ("message_end".equals(event) || "workflow_finished".equals(event)) {
Map<String, Object> result = buildCreativeAssistantResult(data, json);
if (isWorkflowFailed(stringValue(result.get("status")))) {
// 失败态:buildCreativeAssistantResult 已给好可读文案,
// 这里不得再用流式片段或 taskId 覆盖,也不下发 raw。
clearActiveAiTask(loginUserId, taskId);
logger.warn("creative assistant workflow failed, workflowRunId={}, taskId={}",
workflowRunId, taskId);
return success(result);
}
if (StrUtil.isBlank(stringValue(result.get("answer"))) && reply.length() > 0) {
result.put("answer", reply.toString());
}
@@ -1301,6 +1317,17 @@ public class AiController extends BaseController {
private Map<String, Object> buildCreativeAssistantResult(Map<String, Object> responseData, String responseBody) {
Map<String, Object> result = new LinkedHashMap<>();
String status = workflowStatus(responseData);
if (isWorkflowFailed(status)) {
// 工作流失败:只回可读文案与状态,不下发 outputs / raw / 上游错误栈。
// raw 里含 workflow_id / task_id / req_id 与上游异常文本,端上没有任何消费者,
// 一旦下发就会被前端兜底逻辑整段贴进聊天气泡(见 issue 01)。
result.put("status", status);
result.put("answer", CREATIVE_FAILED_MESSAGE);
return result;
}
if (responseData == null) {
result.put("answer", responseBody);
return result;
@@ -1326,7 +1353,8 @@ public class AiController extends BaseController {
Object data = responseData.get("data");
if (data instanceof Map) {
Object status = ((Map) data).get("status");
// status 已在方法开头经 workflowStatus 统一取出,
// 这里只负责下发(含顶层 status 的兜底情况)。
if (status != null) {
result.put("status", status);
}
@@ -1343,6 +1371,41 @@ public class AiController extends BaseController {
return result;
}
/**
* 取 Dify 工作流事件里的执行状态。
*
* <p>{@code workflow_finished} 事件把状态放在 {@code data.status};
* 少数事件会放在顶层,两处都看,取到即返回。
*
* @return 状态字符串;取不到时为 {@code null}
*/
private String workflowStatus(Map<String, Object> responseData) {
if (responseData == null) {
return null;
}
Object data = responseData.get("data");
if (data instanceof Map) {
Object status = ((Map) data).get("status");
if (status != null && StrUtil.isNotBlank(status.toString())) {
return status.toString();
}
}
Object status = responseData.get("status");
if (status != null && StrUtil.isNotBlank(status.toString())) {
return status.toString();
}
return null;
}
/**
* 判断工作流是否以失败收场。
*
* <p>只在拿到明确失败状态时为真;状态缺失视为未失败,避免误伤正常响应。
*/
private boolean isWorkflowFailed(String status) {
return "failed".equalsIgnoreCase(StrUtil.trimToEmpty(status));
}
private Map<String, Object> buildChatMessageResult(Map<String, Object> responseData, String responseBody) {
Map<String, Object> result = new LinkedHashMap<>();
if (responseData == null) {