Files
tuanwei-java/src/main/java/com/gxwebsoft/ai/controller/AiController.java
T
weicw-dshsa f54e28cffc fix(ai): 失败态在返回体里带 status=failed,前端才能区分失败文案与正文(issue 10)
上游工作流 failed 时,后端把 reply 置为失败文案("生成失败,请稍后重试")
并照常走成功路径返回(HTTP 200 / code=0),而返回体里**没有 status**:

    data = {"answer":"生成失败,请稍后重试", "conversationId":"…", "messageId":"…"}

两端前端的 isFailedPayload(读 payload.status / payload.data.status)因此
永远落空,只能把失败文案当正文渲染(实测三个 admin 页面都会这样)。
修 issue 09 时上游每次都 failed,这条是必现的。

改动:
- buildManuscriptResult 增加 failed 参数(保留三参重载,等价 failed=false,
  以免破坏 issue 08 既有测试的调用形态),失败时 put("status","failed")。
- 新增 buildActivityOutlineResult(同构),activityOutlineGen 收尾改用之。
- 两个方法都在外层作用域记录 workflowFailed:失败分支里有 continue,
  不能用分支内的局部布尔。

刻意不改的东西:
- 失败仍走 HTTP 200 / code=0。改成 code!=0 会与既有约定冲突、影响所有调用方
  的 catch 分支,本条只加一个字段即可让两端现有判定生效。
- answer 键在失败态下依然存在(issue 08 契约),只是现在有了 status 可区分。

测试:新增 AiWorkflowFailureHttpStatusTest 8/8,含反向用例——
成功态**不得**带 status(否则 isFailedPayload 会误伤正常结果),
以及「成功态正文恰好是失败文案」时唯一判据是 status。
全量 210 跑 0 失败(2 个 ServerContainer not available 为预存问题)。

验证:用桩上游(probe/stub-dify-failure.py,回 workflow_finished{status:failed})
起第二个后端实例,实测两条链路的返回体都带 status='failed' 且
answer 仍为可读文案,前端 isFailedPayload 判定为 true —— 全链路闭合。
2026-10-13 02:12:46 +08:00

