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; /** * 创意生成工作流以失败收场时给用户看的文案。 * *

上游的真实失败原因(如 {@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.*}。 * *

原先这些 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 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 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 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 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 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 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 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 inputs = buildManuscriptInputs(message); Object files = normalizeFiles(message.getFiles()); Map 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 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 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 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 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 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 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 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 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> creativeAssistant(@RequestBody Map requestBody) { final Integer loginUserId = getLoginUserId(); final DeferredResult> 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> competitionHistoryPreprocess( @RequestParam("files") List files, @RequestParam(value = "scoreFile", required = false) MultipartFile scoreFile, @RequestParam(value = "manualText", required = false) String manualText) { final Integer loginUserId = getLoginUserId(); final DeferredResult> 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 inputs = new LinkedHashMap<>(); List 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 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 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 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> 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> 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 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 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 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 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 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 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 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 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 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 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 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 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 buildManuscriptInputs(ManuscriptGenInputs message) { Map 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 buildActivityOutlineInputs(ActivityOutlineInputs message) { Map 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。 * *

调用方显式传了 {@code inputs} 时以它为准(可覆盖映射配置); * 否则按 {@code mapping} 把非空业务字段改名后透传,未登记的字段按原名发送。 * 这样即使内网工作流的变量名与业务字段不一致,改 yml 即可对齐,无需改代码。 */ private Map buildMappedInputs(Object source, Map explicitInputs, DifyProperties.VariableMapping mapping) { if (explicitInputs != null && !explicitInputs.isEmpty()) { return new LinkedHashMap<>(explicitInputs); } Map raw = objectToMap(source); return remapInputs(raw, mapping); } /** * 按映射表改写 inputs 的键名。 */ private Map remapInputs(Map raw, DifyProperties.VariableMapping mapping) { Map result = new LinkedHashMap<>(); if (raw == null) { return result; } for (Map.Entry 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 字段。 * *

不用 JSONUtil:它对实体走的是 fastjson,字段命名策略与 Jackson 注解 * ({@code @JsonProperty}/{@code @JsonAlias})不一致。 */ private Map objectToMap(Object source) { if (source == null) { return new LinkedHashMap<>(); } try { String json = objectMapper.writeValueAsString(source); Map 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 buildCreativeAssistantInputs(Map requestBody) { Map 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 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 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 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 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 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 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 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 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 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 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 buildCreativeAssistantResult(Map responseData, String responseBody) { Map 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 工作流事件里的执行状态。 * *

{@code workflow_finished} 事件把状态放在 {@code data.status}; * 少数事件会放在顶层,两处都看,取到即返回。 * * @return 状态字符串;取不到时为 {@code null} */ private String workflowStatus(Map 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; } /** * 判断工作流是否以失败收场。 * *

只在拿到明确失败状态时为真;状态缺失视为未失败,避免误伤正常响应。 */ private boolean isWorkflowFailed(String status) { return "failed".equalsIgnoreCase(StrUtil.trimToEmpty(status)); } private Map buildChatMessageResult(Map responseData, String responseBody) { Map 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 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 wrapper = JSONUtil.parseObject("{\"files\":" + fileStr + "}", Map.class); return wrapper == null ? null : wrapper.get("files"); } if (fileStr.trim().startsWith("{")) { Map fileMap = JSONUtil.parseObject(fileStr, Map.class); if (fileMap != null) { List 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 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 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 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(); } } } }