Files
tuanwei-java/src/main/java/com/gxwebsoft/ai/controller/AiController.java
T
weicw-dshsa a2a83f7d9e fix(ai): 稿件/大纲链路失败态不再把标识符当正文推给端上
这三条路径在 workflow_finished 事件只 extractAnswer、从不读 status:
工作流失败时 outputs 为空、answer 可能被填成 workflow_run_id(UUID),
原样下发等于把内部标识符当正文推给端上(issue 03)。

- activityOutlineGen:失败先判定,推「生成失败,请稍后重试」(reset=true)
  再正常收尾
- executeWorkflowForResult:同上;最终结果失败时只回 status + 可读文案,
  不带 outputs / raw
- manuscriptGen:自查发现该方法不走通用执行、自带一份缺口,一并补齐
- socket 发送器增加 reset 字段:端上收到后丢弃失败前已累积的半截内容
- 新增 AiWorkflowFailureStatusTest 10 例:锁定「失败判定先于取 answer」
  的顺序契约与顶层/嵌套 status、大小写空白、成功不误判等边界

Refs: .scratch/ai-assistant/issues/03-other-links-raw-payload-fallback.md
2026-09-24 04:22:29 +08:00

1815 lines
84 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.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;
@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 = "生成失败,请稍后重试";
@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);
Object files = normalizeFiles(message.getFiles());
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;
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);
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)) {
String loadingText = StrUtil.blankToDefault(answer, event);
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);
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;
}
if (answer == null) {
answer = extractWorkflowOutputs(data);
}
if (answer != null && (answer.length() == 0 || reply.indexOf(answer) < 0)) {
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));
}
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);
}
} 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("出错了,请晚点再试~");
}
return success("Stream processing completed");
}
@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("请求参数不能为空");
}
if (StrUtil.isBlank(message.getEventTheme())) {
return fail("event_theme不能为空");
}
if (StrUtil.isBlank(message.getPeople())) {
return fail("people不能为空");
}
if (StrUtil.isBlank(message.getEventTime())) {
return fail("event_time不能为空");
}
if (StrUtil.isBlank(message.getEventFunding())) {
return fail("event_funding不能为空");
}
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;
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);
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);
}
return success("Stream processing completed");
} catch (Exception e) {
logger.error("activity outline request failed", e);
sendActivityOutlineSocketMessage("出错了,请晚点再试~", null, null, false);
sendActivityOutlineSocketEnd(null, null);
return fail("出错了,请晚点再试~");
}
}
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;
}
private Map<String, Object> buildActivityOutlineInputs(ActivityOutlineInputs message) {
Map<String, Object> inputs = new LinkedHashMap<>();
if (message == null) {
return inputs;
}
if (StrUtil.isNotBlank(message.getEventTheme())) {
inputs.put("event_theme", message.getEventTheme());
}
if (StrUtil.isNotBlank(message.getPeople())) {
inputs.put("people", message.getPeople());
}
if (StrUtil.isNotBlank(message.getEventTime())) {
inputs.put("event_time", message.getEventTime());
}
if (StrUtil.isNotBlank(message.getEventFunding())) {
inputs.put("event_funding", message.getEventFunding());
}
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.getEventMeme())) {
inputs.put("event_meme", message.getEventMeme());
}
return inputs;
}
/**
* 把入参实体的业务字段转成 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 (value instanceof String) {
return (String) value;
}
}
Object dataObj = data.get("data");
if (dataObj instanceof Map) {
String nestedAnswer = findTextValue((Map) dataObj);
if (nestedAnswer != null) {
return nestedAnswer;
}
}
return null;
}
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;
}
private String findTextValue(Map source) {
if (source == null || source.isEmpty()) {
return null;
}
String[] preferredKeys = new String[]{"answer", "content", "text", "result", "output", "article", "manuscript"};
for (String key : preferredKeys) {
Object value = source.get(key);
if (value instanceof String) {
return (String) value;
}
}
for (Object value : source.values()) {
if (value instanceof String) {
return (String) value;
}
if (value instanceof Map) {
String nestedValue = findTextValue((Map) value);
if (nestedValue != null) {
return nestedValue;
}
}
}
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();
}
}
}
}