2211 lines
104 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.gxwebsoft.ai.controller;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.ai.config.DifyProperties;
import com.gxwebsoft.ai.entity.ActivityOutlineInputs;
import com.gxwebsoft.ai.entity.AiChatHistory;
import com.gxwebsoft.ai.entity.AiChatList;
import com.gxwebsoft.ai.entity.DeclarationInputs;
import com.gxwebsoft.ai.entity.DocumentProcessInputs;
import com.gxwebsoft.ai.entity.ManuscriptGenInputs;
import com.gxwebsoft.ai.service.AiChatHistoryService;
import com.gxwebsoft.ai.service.AiChatListService;
import com.gxwebsoft.common.core.utils.JSONUtil;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.ai.config.WebSocketServer;
import com.gxwebsoft.ai.entity.ChatMessage;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.*;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Api(tags = "AI")
@RestController
@RequestMapping("/api/ai")
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 = "生成失败,请稍后重试";
/**
* 内部标识符形状:纯 UUID({@code workflow_run_id} / 节点 id 等)。
* 与前端 {@code ai-result.js} 的 {@code isDisplayableAnswer} 同一口径。
*/
private static final Pattern UUID_PATTERN = Pattern.compile(
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
/**
* 无空格的长 token({@code task_id} / {@code req_id} 等)。
*/
private static final Pattern LONG_TOKEN_PATTERN = Pattern.compile("[0-9a-zA-Z_-]{32,}");
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s");
/**
* loading 气泡文案的长度上限。
*
* <p>「加载中」气泡位置只够显示一句短提示;{@code agent_thought} 的思维链
* 实测可达数 KB,整段塞进去等于把思维链推给端上,故超限即弃用。
*/
private static final int LOADING_TEXT_MAX_LENGTH = 120;
/**
* 从「活动资金」的自由文本里抽金额。
*
* <p>两个捕获组:数值(容忍千分位逗号与小数)+ 可选的中文倍率单位。
* 单位前用 {@code \s*} 容忍「1 万元」这类写法 —— 只认紧邻的「万」会把 1 万元抽成 1 元。
*/
private static final Pattern EVENT_FUNDING_AMOUNT_PATTERN =
Pattern.compile("(\\d[\\d,]*(?:\\.\\d+)?)\\s*([亿万千百]?)");
/**
* 日期/时间表达式,用于在抽金额前剔除。
*
* <p>{@code "2026年4月办活动,预算5000元"} 里 2026 会被金额正则当成第一处数字,
* 静默发出错误金额 —— 比留空更糟,故先整体剔除。
*/
private static final Pattern EVENT_FUNDING_DATE_PATTERN = Pattern.compile(
"\\d[\\d,]*\\s*(?:年|月|日|号|时|点|分|秒)"
+ "|\\d{4}\\s*[-/.]\\s*\\d{1,2}\\s*[-/.]\\s*\\d{1,2}"
+ "|\\d{1,2}\\s*[-/.]\\s*\\d{1,2}\\s*[-/.]\\s*\\d{2,4}");
@Resource
private WebSocketServer webSocketServer;
@Resource
private AiChatHistoryService aiChatHistoryService;
@Resource
private AiChatListService aiChatListService;
@Resource
private DifyProperties difyProperties;
/**
* 用于把入参实体序列化成 Map,尊重 Jackson 注解
*/
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Dify 接口地址前缀,取自 {@code dify.base-url}
*/
private String aiUrl() {
return difyProperties.getBaseUrl();
}
/**
* 各应用 API Key,取自 {@code dify.apps.*}。
*
* <p>原先这些 Key 硬编码在字段上,现统一外置到配置;取 Key 一律走这里,
* 便于换环境时只动 yml。
*/
private DifyProperties.AppKeys appKeys() {
return difyProperties.getApps();
}
/**
* 单次请求超时(毫秒)
*/
private int aiTimeout() {
Integer timeout = difyProperties.getTimeoutMs();
return timeout == null || timeout <= 0 ? 600000 : timeout;
}
private final Map<String, ActiveAiTask> activeAiTaskMap = new ConcurrentHashMap<>();
@PostMapping("/chat/message")
public ApiResult<?> message(@RequestBody ChatMessage message) throws IOException {
try {
URL url = new URL(aiUrl() + "/chat-messages");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Authorization", "Bearer " + appKeys().getChat());
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "text/event-stream");
connection.setDoOutput(true);
connection.setConnectTimeout(aiTimeout());
connection.setReadTimeout(aiTimeout());
Integer chatId = message.getChatId();
if (chatId == null || chatId == 0) {
AiChatList aiChatList = new AiChatList();
aiChatList.setUserId(getLoginUserId());
aiChatList.setTitle(message.getQuery());
aiChatListService.save(aiChatList);
chatId = aiChatList.getId();
}
Map<String, Object> payload = new HashMap<>();
payload.put("query", message.getQuery());
payload.put("response_mode", StrUtil.blankToDefault(message.getResponseMode(), "streaming"));
payload.put("user", getLoginUserId() == null ? "0" : getLoginUserId().toString());
payload.put("conversation_id", StrUtil.blankToDefault(message.getConversationId(), ""));
payload.put("inputs", parseInputs(message.getInputs()));
System.out.println("payload:" + payload);
try (OutputStream os = connection.getOutputStream()) {
os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8));
os.flush();
}
int responseCode = connection.getResponseCode();
if (responseCode < 200 || responseCode >= 300) {
String responseBody = readStream(connection.getErrorStream());
JSONObject result = JSONObject.parseObject(responseBody);
throw new RuntimeException(result != null ? result.toJSONString() : responseBody);
}
AiChatHistory aiChatHistory = new AiChatHistory();
aiChatHistory.setUserId(getLoginUserId());
aiChatHistory.setChatId(chatId);
aiChatHistory.setContent(message.getQuery());
StringBuilder reply = new StringBuilder();
String conversationId = null;
String messageId = null;
String taskId = null;
boolean conversationSaved = false;
boolean historySaved = false;
try (BufferedReader br = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
if (StrUtil.isBlank(line) || !line.startsWith("data:")) {
continue;
}
String json = line.substring(5).trim();
if (StrUtil.isBlank(json)) {
continue;
}
Map data = JSONUtil.parseObject(json, Map.class);
if (data == null) {
continue;
}
Object eventObj = data.get("event");
String event = eventObj == null ? "" : eventObj.toString();
if (StrUtil.isNotBlank(stringValue(data.get("task_id")))) {
taskId = stringValue(data.get("task_id"));
recordActiveAiTask(getLoginUserId(), "chat", taskId, appKeys().getChat());
}
String currentConversationId = data.get("conversation_id") == null
? conversationId
: data.get("conversation_id").toString();
String currentMessageId = data.get("message_id") == null
? messageId
: data.get("message_id").toString();
if (StrUtil.isNotBlank(currentConversationId)) {
conversationId = currentConversationId;
if (!conversationSaved) {
aiChatListService.updateConversationId(chatId, conversationId);
conversationSaved = true;
}
}
if (StrUtil.isNotBlank(currentMessageId)) {
messageId = currentMessageId;
}
if ("message".equals(event)) {
String answer = data.get("answer") == null ? null : data.get("answer").toString();
if (answer != null) {
reply.append(answer);
HashMap<String, String> answerData = new HashMap<>();
answerData.put("answer", answer);
answerData.put("conversationId", StrUtil.blankToDefault(conversationId, "0"));
answerData.put("messageId", StrUtil.blankToDefault(messageId, "0"));
answerData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
answerData.put("stopType", "chat");
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData));
}
} else if ("agent_log".equals(event) || "agent_thought".equals(event)) {
String answer = data.get("thought") == null
? (data.get("answer") == null ? null : data.get("answer").toString())
: data.get("thought").toString();
if (answer != null) {
System.out.println(answer);
HashMap<String, String> answerData = new HashMap<>();
answerData.put("answer", answer);
answerData.put("loading", "true");
answerData.put("conversationId", StrUtil.blankToDefault(conversationId, "0"));
answerData.put("messageId", StrUtil.blankToDefault(messageId, "0"));
answerData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
answerData.put("stopType", "chat");
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData));
}
} else if ("message_end".equals(event) || "workflow_finished".equals(event)) {
if (!historySaved) {
historySaved = true;
aiChatHistory.setConversationId(conversationId);
aiChatHistory.setReply(reply.toString());
aiChatHistoryService.save(aiChatHistory);
}
HashMap<String, String> endData = new HashMap<>();
endData.put("answer", "__END__");
endData.put("conversationId", StrUtil.blankToDefault(conversationId, "0"));
endData.put("messageId", StrUtil.blankToDefault(messageId, "0"));
endData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
endData.put("stopType", "chat");
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData));
clearActiveAiTask(getLoginUserId(), taskId);
} else if ("error".equals(event)) {
throw new RuntimeException(json);
}
}
}
if (!historySaved) {
aiChatHistory.setConversationId(conversationId);
aiChatHistory.setReply(reply.toString());
aiChatHistoryService.save(aiChatHistory);
HashMap<String, String> endData = new HashMap<>();
endData.put("answer", "__END__");
endData.put("conversationId", StrUtil.blankToDefault(conversationId, "0"));
endData.put("messageId", StrUtil.blankToDefault(messageId, "0"));
endData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
endData.put("stopType", "chat");
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData));
clearActiveAiTask(getLoginUserId(), taskId);
}
} catch (Exception e) {
System.out.println(e.getMessage());
for (StackTraceElement stackTraceElement : e.getStackTrace()) {
System.out.println(stackTraceElement);
}
HashMap<String, String> answerData = new HashMap<>();
answerData.put("answer", "出错了,请晚点再来提问吧~");
answerData.put("taskId", "0");
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData));
// webSocketServer.sendMessage(getLoginUserId().toString(), "出错了,请晚点再来提问吧~");
clearActiveAiTask(getLoginUserId(), null);
return fail("出错了,请晚点再来提问吧~");
}
// 返回成功响应
return success("Stream processing completed");
}
@PostMapping("/manuscriptGen")
public ApiResult<?> manuscriptGen(@RequestBody ManuscriptGenInputs message) throws IOException {
try {
URL url = new URL(aiUrl() + "/workflows/run");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Authorization", "Bearer " + appKeys().getManuscript());
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "text/event-stream");
connection.setDoOutput(true);
connection.setConnectTimeout(aiTimeout());
connection.setReadTimeout(aiTimeout());
Map<String, Object> inputs = buildManuscriptInputs(message);
// 附件当前不转发给 Dify:该应用没有文件类输入变量(file_upload.enabled=false),
// 上游也不认这个字段。原先这里调了 normalizeFiles 却从不使用其返回值,
// 反而让人以为附件已生效(见 issue 07)。要真正支持附件需先在 Dify
// 开始节点加文件变量,再按文件类变量的契约(transfer_method / upload_file_id)改造。
Map<String, Object> payload = new HashMap<>();
payload.put("response_mode", "streaming");
payload.put("user", getLoginUserId() == null ? "0" : getLoginUserId().toString());
payload.put("inputs", inputs);
System.out.println("manuscript payload:" + payload);
try (OutputStream os = connection.getOutputStream()) {
os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8));
os.flush();
}
int responseCode = connection.getResponseCode();
if (responseCode < 200 || responseCode >= 300) {
String responseBody = readStream(connection.getErrorStream());
JSONObject result = JSONObject.parseObject(responseBody);
throw new RuntimeException(result != null ? result.toJSONString() : responseBody);
}
StringBuilder reply = new StringBuilder();
String workflowRunId = null;
String taskId = null;
boolean endSent = false;
// 上游以 failed 收场时置真,用于给 HTTP 返回体带上 status(见 issue 10)。
// 不用局部布尔直接在收尾赋值是因为失败分支里 `continue` 了,
// 必须在外层作用域里记录。
boolean workflowFailed = false;
try (BufferedReader br = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
if (StrUtil.isBlank(line) || !line.startsWith("data:")) {
continue;
}
String json = line.substring(5).trim();
if (StrUtil.isBlank(json)) {
continue;
}
Map data = JSONUtil.parseObject(json, Map.class);
if (data == null) {
continue;
}
String event = data.get("event") == null ? "" : data.get("event").toString();
if (StrUtil.isNotBlank(stringValue(data.get("workflow_run_id")))) {
workflowRunId = stringValue(data.get("workflow_run_id"));
}
if (StrUtil.isNotBlank(stringValue(data.get("task_id")))) {
taskId = stringValue(data.get("task_id"));
} else if (StrUtil.isNotBlank(stringValue(data.get("id")))) {
taskId = stringValue(data.get("id"));
}
if (StrUtil.isNotBlank(taskId)) {
recordActiveAiTask(getLoginUserId(), "workflow", taskId, appKeys().getManuscript());
}
System.out.println(data);
// 正文只认正文事件(text_chunk / message);其余事件的
// extractAnswer 结果可能是 data.id 这类内部标识符,不得进 reply(见 issue 06)。
String answer = extractAnswer(data);
if (answer != null && ("text_chunk".equals(event) || "message".equals(event))) {
reply.append(answer);
HashMap<String, String> answerData = new HashMap<>();
answerData.put("answer", answer);
answerData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
answerData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData));
} else if ("workflow_started".equals(event) || "node_started".equals(event)
|| "node_finished".equals(event) || "agent_log".equals(event)
|| "agent_thought".equals(event)) {
// 这些事件只用于「加载中」提示,不下发正文。
// 不能用 extractAnswer 兜底:它可能在 outputs 里取到**整篇**稿件,
// 当成 loading 帧发出去等于把正文重复推送一遍(见 issue 06)。
String loadingText = buildLoadingText(event, data);
if (loadingText != null) {
HashMap<String, String> answerData = new HashMap<>();
answerData.put("answer", loadingText);
answerData.put("loading", "true");
answerData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
answerData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData));
}
} else if ("workflow_finished".equals(event) || "message_end".equals(event)) {
// 失败态先判定:outputs 为空时 answer 可能是 workflow_run_id
// 这类内部标识符,不能当正文推给端上(见 issue 03)。
if (isWorkflowFailed(workflowStatus(data))) {
logger.warn("manuscript workflow failed, taskId={}, workflowRunId={}",
taskId, workflowRunId);
workflowFailed = true;
reply.setLength(0);
reply.append(CREATIVE_FAILED_MESSAGE);
HashMap<String, String> failureData = new HashMap<>();
failureData.put("answer", CREATIVE_FAILED_MESSAGE);
failureData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
failureData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
// reset=true:丢弃失败前可能已推给端的半截内容
failureData.put("reset", "true");
webSocketServer.sendMessage(getLoginUserId().toString(),
JSONUtil.toJSONString(failureData));
if (!endSent) {
endSent = true;
HashMap<String, String> endData = new HashMap<>();
endData.put("answer", "__END__");
endData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
endData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
webSocketServer.sendMessage(getLoginUserId().toString(),
JSONUtil.toJSONString(endData));
}
clearActiveAiTask(getLoginUserId(), taskId);
continue;
}
// 收尾事件不产出正文:正文来自 text_chunk / message。
// 这里只在流式正文缺失时用 outputs 兜底,且兜底值同样要过
// 可展示性校验——否则会重演 issue 06(把 data.id 当正文)。
String finalAnswer = answer;
if (finalAnswer == null) {
finalAnswer = extractWorkflowOutputs(data);
}
if (isDisplayableText(finalAnswer)
&& reply.indexOf(finalAnswer) < 0) {
// 注:isDisplayableText 已排除空串,故无需再判 length()==0。
// 反过来若把空串放进来,reply.indexOf("") 恒为 0 会误判"已存在"
// 而短路掉正常内容,所以两道判断缺一不可。
reply.append(finalAnswer);
HashMap<String, String> answerData = new HashMap<>();
answerData.put("answer", finalAnswer);
answerData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
answerData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData));
}
if (!endSent) {
endSent = true;
HashMap<String, String> endData = new HashMap<>();
endData.put("answer", "__END__");
endData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
endData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData));
}
clearActiveAiTask(getLoginUserId(), taskId);
} else if ("error".equals(event)) {
throw new RuntimeException(json);
}
}
}
if (!endSent) {
HashMap<String, String> endData = new HashMap<>();
endData.put("answer", "__END__");
endData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
endData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData));
clearActiveAiTask(getLoginUserId(), taskId);
}
// 必须把累积的正文放进 data 一起回:只回一句状态文案时,
// 前端(uni-app 的 apiRequest 会回落 body.message)会把
// "Stream processing completed" 当成正文显示(见 issue 08)。
// 失败时同时带 status=failed,前端才能把失败文案与正文区分开(见 issue 10)。
return success("Stream processing completed",
buildManuscriptResult(reply, taskId, workflowRunId, workflowFailed));
} catch (Exception e) {
System.out.println(e.getMessage());
for (StackTraceElement stackTraceElement : e.getStackTrace()) {
System.out.println(stackTraceElement);
}
HashMap<String, String> answerData = new HashMap<>();
answerData.put("answer", "出错了,请晚点再试~");
answerData.put("taskId", "0");
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData));
return fail("出错了,请晚点再试~");
}
}
/**
* 稿件生成的 HTTP 返回体。
*
* <p>本接口同时走 WebSocket 推流,但**不能**因此省略返回体:不是所有调用方
* 都连了 WebSocket(如小程序的问答助手页在稿件模式下只发 HTTP),
* 它们唯一的正文来源就是这里的 {@code answer}。
*
* <p>只回 {@code message}(如 "Stream processing completed")时,
* 前端会把它当正文渲染——uni-app 的 {@code apiRequest} 在 {@code data}
* 缺失时会回落 {@code body.message}(见 issue 08)。
*/
private Map<String, Object> buildManuscriptResult(StringBuilder reply, String taskId, String workflowRunId) {
return buildManuscriptResult(reply, taskId, workflowRunId, false);
}
/**
* 构造稿件生成的 HTTP 返回体。
*
* <p>{@code answer} 键<b>始终存在</b>(空正文给空串):{@code ApiResult} 带
* {@code @JsonInclude(NON_NULL)},若 {@code data} 为 null 会被整个省略,
* 前端就会回落到 {@code message}("Stream processing completed")——见 issue 08。
*
* <p>失败时必须带 {@code status=failed}:否则前端拿到的是
* {@code answer="生成失败,请稍后重试"},会把失败文案当正文渲染
* (issue 10 实测:三个 admin 页面都会这样),且无从据此判定失败。
* uniapp 的 {@code isFailedPayload} 读的就是这个字段。
*
* @param failed 上游工作流是否以 failed 收场
*/
private Map<String, Object> buildManuscriptResult(StringBuilder reply, String taskId,
String workflowRunId, boolean failed) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("answer", reply == null ? "" : reply.toString());
if (failed) {
result.put("status", "failed");
}
if (StrUtil.isNotBlank(taskId)) {
result.put("taskId", taskId);
}
if (StrUtil.isNotBlank(workflowRunId)) {
result.put("workflowRunId", workflowRunId);
}
return result;
}
@PostMapping("/creativeAssistant")
public DeferredResult<ApiResult<?>> creativeAssistant(@RequestBody Map<String, Object> requestBody) {
final Integer loginUserId = getLoginUserId();
final DeferredResult<ApiResult<?>> deferredResult = new DeferredResult<>(AI_REQUEST_TIMEOUT);
final AtomicBoolean requestActive = new AtomicBoolean(true);
deferredResult.onTimeout(() -> {
requestActive.set(false);
if (!deferredResult.isSetOrExpired()) {
deferredResult.setResult(fail("AI创意助手处理超时,请稍后重试"));
}
});
deferredResult.onError((throwable) -> requestActive.set(false));
deferredResult.onCompletion(() -> requestActive.set(false));
CompletableFuture.runAsync(() -> {
ApiResult<?> result;
try {
result = executeCreativeAssistant(requestBody, loginUserId);
} catch (Exception e) {
logger.error("creative assistant request failed", e);
result = fail("出错了,请晚点再试~");
}
if (requestActive.get() && !deferredResult.isSetOrExpired()) {
deferredResult.setResult(result);
}
});
return deferredResult;
}
@PostMapping("/competition-history/preprocess")
public DeferredResult<ApiResult<?>> competitionHistoryPreprocess(
@RequestParam("files") List<MultipartFile> files,
@RequestParam(value = "scoreFile", required = false) MultipartFile scoreFile,
@RequestParam(value = "manualText", required = false) String manualText) {
final Integer loginUserId = getLoginUserId();
final DeferredResult<ApiResult<?>> deferredResult = new DeferredResult<>(AI_REQUEST_TIMEOUT);
final AtomicBoolean requestActive = new AtomicBoolean(true);
deferredResult.onTimeout(() -> {
requestActive.set(false);
if (!deferredResult.isSetOrExpired()) {
deferredResult.setResult(fail("历史竞赛文件预处理超时,请稍后查看知识库列表"));
}
});
deferredResult.onError((throwable) -> requestActive.set(false));
deferredResult.onCompletion(() -> requestActive.set(false));
CompletableFuture.runAsync(() -> {
ApiResult<?> result;
try {
if (files == null || files.isEmpty()) {
result = fail("请至少上传一个历史项目书文件");
} else {
Map<String, Object> inputs = new LinkedHashMap<>();
List<Object> uploadedFiles = new ArrayList<>();
for (MultipartFile file : files) {
if (file != null && !file.isEmpty()) {
uploadedFiles.add(uploadMultipartToAi(file, appKeys().getCompetitionHistoryPreprocess(), loginUserId));
}
}
if (uploadedFiles.isEmpty()) {
result = fail("请至少上传一个有效的历史项目书文件");
} else {
inputs.put("file_upload", uploadedFiles);
if (scoreFile != null && !scoreFile.isEmpty()) {
inputs.put("score_file", uploadMultipartToAi(scoreFile, appKeys().getCompetitionHistoryPreprocess(), loginUserId));
}
if (StrUtil.isNotBlank(manualText)) {
inputs.put("manual_text", manualText.trim());
}
result = executeWorkflowForResult(inputs, appKeys().getCompetitionHistoryPreprocess(),
"competitionHistoryPreprocess", loginUserId, true);
}
}
} catch (Exception e) {
logger.error("competition history preprocess failed", e);
result = fail("历史竞赛文件预处理失败: " + e.getMessage());
}
if (requestActive.get() && !deferredResult.isSetOrExpired()) {
deferredResult.setResult(result);
}
});
return deferredResult;
}
@PostMapping("/project-quality-review")
public ApiResult<?> projectQualityReview(
@RequestParam("newProject") MultipartFile newProject,
@RequestParam(value = "customRubric", required = false) MultipartFile customRubric) {
Integer loginUserId = getLoginUserId();
try {
if (newProject == null || newProject.isEmpty()) {
return fail("请上传待审项目申报书");
}
Map<String, Object> inputs = new LinkedHashMap<>();
inputs.put("new_project", uploadMultipartToAi(newProject, appKeys().getProjectQualityReview(), loginUserId));
if (customRubric != null && !customRubric.isEmpty()) {
inputs.put("custom_rubric", uploadMultipartToAi(customRubric, appKeys().getProjectQualityReview(), loginUserId));
}
return executeWorkflowForResult(inputs, appKeys().getProjectQualityReview(),
"projectQualityReview", loginUserId, true);
} catch (Exception e) {
logger.error("project quality review failed", e);
sendWorkflowSocketMessage("出错了,请晚点再试~", null, null, "projectQualityReview", false, loginUserId);
sendWorkflowSocketEnd(null, null, "projectQualityReview", loginUserId);
return fail("项目书质量初审失败: " + e.getMessage());
}
}
@ApiOperation("申报书生成")
@PostMapping("/declarationGen")
public ApiResult<?> declarationGen(@RequestBody DeclarationInputs message) {
Integer loginUserId = getLoginUserId();
if (message == null) {
return fail("请求参数不能为空");
}
// 核心创意是必填项(内网工作流 core_idea);upload_file 为可选的已有初稿/参考资料
if (StrUtil.isBlank(message.getCoreIdea())) {
return fail("coreIdea不能为空");
}
try {
Map<String, Object> inputs = buildMappedInputs(message, message.getInputs(),
difyProperties.getWorkflows().getDeclaration());
return executeWorkflowForResult(inputs, appKeys().getDeclaration(),
"declaration", loginUserId, true);
} catch (Exception e) {
logger.error("declaration gen failed", e);
sendWorkflowSocketMessage("出错了,请晚点再试~", null, null, "declaration", false, loginUserId);
sendWorkflowSocketEnd(null, null, "declaration", loginUserId);
return fail("申报书生成失败: " + e.getMessage());
}
}
@ApiOperation("智能文档处理")
@PostMapping("/documentProcess")
public ApiResult<?> documentProcess(@RequestBody DocumentProcessInputs message) {
Integer loginUserId = getLoginUserId();
if (message == null) {
return fail("请求参数不能为空");
}
// 网络地址与上传文件至少一个(内网工作流 pdf_url / pdf_file)
if (StrUtil.isBlank(message.getPdfUrl()) && !hasFileValue(message.getPdfFile())) {
return fail("pdfUrl与pdfFile至少需要一个");
}
try {
Map<String, Object> inputs = buildMappedInputs(message, message.getInputs(),
difyProperties.getWorkflows().getDocumentProcess());
return executeWorkflowForResult(inputs, appKeys().getDocumentProcess(),
"documentProcess", loginUserId, true);
} catch (Exception e) {
logger.error("document process failed", e);
sendWorkflowSocketMessage("出错了,请晚点再试~", null, null, "documentProcess", false, loginUserId);
sendWorkflowSocketEnd(null, null, "documentProcess", loginUserId);
return fail("文档处理失败: " + e.getMessage());
}
}
@ApiOperation("智能文档处理(文件上传)")
@PostMapping("/documentProcess/upload")
public DeferredResult<ApiResult<?>> documentProcessUpload(
@RequestParam(value = "file", required = false) MultipartFile file,
@RequestParam(value = "pdfUrl", required = false) String pdfUrl,
@RequestParam(value = "query", required = false) String query) {
final Integer loginUserId = getLoginUserId();
final DeferredResult<ApiResult<?>> deferredResult = new DeferredResult<>(AI_REQUEST_TIMEOUT);
final AtomicBoolean requestActive = new AtomicBoolean(true);
deferredResult.onTimeout(() -> {
requestActive.set(false);
if (!deferredResult.isSetOrExpired()) {
deferredResult.setResult(fail("文档处理超时,请稍后查看知识库列表"));
}
});
deferredResult.onError((throwable) -> requestActive.set(false));
deferredResult.onCompletion(() -> requestActive.set(false));
CompletableFuture.runAsync(() -> {
ApiResult<?> result;
try {
if ((file == null || file.isEmpty()) && StrUtil.isBlank(pdfUrl)) {
result = fail("请上传文件或提供pdfUrl");
} else {
// 键名直接用业务字段名,再交给 remapInputs 按配置映射成真实变量名,
// 避免在这里手写 "pdf_file" 之类的 Dify 变量名造成两处维护。
Map<String, Object> inputs = new LinkedHashMap<>();
if (file != null && !file.isEmpty()) {
inputs.put("pdfFile", uploadMultipartToAi(file, appKeys().getDocumentProcess(), loginUserId));
}
if (StrUtil.isNotBlank(pdfUrl)) {
inputs.put("pdfUrl", pdfUrl.trim());
}
if (StrUtil.isNotBlank(query)) {
inputs.put("query", query.trim());
}
result = executeWorkflowForResult(
remapInputs(inputs, difyProperties.getWorkflows().getDocumentProcess()),
appKeys().getDocumentProcess(), "documentProcess", loginUserId, true);
}
} catch (Exception e) {
logger.error("document process upload failed", e);
result = fail("文档处理失败: " + e.getMessage());
}
if (requestActive.get() && !deferredResult.isSetOrExpired()) {
deferredResult.setResult(result);
}
});
return deferredResult;
}
@PostMapping("/activityOutlineGen")
public ApiResult<?> activityOutlineGen(@RequestBody ActivityOutlineInputs message) {
if (message == null) {
return fail("请求参数不能为空");
}
String validationError = validateActivityOutline(message);
if (validationError != null) {
return fail(validationError);
}
try {
URL url = new URL(aiUrl() + "/chat-messages");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Authorization", "Bearer " + appKeys().getActivityOutline());
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "text/event-stream");
connection.setDoOutput(true);
connection.setConnectTimeout(aiTimeout());
connection.setReadTimeout(aiTimeout());
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("query", "请根据已提供的活动信息生成完整的活动策划大纲");
payload.put("response_mode", "streaming");
payload.put("user", getLoginUserId() == null ? "0" : getLoginUserId().toString());
payload.put("conversation_id", "");
payload.put("inputs", buildActivityOutlineInputs(message));
logger.info("activity outline payload: {}", payload);
try (OutputStream os = connection.getOutputStream()) {
os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8));
os.flush();
}
int responseCode = connection.getResponseCode();
if (responseCode < 200 || responseCode >= 300) {
String responseBody = readStream(connection.getErrorStream());
Map<String, Object> responseData = null;
try {
responseData = JSONUtil.parseObject(responseBody, Map.class);
} catch (Exception ignored) {
}
String messageText = responseData == null ? responseBody : JSONUtil.toJSONString(responseData);
return fail("活动策划大纲生成失败", messageText);
}
StringBuilder reply = new StringBuilder();
String conversationId = null;
String messageId = null;
boolean endSent = false;
// 见 manuscriptGen:失败分支里有 continue,故在外层作用域记录(issue 10)。
boolean workflowFailed = false;
try (BufferedReader br = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
if (StrUtil.isBlank(line) || !line.startsWith("data:")) {
continue;
}
String json = line.substring(5).trim();
if (StrUtil.isBlank(json)) {
continue;
}
Map data = JSONUtil.parseObject(json, Map.class);
if (data == null) {
continue;
}
String event = stringValue(data.get("event"));
if (StrUtil.isNotBlank(stringValue(data.get("conversation_id")))) {
conversationId = stringValue(data.get("conversation_id"));
}
if (StrUtil.isNotBlank(stringValue(data.get("message_id")))) {
messageId = stringValue(data.get("message_id"));
}
if ("message".equals(event)) {
String answer = stringValue(data.get("answer"));
if (answer != null) {
reply.append(answer);
sendActivityOutlineSocketMessage(answer, conversationId, messageId, false);
}
} else if ("agent_log".equals(event) || "agent_thought".equals(event)) {
String loadingText = stringValue(data.get("thought"));
if (loadingText == null) {
loadingText = stringValue(data.get("answer"));
}
if (loadingText != null) {
sendActivityOutlineSocketMessage(loadingText, conversationId, messageId, true);
}
} else if ("message_end".equals(event) || "workflow_finished".equals(event)) {
// 失败态必须先判定:此时 outputs 为空、answer 可能是
// workflow_run_id 这类内部标识符,直接下发等于把标识符
// 当正文推给端上(见 issue 03)。
if (isWorkflowFailed(workflowStatus(data))) {
logger.warn("activity outline workflow failed, conversationId={}, messageId={}",
conversationId, messageId);
workflowFailed = true;
reply.setLength(0);
reply.append(CREATIVE_FAILED_MESSAGE);
// reset=true:丢弃失败前可能已推给端的半截内容,
// 否则失败文案会追加在流式残片后面(见 issue 03)。
sendActivityOutlineSocketMessage(CREATIVE_FAILED_MESSAGE,
conversationId, messageId, false, true);
if (!endSent) {
endSent = true;
sendActivityOutlineSocketEnd(conversationId, messageId);
}
continue;
}
String answer = extractAnswer(data);
if (answer != null && (answer.length() == 0 || reply.indexOf(answer) < 0)) {
reply.append(answer);
sendActivityOutlineSocketMessage(answer, conversationId, messageId, false);
}
if (!endSent) {
endSent = true;
sendActivityOutlineSocketEnd(conversationId, messageId);
}
} else if ("error".equals(event)) {
throw new RuntimeException(json);
}
}
}
if (!endSent) {
sendActivityOutlineSocketEnd(conversationId, messageId);
}
// 同 manuscriptGen:不能只回状态文案。activityOutlineGen 的调用方
// (小程序「活动策划大纲」模式)同样只发 HTTP、不连 WebSocket,
// 返回体是它唯一的正文来源(见 issue 08)。
return success("Stream processing completed",
buildActivityOutlineResult(reply, conversationId, messageId, workflowFailed));
} catch (Exception e) {
logger.error("activity outline request failed", e);
sendActivityOutlineSocketMessage("出错了,请晚点再试~", null, null, false);
sendActivityOutlineSocketEnd(null, null);
return fail("出错了,请晚点再试~");
}
}
/**
* 构造活动策划大纲的 HTTP 返回体。
*
* <p>与 {@link #buildManuscriptResult(StringBuilder, String, String, boolean)} 同构:
* {@code answer} 键始终存在(空正文给空串,避免 {@code data} 被
* {@code @JsonInclude(NON_NULL)} 省略而让前端回落到 {@code message},见 issue 08),
* 失败时带 {@code status=failed} 以免失败文案被当正文渲染(见 issue 10)。
*
* @param failed 上游工作流是否以 failed 收场
*/
private Map<String, Object> buildActivityOutlineResult(StringBuilder reply, String conversationId,
String messageId, boolean failed) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("answer", reply == null ? "" : reply.toString());
if (failed) {
result.put("status", "failed");
}
if (StrUtil.isNotBlank(conversationId)) {
result.put("conversationId", conversationId);
}
if (StrUtil.isNotBlank(messageId)) {
result.put("messageId", messageId);
}
return result;
}
private void sendActivityOutlineSocketMessage(String answer, String conversationId, String messageId, boolean loading) {
sendActivityOutlineSocketMessage(answer, conversationId, messageId, loading, false);
}
/**
* @param reset 为真时端上应丢弃已累积的流式片段,只用本条内容。
* 失败收场时用它覆盖失败前可能已推给端的半截内容
* (见 issue 03)。
*/
private void sendActivityOutlineSocketMessage(String answer, String conversationId, String messageId,
boolean loading, boolean reset) {
HashMap<String, String> data = new HashMap<>();
data.put("answer", answer == null ? "" : answer);
data.put("conversationId", StrUtil.blankToDefault(conversationId, "0"));
data.put("messageId", StrUtil.blankToDefault(messageId, "0"));
if (loading) {
data.put("loading", "true");
}
if (reset) {
data.put("reset", "true");
}
Integer loginUserId = getLoginUserId();
try {
webSocketServer.sendMessage(loginUserId == null ? "0" : loginUserId.toString(), JSONUtil.toJSONString(data));
} catch (IOException e) {
logger.error("send activity outline socket message failed", e);
}
}
private void sendActivityOutlineSocketEnd(String conversationId, String messageId) {
HashMap<String, String> data = new HashMap<>();
data.put("answer", "__END__");
data.put("conversationId", StrUtil.blankToDefault(conversationId, "0"));
data.put("messageId", StrUtil.blankToDefault(messageId, "0"));
Integer loginUserId = getLoginUserId();
try {
webSocketServer.sendMessage(loginUserId == null ? "0" : loginUserId.toString(), JSONUtil.toJSONString(data));
} catch (IOException e) {
logger.error("send activity outline socket end failed", e);
}
}
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不能为空");
}
HttpURLConnection connection = null;
try {
URL url = new URL(aiUrl() + "/workflows/run");
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Authorization", "Bearer " + appKeys().getCreativeAssistant());
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "text/event-stream");
connection.setDoOutput(true);
connection.setConnectTimeout(aiTimeout());
connection.setReadTimeout(aiTimeout());
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("inputs", buildCreativeAssistantInputs(requestBody));
payload.put("response_mode", "streaming");
payload.put("user", loginUserId == null ? "0" : loginUserId.toString());
logger.info("creative assistant payload: {}", payload);
try (OutputStream os = connection.getOutputStream()) {
os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8));
os.flush();
}
int responseCode = connection.getResponseCode();
if (responseCode < 200 || responseCode >= 300) {
String responseBody = readStream(connection.getErrorStream());
Map<String, Object> responseData = null;
try {
responseData = JSONUtil.parseObject(responseBody, Map.class);
} catch (Exception ignored) {
}
String message = responseData == null ? responseBody : JSONUtil.toJSONString(responseData);
return fail("AI创意助手调用失败", message);
}
StringBuilder reply = new StringBuilder();
String workflowRunId = null;
String taskId = null;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
logger.info("creative assistant stream: {}", line);
if (StrUtil.isBlank(line) || !line.startsWith("data:")) {
continue;
}
String json = line.substring(5).trim();
if (StrUtil.isBlank(json)) {
continue;
}
Map<String, Object> data = JSONUtil.parseObject(json, Map.class);
if (data == null) {
continue;
}
if (StrUtil.isNotBlank(stringValue(data.get("workflow_run_id")))) {
workflowRunId = stringValue(data.get("workflow_run_id"));
}
if (StrUtil.isNotBlank(stringValue(data.get("task_id")))) {
taskId = stringValue(data.get("task_id"));
} else if (StrUtil.isNotBlank(stringValue(data.get("id")))) {
taskId = stringValue(data.get("id"));
}
if (StrUtil.isNotBlank(taskId)) {
recordActiveAiTask(loginUserId, "workflow", taskId, appKeys().getCreativeAssistant());
}
String event = stringValue(data.get("event"));
String answer = extractAnswer(data);
if (answer != null && ("text_chunk".equals(event) || "message".equals(event))) {
reply.append(answer);
}
if ("error".equals(event)) {
throw new RuntimeException(json);
}
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());
}
if (StrUtil.isBlank(stringValue(result.get("workflowRunId"))) && StrUtil.isNotBlank(workflowRunId)) {
result.put("workflowRunId", workflowRunId);
}
if (StrUtil.isBlank(stringValue(result.get("taskId"))) && StrUtil.isNotBlank(taskId)) {
result.put("taskId", taskId);
}
clearActiveAiTask(loginUserId, taskId);
return success(result);
}
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("answer", reply.toString());
if (StrUtil.isNotBlank(workflowRunId)) {
result.put("workflowRunId", workflowRunId);
}
if (StrUtil.isNotBlank(taskId)) {
result.put("taskId", taskId);
}
clearActiveAiTask(loginUserId, taskId);
return success(result);
} catch (Exception e) {
logger.error("creative assistant execute failed", e);
return fail("出错了,请晚点再试~");
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private Map<String, Object> parseInputs(String inputs) {
if (StrUtil.isBlank(inputs)) {
return new HashMap<>();
}
try {
return JSONUtil.parseObject(inputs, Map.class);
} catch (Exception e) {
return Collections.emptyMap();
}
}
private String readStream(InputStream inputStream) throws IOException {
if (inputStream == null) {
return "";
}
try (BufferedReader br = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
boolean firstLine = true;
while ((line = br.readLine()) != null) {
if (!firstLine) {
sb.append('\n');
}
sb.append(line);
firstLine = false;
}
return sb.toString();
}
}
private Map<String, Object> buildManuscriptInputs(ManuscriptGenInputs message) {
Map<String, Object> inputs = new LinkedHashMap<>();
if (message == null) {
return inputs;
}
if (StrUtil.isNotBlank(message.getDocType())) {
inputs.put("doc_type", message.getDocType());
}
if (StrUtil.isNotBlank(message.getEventType())) {
inputs.put("event_type", message.getEventType());
}
if (StrUtil.isNotBlank(message.getTheme())) {
inputs.put("theme", message.getTheme());
}
if (StrUtil.isNotBlank(message.getTime())) {
inputs.put("time", message.getTime());
}
if (StrUtil.isNotBlank(message.getLocation())) {
inputs.put("location", message.getLocation());
}
if (StrUtil.isNotBlank(message.getParticipants())) {
inputs.put("participants", message.getParticipants());
}
if (StrUtil.isNotBlank(message.getHighlights())) {
inputs.put("highlights", message.getHighlights());
}
if (message.getNumber() != null) {
// Dify 工作流里该变量名首字母大写(Number),与其它小写变量不一致。
// 变量名大小写敏感,发 "number" 会因缺参报错,故按实际变量名发送。
inputs.put("Number", message.getNumber());
}
return inputs;
}
/**
* 「活动策划大纲生成」的前置校验。
*
* <p>只校验 {@code event_theme} / {@code people} / {@code event_time} 三项 ——
* 它们缺失时生成的大纲没有主题、受众、时间,对用户无意义。
*
* <p>刻意<b>不</b>校验另外三个上游必填项,因为"上游必填"不等于"用户必须填":
* <ul>
* <li>{@code event_member} / {@code tel_people}:两端前端都不采集,
* 由 {@link #buildActivityOutlineInputs} 补空串占位。
* 实测空串可让工作流 succeeded,加校验会把默认入参直接挡在门外。</li>
* <li>{@code event_funding}:上游只要求"键存在且可解析为数值",
* <b>空串是合法取值</b>(实测 {@code ""} → succeeded + 7268 条 message)。
* 而问答助手页的资金是从用户自然语言里解析的,用户没提钱时就是空,
* 此时大纲照样生成得出来,不该拦。</li>
* </ul>
*
* <p>由此得到必须守住的不变量:<b>上游声明的必填集合,其键必须全部下发</b>——
* 本方法放行的任何入参都不得让 {@code buildActivityOutlineInputs} 漏键。
* 改这里的校验集合时,务必同步 {@code AiActivityOutlineContractTest} 里的断言。
*
* @return 错误文案;通过校验时返回 {@code null}
*/
private String validateActivityOutline(ActivityOutlineInputs message) {
if (StrUtil.isBlank(message.getEventTheme())) {
return "event_theme不能为空";
}
if (StrUtil.isBlank(message.getPeople())) {
return "people不能为空";
}
if (StrUtil.isBlank(message.getEventTime())) {
return "event_time不能为空";
}
return null;
}
/**
* 把大纲入参映射成 Dify 开始节点的 inputs(见 issue 09)。
*
* <p>与该实例上其它工作流的 remap 逻辑有一处<b>本质区别</b>:
* Dify 的 {@code xxx is required in input form} 检查的是<b>键是否存在</b>,
* 不是值是否非空 —— 实测对照:
* <pre>
* 缺 event_member 键 → failed: event_member is required in input form
* "event_member": "" → succeeded(4752 条 message)
* </pre>
* 所以必填变量必须<b>无条件占位下发</b>,空值给空串;
* 而 {@code event_type} / {@code event_location} / {@code event_meme}
* 实测非必填(抽掉后照样 succeeded),保持「为空则不发」的旧行为。
*
* <p>{@code event_funding} 另有类型问题:上游是 number,见
* {@link #normalizeEventFunding(String)}。
*/
private Map<String, Object> buildActivityOutlineInputs(ActivityOutlineInputs message) {
Map<String, Object> inputs = new LinkedHashMap<>();
if (message == null) {
message = new ActivityOutlineInputs();
}
// —— 上游实测必填的 6 个变量:键必须存在,空缺用空串占位 ——
inputs.put("event_theme", blankToEmpty(message.getEventTheme()));
inputs.put("people", blankToEmpty(message.getPeople()));
inputs.put("event_time", blankToEmpty(message.getEventTime()));
// number 类型:抽出数值,抽不出给空串(都是上游接受的形态)
inputs.put("event_funding", normalizeEventFunding(message.getEventFunding()));
// 两端前端都不采集这两个字段,因此空串是常态而非错误
inputs.put("event_member", blankToEmpty(message.getEventMember()));
inputs.put("tel_people", blankToEmpty(message.getTelPeople()));
// —— 上游实测非必填:为空不下发 ——
if (StrUtil.isNotBlank(message.getEventType())) {
inputs.put("event_type", message.getEventType());
}
if (StrUtil.isNotBlank(message.getEventLocation())) {
inputs.put("event_location", message.getEventLocation());
}
if (StrUtil.isNotBlank(message.getPeopleManage())) {
inputs.put("people_manage", message.getPeopleManage());
}
if (StrUtil.isNotBlank(message.getEventMeme())) {
inputs.put("event_meme", message.getEventMeme());
}
return inputs;
}
private String blankToEmpty(String value) {
return value == null ? "" : value.trim();
}
/**
* 把「活动资金」的<b>用户原文</b>归一化成 Dify 要的 number。
*
* <p>上游该变量声明为 {@code number},而两端前端的输入框是自由文本:
* admin 的示例值是「预算 5000 元,来源为校团委志愿服务专项经费」,
* 小程序问答助手页解析用户自然语言后默认填「待定」。实测取值判定:
* <pre>
* 5000 / "5000" / 5000.5 / 0 → succeeded(可解析的数值,上游会强制转换)
* "" → succeeded(7268 条 message)
* "待定" / "预算 5000 元" → failed: event_funding in input form must be a valid number
* </pre>
* 即「不可解析的文本必然让整个工作流失败,空串反而是安全的」,
* 因此这里抽出金额;抽不出就降级为空串,<b>绝不原样下发</b>。
*
* <p>两条<b>必须先处理的坑</b>,否则会静默发出错误金额(比留空更糟):
* <ol>
* <li><b>日期</b>:{@code "2026年4月办活动,预算5000元"} 里的 2026 会被当成金额,
* 故先用 {@link #EVENT_FUNDING_DATE_PATTERN} 剔除日期/时间表达式。</li>
* <li><b>数字与单位之间可能有空格</b>:{@code "预算 1 万元"} 若只认紧邻的「万」,
* 会抽出 1 元。故用 {@code \s*} 容忍空白。</li>
* </ol>
*
* <p>支持「万 / 亿 / 千 / 百」倍率,以及 {@code "3万5千"} 这类连续降序单位相加。
* 只取<b>第一处</b>金额(相邻的单位片段相加),不会把「另有 3000 元物资」也累加进来。
*
* @return {@link java.math.BigDecimal}(序列化成不带引号的 JSON 数值)或空串
*/
private Object normalizeEventFunding(String raw) {
if (StrUtil.isBlank(raw)) {
return "";
}
// 先剔除日期/时间,避免把年份当金额
String text = EVENT_FUNDING_DATE_PATTERN.matcher(raw).replaceAll(" ");
Matcher matcher = EVENT_FUNDING_AMOUNT_PATTERN.matcher(text);
// 负号不在捕获组里,会被静默丢成正数:"-5000" -> 5000 是错的,直接降级
if (!matcher.find() || isNegated(text, matcher.start())) {
logger.info("activity outline event_funding 无法解析为数值,降级为空串: {}", raw);
return "";
}
BigDecimal amount = toAmount(matcher.group(1), matcher.group(2));
BigDecimal lastMultiplier = multiplierOf(matcher.group(2));
int cursor = matcher.end();
// 相邻的降序单位继续相加:"3万5千" -> 30000 + 5000
while (true) {
Matcher next = EVENT_FUNDING_AMOUNT_PATTERN.matcher(text);
if (!next.find(cursor) || next.start() > cursor + 2) {
break;
}
BigDecimal multiplier = multiplierOf(next.group(2));
if (multiplier.compareTo(lastMultiplier) >= 0) {
break;
}
amount = amount.add(toAmount(next.group(1), next.group(2)));
lastMultiplier = multiplier;
cursor = next.end();
}
return amount;
}
/**
* 判断数字前是否带负号(容忍中间的空格:「- 5000」)。
*/
private boolean isNegated(String text, int numberStart) {
for (int i = numberStart - 1; i >= 0; i--) {
char c = text.charAt(i);
if (Character.isWhitespace(c)) {
continue;
}
return c == '-';
}
return false;
}
private BigDecimal toAmount(String digits, String unit) {
return new BigDecimal(digits.replace(",", "")).multiply(multiplierOf(unit));
}
private BigDecimal multiplierOf(String unit) {
if ("亿".equals(unit)) {
return BigDecimal.valueOf(100000000L);
}
if ("万".equals(unit)) {
return BigDecimal.valueOf(10000L);
}
if ("千".equals(unit)) {
return BigDecimal.valueOf(1000L);
}
if ("百".equals(unit)) {
return BigDecimal.valueOf(100L);
}
return BigDecimal.ONE;
}
/**
* 把入参实体的业务字段转成 Dify 工作流的 inputs。
*
* <p>调用方显式传了 {@code inputs} 时以它为准(可覆盖映射配置);
* 否则按 {@code mapping} 把非空业务字段改名后透传,未登记的字段按原名发送。
* 这样即使内网工作流的变量名与业务字段不一致,改 yml 即可对齐,无需改代码。
*/
private Map<String, Object> buildMappedInputs(Object source,
Map<String, Object> explicitInputs,
DifyProperties.VariableMapping mapping) {
if (explicitInputs != null && !explicitInputs.isEmpty()) {
return new LinkedHashMap<>(explicitInputs);
}
Map<String, Object> raw = objectToMap(source);
return remapInputs(raw, mapping);
}
/**
* 按映射表改写 inputs 的键名。
*/
private Map<String, Object> remapInputs(Map<String, Object> raw, DifyProperties.VariableMapping mapping) {
Map<String, Object> result = new LinkedHashMap<>();
if (raw == null) {
return result;
}
for (Map.Entry<String, Object> entry : raw.entrySet()) {
Object value = entry.getValue();
if (value == null) {
continue;
}
if (value instanceof String && StrUtil.isBlank((String) value)) {
continue;
}
String variable = mapping == null ? entry.getKey() : mapping.variableOf(entry.getKey());
// 映射为空串表示该字段不发给 Dify
if (StrUtil.isBlank(variable)) {
continue;
}
result.put(variable, value);
}
return result;
}
/**
* 用 Jackson 把入参实体转成 Map,跳过 null 字段。
*
* <p>不用 JSONUtil:它对实体走的是 fastjson,字段命名策略与 Jackson 注解
* ({@code @JsonProperty}/{@code @JsonAlias})不一致。
*/
private Map<String, Object> objectToMap(Object source) {
if (source == null) {
return new LinkedHashMap<>();
}
try {
String json = objectMapper.writeValueAsString(source);
Map<String, Object> map = JSONUtil.parseObject(json, Map.class);
return map == null ? new LinkedHashMap<>() : map;
} catch (Exception e) {
logger.warn("入参实体转换失败: {}", source.getClass().getSimpleName(), e);
return new LinkedHashMap<>();
}
}
private Map<String, Object> buildCreativeAssistantInputs(Map<String, Object> requestBody) {
Map<String, Object> inputs = new LinkedHashMap<>();
if (requestBody == null) {
return inputs;
}
String userContent = stringValue(requestBody.get("user_content"));
if (StrUtil.isNotBlank(userContent)) {
inputs.put("user_content", userContent);
}
Object rawUserFiles = requestBody.get("user_files");
if (hasFileValue(rawUserFiles)) {
Object userFiles = normalizeFiles(rawUserFiles);
Object fileValue = userFiles != null ? userFiles : rawUserFiles;
if (hasFileValue(fileValue)) {
inputs.put("user_files", fileValue);
}
}
return inputs;
}
private ApiResult<?> executeWorkflowForResult(Map<String, Object> inputs,
String apiKey,
String stopType,
Integer loginUserId,
boolean streamToSocket) throws IOException {
HttpURLConnection connection = null;
try {
URL url = new URL(aiUrl() + "/workflows/run");
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Authorization", "Bearer " + apiKey);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "text/event-stream");
connection.setDoOutput(true);
connection.setConnectTimeout(aiTimeout());
connection.setReadTimeout(aiTimeout());
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("inputs", inputs == null ? new LinkedHashMap<>() : inputs);
payload.put("response_mode", "streaming");
payload.put("user", loginUserId == null ? "0" : loginUserId.toString());
logger.info("{} workflow payload: {}", stopType, payload);
try (OutputStream os = connection.getOutputStream()) {
os.write(JSONUtil.toJSONString(payload).getBytes(StandardCharsets.UTF_8));
os.flush();
}
int responseCode = connection.getResponseCode();
if (responseCode < 200 || responseCode >= 300) {
String responseBody = readStream(connection.getErrorStream());
return fail("AI工作流调用失败", responseBody);
}
StringBuilder reply = new StringBuilder();
String workflowRunId = null;
String taskId = null;
Map<String, Object> lastData = null;
boolean endSent = false;
boolean workflowFailed = false;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (StrUtil.isBlank(line) || !line.startsWith("data:")) {
continue;
}
String json = line.substring(5).trim();
if (StrUtil.isBlank(json)) {
continue;
}
Map<String, Object> data = JSONUtil.parseObject(json, Map.class);
if (data == null) {
continue;
}
lastData = data;
if (StrUtil.isNotBlank(stringValue(data.get("workflow_run_id")))) {
workflowRunId = stringValue(data.get("workflow_run_id"));
}
if (StrUtil.isNotBlank(stringValue(data.get("task_id")))) {
taskId = stringValue(data.get("task_id"));
} else if (StrUtil.isNotBlank(stringValue(data.get("id")))) {
taskId = stringValue(data.get("id"));
}
if (StrUtil.isNotBlank(taskId)) {
recordActiveAiTask(loginUserId, stopType, taskId, apiKey);
}
String event = stringValue(data.get("event"));
String answer = extractAnswer(data);
if (answer != null && ("text_chunk".equals(event) || "message".equals(event))) {
reply.append(answer);
if (streamToSocket) {
sendWorkflowSocketMessage(answer, taskId, workflowRunId, stopType, false, loginUserId);
}
} else if ("workflow_started".equals(event) || "node_started".equals(event)
|| "node_finished".equals(event) || "agent_log".equals(event)
|| "agent_thought".equals(event)) {
if (streamToSocket) {
sendWorkflowSocketMessage(StrUtil.blankToDefault(answer, event),
taskId, workflowRunId, stopType, true, loginUserId);
}
} else if ("workflow_finished".equals(event) || "message_end".equals(event)) {
// 失败态必须先判定:outputs 为空时 answer 可能是
// workflow_run_id 这类内部标识符,不能当正文推给端上(见 issue 03)。
String eventStatus = workflowStatus(data);
if (isWorkflowFailed(eventStatus)) {
logger.warn("workflow failed, stopType={}, taskId={}, workflowRunId={}",
stopType, taskId, workflowRunId);
workflowFailed = true;
reply.setLength(0);
reply.append(CREATIVE_FAILED_MESSAGE);
if (streamToSocket) {
// reset=true:丢弃失败前可能已推给端的半截内容
sendWorkflowSocketMessage(CREATIVE_FAILED_MESSAGE,
taskId, workflowRunId, stopType, false, loginUserId, true);
}
if (streamToSocket) {
sendWorkflowSocketEnd(taskId, workflowRunId, stopType, loginUserId);
endSent = true;
}
clearActiveAiTask(loginUserId, taskId);
continue;
}
String finalAnswer = answer == null ? extractWorkflowOutputs(data) : answer;
if (finalAnswer != null && (finalAnswer.length() == 0 || reply.indexOf(finalAnswer) < 0)) {
reply.append(finalAnswer);
if (streamToSocket) {
sendWorkflowSocketMessage(finalAnswer, taskId, workflowRunId, stopType, false, loginUserId);
}
}
if (streamToSocket) {
sendWorkflowSocketEnd(taskId, workflowRunId, stopType, loginUserId);
endSent = true;
}
clearActiveAiTask(loginUserId, taskId);
} else if ("error".equals(event)) {
throw new RuntimeException(json);
}
}
}
if (streamToSocket && !endSent) {
sendWorkflowSocketEnd(taskId, workflowRunId, stopType, loginUserId);
}
clearActiveAiTask(loginUserId, taskId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("answer", reply.toString());
if (StrUtil.isNotBlank(taskId)) {
result.put("taskId", taskId);
}
if (StrUtil.isNotBlank(workflowRunId)) {
result.put("workflowRunId", workflowRunId);
}
// 失败态只回状态与可读文案,不下发 outputs / raw / 上游错误栈(见 issue 03)。
if (workflowFailed) {
result.put("status", "failed");
result.put("answer", CREATIVE_FAILED_MESSAGE);
return success(result);
}
if (lastData != null) {
Object data = lastData.get("data");
if (data instanceof Map) {
Object outputs = ((Map) data).get("outputs");
if (outputs != null) {
result.put("outputs", outputs);
}
Object status = ((Map) data).get("status");
if (status != null) {
result.put("status", status);
}
}
result.put("raw", lastData);
}
return success(result);
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private void sendWorkflowSocketMessage(String answer,
String taskId,
String workflowRunId,
String stopType,
boolean loading,
Integer loginUserId) {
sendWorkflowSocketMessage(answer, taskId, workflowRunId, stopType, loading, loginUserId, false);
}
/**
* @param reset 为真时端上应丢弃已累积的流式片段,只用本条内容。
* 失败收场时用它覆盖失败前可能已推给端的半截内容(见 issue 03)。
*/
private void sendWorkflowSocketMessage(String answer,
String taskId,
String workflowRunId,
String stopType,
boolean loading,
Integer loginUserId,
boolean reset) {
HashMap<String, String> data = new HashMap<>();
data.put("answer", answer == null ? "" : answer);
data.put("taskId", StrUtil.blankToDefault(taskId, "0"));
data.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
data.put("stopType", StrUtil.blankToDefault(stopType, "workflow"));
if (loading) {
data.put("loading", "true");
}
if (reset) {
data.put("reset", "true");
}
try {
webSocketServer.sendMessage(loginUserId == null ? "0" : loginUserId.toString(), JSONUtil.toJSONString(data));
} catch (IOException e) {
logger.error("send workflow socket message failed", e);
}
}
private void sendWorkflowSocketEnd(String taskId, String workflowRunId, String stopType, Integer loginUserId) {
HashMap<String, String> data = new HashMap<>();
data.put("answer", "__END__");
data.put("taskId", StrUtil.blankToDefault(taskId, "0"));
data.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
data.put("stopType", StrUtil.blankToDefault(stopType, "workflow"));
try {
webSocketServer.sendMessage(loginUserId == null ? "0" : loginUserId.toString(), JSONUtil.toJSONString(data));
} catch (IOException e) {
logger.error("send workflow socket end failed", e);
}
}
private Map<String, Object> uploadMultipartToAi(MultipartFile file, String apiKey, Integer loginUserId) throws IOException {
File tempFile = null;
try {
String originalFilename = StrUtil.blankToDefault(file.getOriginalFilename(), "upload.bin");
String suffix = "";
int suffixIndex = originalFilename.lastIndexOf('.');
if (suffixIndex >= 0) {
suffix = originalFilename.substring(suffixIndex);
}
tempFile = File.createTempFile("ai-upload-", suffix);
file.transferTo(tempFile);
HttpResponse response = HttpRequest.post(aiUrl() + "/files/upload")
.header("Authorization", "Bearer " + apiKey)
.form("user", loginUserId == null ? "0" : loginUserId.toString())
.form("file", tempFile, originalFilename)
.timeout(aiTimeout())
.execute();
String responseBody = response.body();
Map<String, Object> result = JSONUtil.parseObject(responseBody, Map.class);
if (response.getStatus() < 200 || response.getStatus() >= 300) {
throw new IOException(result == null ? responseBody : JSONUtil.toJSONString(result));
}
if (result == null) {
throw new IOException("AI文件上传返回为空");
}
Map<String, Object> fileValue = new LinkedHashMap<>();
fileValue.put("transfer_method", "local_file");
fileValue.put("upload_file_id", result.get("id"));
fileValue.put("type", resolveAiFileType(originalFilename, stringValue(result.get("mime_type"))));
fileValue.put("url", "");
fileValue.put("name", result.get("name"));
fileValue.put("raw", result);
return fileValue;
} finally {
if (tempFile != null && tempFile.exists()) {
tempFile.delete();
}
}
}
private String resolveAiFileType(String fileName, String mimeType) {
String lowerName = StrUtil.blankToDefault(fileName, "").toLowerCase();
String lowerMime = StrUtil.blankToDefault(mimeType, "").toLowerCase();
if (lowerMime.startsWith("image/") || lowerName.matches(".*\\.(png|jpe?g|gif|webp|bmp)$")) {
return "image";
}
if (lowerMime.startsWith("audio/") || lowerName.matches(".*\\.(mp3|wav|m4a|aac|flac)$")) {
return "audio";
}
if (lowerMime.startsWith("video/") || lowerName.matches(".*\\.(mp4|mov|avi|mkv|webm)$")) {
return "video";
}
return "document";
}
private boolean hasFileValue(Object value) {
if (value == null) {
return false;
}
if (value instanceof String) {
return StrUtil.isNotBlank((String) value);
}
if (value instanceof List) {
return !((List) value).isEmpty();
}
if (value instanceof Map) {
return !((Map) value).isEmpty();
}
return true;
}
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;
}
String answer = extractWorkflowOutputs(responseData);
if (answer == null) {
answer = extractAnswer(responseData);
}
if (answer != null) {
result.put("answer", answer);
}
String workflowRunId = stringValue(responseData.get("workflow_run_id"));
if (StrUtil.isNotBlank(workflowRunId)) {
result.put("workflowRunId", workflowRunId);
}
String taskId = stringValue(responseData.get("task_id"));
if (StrUtil.isNotBlank(taskId)) {
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"));
}
result.put("raw", responseData);
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) {
result.put("answer", responseBody);
return result;
}
String answer = extractAnswer(responseData);
if (answer != null) {
result.put("answer", answer);
}
String conversationId = stringValue(responseData.get("conversation_id"));
if (StrUtil.isNotBlank(conversationId)) {
result.put("conversationId", conversationId);
}
String messageId = stringValue(responseData.get("message_id"));
if (StrUtil.isNotBlank(messageId)) {
result.put("messageId", messageId);
}
if (responseData.get("metadata") != null) {
result.put("metadata", responseData.get("metadata"));
}
result.put("raw", responseData);
return result;
}
private Object normalizeFiles(Object files) {
if (files == null) {
return null;
}
if (files instanceof List) {
return files;
}
if (files instanceof Map) {
List<Object> fileList = new ArrayList<>();
fileList.add(files);
return fileList;
}
if (files instanceof String && StrUtil.isNotBlank((String) files)) {
String fileStr = (String) files;
if (fileStr.trim().startsWith("[")) {
Map<String, Object> wrapper = JSONUtil.parseObject("{\"files\":" + fileStr + "}", Map.class);
return wrapper == null ? null : wrapper.get("files");
}
if (fileStr.trim().startsWith("{")) {
Map<String, Object> fileMap = JSONUtil.parseObject(fileStr, Map.class);
if (fileMap != null) {
List<Object> fileList = new ArrayList<>();
fileList.add(fileMap);
return fileList;
}
}
}
return null;
}
private String extractAnswer(Map data) {
if (data == null) {
return null;
}
String[] keys = new String[]{"answer", "text", "chunk", "thought"};
for (String key : keys) {
Object value = data.get(key);
if (isDisplayableText(value)) {
return (String) value;
}
}
Object dataObj = data.get("data");
if (dataObj instanceof Map) {
// 注意用 findPreferredTextValue 而非 findTextValue:
// data 里除了正文还有 id / status 等元数据,后者的通用扫描
// 会把内部标识符或 "succeeded" 这类状态词当成正文(见 issue 06)。
String nestedAnswer = findPreferredTextValue((Map) dataObj);
if (nestedAnswer != null) {
return nestedAnswer;
}
}
return null;
}
/**
* 判断一个值是否适合当作**正文**下发给端上。
*
* <p>Dify 事件的 {@code data.id} 是 workflow / 节点的内部标识符(UUID),
* 而 {@code findTextValue} 的兜底会把它当成文本取出来;一旦被当正文追加,
* 用户看到的稿件末尾就会挂一串 UUID(见 issue 06)。
*
* <p>与前端 {@code tuanwei-uniapp/utils/ai-result.js} 的
* {@code isDisplayableAnswer} 同一口径:UUID 与无空格长 token 都不算可展示内容。
* 前端那道防线只覆盖**失败**路径,成功路径必须由后端拦住。
*/
private boolean isDisplayableText(Object value) {
if (!(value instanceof String)) {
return false;
}
String text = ((String) value).trim();
if (text.isEmpty()) {
return false;
}
// 纯 UUID(workflow_run_id / 节点 id 等)
if (UUID_PATTERN.matcher(text).matches()) {
return false;
}
// 无空格的长 token(task_id / req_id 等)
if (!WHITESPACE_PATTERN.matcher(text).find() && LONG_TOKEN_PATTERN.matcher(text).matches()) {
return false;
}
return true;
}
private String extractWorkflowOutputs(Map data) {
if (data == null) {
return null;
}
Object outputs = data.get("outputs");
if (outputs instanceof Map) {
return findTextValue((Map) outputs);
}
Object dataObj = data.get("data");
if (dataObj instanceof Map) {
Object nestedOutputs = ((Map) dataObj).get("outputs");
if (nestedOutputs instanceof Map) {
return findTextValue((Map) nestedOutputs);
}
return findTextValue((Map) dataObj);
}
return null;
}
/**
* 为「加载中」事件生成提示文案。
*
* <p>只取**节点标题**这类天然短小、面向用户的字段(如「用户输入」「LLM」),
* 不再回退到 {@code extractAnswer}——后者会在 outputs 里取到整篇稿件,
* 当 loading 帧发出去就等于把正文又推了一遍(见 issue 06)。
*
* <p>{@code thought}(思维链)同样有风险:{@code agent_thought} 的 thought
* 实测可达数 KB,整个塞进 loading 气泡等于把思维链当提示推给端上。
* 故超过 {@link #LOADING_TEXT_MAX_LENGTH} 的一律弃用、退到事件名。
*
* @return 提示文案;无可展示内容时返回 {@code null}(调用方不下发)
*/
private String buildLoadingText(String event, Map data) {
if (data != null) {
Object dataObj = data.get("data");
if (dataObj instanceof Map) {
String title = stringValue(((Map) dataObj).get("title"));
if (isDisplayableText(title) && title.length() <= LOADING_TEXT_MAX_LENGTH) {
return title;
}
}
String thought = stringValue(data.get("thought"));
if (isDisplayableText(thought) && thought.length() <= LOADING_TEXT_MAX_LENGTH) {
return thought;
}
}
// 兜底用事件名(如 node_started),它是稳定的枚举而非内部标识符
return StrUtil.isBlank(event) ? null : event;
}
/**
* 在已知的正文键名里取值,并递归进嵌套对象。
*
* <p>用于 {@code data} 这类**混有元数据**的对象:只认正文键名,
* 不做「任意 String 值」的通用扫描。
*/
private String findPreferredTextValue(Map source) {
if (source == null || source.isEmpty()) {
return null;
}
String[] preferredKeys = new String[]{"answer", "content", "text", "chunk", "result",
"output", "article", "manuscript", "generated_content"};
for (String key : preferredKeys) {
Object value = source.get(key);
if (isDisplayableText(value)) {
return (String) value;
}
}
for (Object value : source.values()) {
if (value instanceof Map) {
String nestedValue = findPreferredTextValue((Map) value);
if (nestedValue != null) {
return nestedValue;
}
}
}
return null;
}
/**
* 在 outputs 这类**只装业务产物**的对象里取正文。
*
* <p>先按已知键名找,找不到再退一步取任意可展示的 String
* (工作流的输出变量名由编排决定,无法穷举,如 {@code generated_content})。
* 与 {@link #findPreferredTextValue} 的区别正在于这一层兜底 ——
* 它对 outputs 安全,对混有 id/status 的 data 不安全。
*/
private String findTextValue(Map source) {
if (source == null || source.isEmpty()) {
return null;
}
String preferred = findPreferredTextValue(source);
if (preferred != null) {
return preferred;
}
for (Object value : source.values()) {
if (isDisplayableText(value)) {
return (String) value;
}
if (value instanceof Map) {
String nestedValue = findTextValue((Map) value);
if (nestedValue != null) {
return nestedValue;
}
}
}
// 关键:这里的「任意 String 值」兜底**必须**过 isDisplayableText。
// 修复前它无条件返回第一个 String,正是把 data.id(UUID)当正文的放大器(见 issue 06)。
return null;
}
private String stringValue(Object value) {
return value == null ? null : value.toString();
}
private void recordActiveAiTask(Integer userId, String mode, String taskId, String apiKey) {
if (userId == null || StrUtil.isBlank(taskId)) {
return;
}
String normalizedMode = normalizeStopMode(mode);
activeAiTaskMap.put(buildActiveTaskKey(userId, normalizedMode),
new ActiveAiTask(taskId, normalizedMode, apiKey));
}
private ActiveAiTask findActiveAiTask(Integer userId, String mode) {
if (userId == null) {
return null;
}
ActiveAiTask activeTask = activeAiTaskMap.get(buildActiveTaskKey(userId, normalizeStopMode(mode)));
if (activeTask != null) {
return activeTask;
}
if (!isChatStopMode(mode)) {
return activeAiTaskMap.get(buildActiveTaskKey(userId, "workflow"));
}
return activeAiTaskMap.get(buildActiveTaskKey(userId, "chat"));
}
private void clearActiveAiTask(Integer userId, String taskId) {
if (userId == null) {
return;
}
if (StrUtil.isBlank(taskId)) {
activeAiTaskMap.remove(buildActiveTaskKey(userId, "chat"));
activeAiTaskMap.remove(buildActiveTaskKey(userId, "workflow"));
return;
}
activeAiTaskMap.entrySet().removeIf(entry ->
entry.getKey().startsWith(userId + ":") && taskId.equals(entry.getValue().getTaskId()));
}
private String resolveStopApiKey(String mode, ActiveAiTask activeTask) {
if (activeTask != null && StrUtil.isNotBlank(activeTask.getApiKey())) {
return activeTask.getApiKey();
}
if ("image".equals(mode)) {
return appKeys().getCreativeAssistant();
}
if ("writing".equals(mode)) {
return appKeys().getManuscript();
}
if ("projectQualityReview".equals(mode)) {
return appKeys().getProjectQualityReview();
}
if ("competitionHistoryPreprocess".equals(mode)) {
return appKeys().getCompetitionHistoryPreprocess();
}
if ("documentProcess".equals(mode)) {
return appKeys().getDocumentProcess();
}
if ("declaration".equals(mode)) {
return appKeys().getDeclaration();
}
return appKeys().getChat();
}
private String normalizeStopMode(String mode) {
return isChatStopMode(mode) ? "chat" : "workflow";
}
private boolean isChatStopMode(String mode) {
return StrUtil.isBlank(mode)
|| "chat".equals(mode)
|| "message".equals(mode)
|| "activityOutline".equals(mode);
}
private String buildActiveTaskKey(Integer userId, String mode) {
return userId + ":" + mode;
}
private static class ActiveAiTask {
private final String taskId;
private final String apiKey;
ActiveAiTask(String taskId, String mode, String apiKey) {
this.taskId = taskId;
this.apiKey = apiKey;
}
String getTaskId() {
return taskId;
}
String getApiKey() {
return apiKey;
}
}
@PostMapping("/messageStop")
public ApiResult<?> stop(@RequestBody Map<String, Object> data) {
Integer loginUserId = getLoginUserId();
String mode = StrUtil.blankToDefault(stringValue(data == null ? null : data.get("mode")), "chat");
String taskId = stringValue(data == null ? null : data.get("taskId"));
ActiveAiTask activeTask = findActiveAiTask(loginUserId, mode);
if (StrUtil.isBlank(taskId) && activeTask != null) {
taskId = activeTask.getTaskId();
}
if (StrUtil.isBlank(taskId)) {
return success("当前没有可停止的AI任务");
}
String stopType = isChatStopMode(mode) ? "chat" : "workflow";
String apiKey = resolveStopApiKey(mode, activeTask);
String stopPath = "chat".equals(stopType)
? "/chat-messages/" + taskId + "/stop"
: "/workflows/tasks/" + taskId + "/stop";
Map<String, Object> postData = new HashMap<>();
postData.put("user", loginUserId == null ? "0" : loginUserId.toString());
logger.info("AI stop payload: mode={}, taskId={}, path={}", mode, taskId, stopPath);
HttpResponse response = HttpRequest.post(aiUrl() + stopPath)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(JSONObject.toJSONString(postData))
.timeout(aiTimeout())
.execute();
String responseBody = response.body();
if (response.getStatus() < 200 || response.getStatus() >= 300) {
logger.warn("AI stop failed: status={}, body={}", response.getStatus(), responseBody);
return fail("停止AI任务失败", responseBody);
}
clearActiveAiTask(loginUserId, taskId);
return success(responseBody);
}
@PostMapping("/file/upload")
public ApiResult<?> uploadFile(@RequestParam("file") MultipartFile file,
@RequestParam(value = "user", required = false) String user,
@RequestParam(value = "type", required = false) Integer type) {
if (file == null || file.isEmpty()) {
return fail("文件不能为空");
}
File tempFile = null;
try {
String token = appKeys().getManuscript();
if (type == null || type == 0) {
token = appKeys().getChat();
}
String originalFilename = StrUtil.blankToDefault(file.getOriginalFilename(), "upload.bin");
String suffix = "";
int suffixIndex = originalFilename.lastIndexOf('.');
if (suffixIndex >= 0) {
suffix = originalFilename.substring(suffixIndex);
}
tempFile = File.createTempFile("ai-upload-", suffix);
file.transferTo(tempFile);
HttpResponse response = HttpRequest.post(aiUrl() + "/files/upload")
.header("Authorization", "Bearer " + token)
.form("user", StrUtil.blankToDefault(user, getLoginUserId().toString()))
.form("file", tempFile, originalFilename)
.timeout(aiTimeout())
.execute();
int responseCode = response.getStatus();
String responseBody = response.body();
Map<String, Object> result = JSONUtil.parseObject(responseBody, Map.class);
if (responseCode < 200 || responseCode >= 300) {
String message = result == null ? responseBody : JSONUtil.toJSONString(result);
return fail("上传失败", message);
}
return success(result == null ? responseBody : result);
} catch (Exception e) {
return fail("上传失败", e.getMessage());
} finally {
if (tempFile != null && tempFile.exists()) {
tempFile.delete();
}
}
}
}