f54e28cffc
上游工作流 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 —— 全链路闭合。
2211 lines
104 KiB
Java
2211 lines
104 KiB
Java
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();
|
||
}
|
||
}
|
||
}
|
||
}
|