From 9752538400a995e8d552dc1c5319c018e6ecefa1 Mon Sep 17 00:00:00 2001 From: firefly <1633489380@qq.com> Date: Fri, 1 Aug 2025 17:20:25 +0800 Subject: [PATCH] =?UTF-8?q?dify=E7=AC=AC=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-admin/pom.xml | 34 ++ .../com/ruoyi/web/api/DifyChatController.java | 479 ++++++++++++++++++ 2 files changed, 513 insertions(+) create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/web/api/DifyChatController.java diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index c05f707..5ad8cb7 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -61,6 +61,40 @@ ruoyi-generator + + org.projectlombok + lombok + provided + + + + com.squareup.okhttp3 + okhttp + 4.9.3 + + + org.json + json + 20230227 + + + + com.squareup.okhttp3 + okhttp + 4.9.3 + + + cn.hutool + hutool-all + 5.8.20 + + + org.json + json + 20160810 + + + diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/api/DifyChatController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/api/DifyChatController.java new file mode 100644 index 0000000..6254717 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/api/DifyChatController.java @@ -0,0 +1,479 @@ +package com.ruoyi.web.api; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import okhttp3.*; +import okhttp3.RequestBody; +import okhttp3.ResponseBody; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.BufferedReader; +import java.io.IOException; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Dify聊天控制器 + *

+ * 该控制器用于处理与Dify AI聊天服务的通信,提供流式聊天功能。 + * Dify是一个LLM应用开发平台,此控制器通过调用其API实现与AI模型的交互。 + *

+ */ +@RestController +@RequestMapping("/api/chat") +public class DifyChatController extends BaseController { + + /** + * Dify API的URL地址 + * 用于发送聊天消息请求到Dify服务 + */ + private static final String DIFY_API_URL = "https://api.dify.ai/v1/chat-messages"; + + /** + * Dify反馈API的基础URL + * 用于提交消息反馈(点赞、点踩等) + */ + private static final String DIFY_FEEDBACK_BASE_URL = "https://api.dify.ai/v1/messages"; + + private static final String DIFY_API_HISTORY_URL = "https://api.dify.ai/v1/messages"; + + /** + * Dify会话API的基础URL + * 用于获取会话列表 + */ + private static final String DIFY_CONVERSATIONS_URL = "https://api.dify.ai/v1/conversations"; + + /** + * Dify API的访问密钥 + * 用于身份验证,授权访问Dify服务 + */ + private static final String DIFY_API_KEY = "app-7DEqa9NIpCMGcRJK9mg7zL2f"; + + /** + * HTTP客户端实例 + * 配置了5分钟的读取超时时间,用于与Dify API进行通信 + */ + private final OkHttpClient client = new OkHttpClient.Builder() + .readTimeout(Duration.ofMinutes(5)) + .build(); + + /** + * JSON对象映射器 + * 用于处理JSON数据的序列化和反序列化 + */ + private final ObjectMapper mapper = new ObjectMapper(); + + /** + * 处理流式聊天请求的端点 + *

+ * 该方法接收客户端发送的聊天请求,并通过SSE(Server-Sent Events)方式将响应流式传输回客户端。 + * 使用异步处理避免阻塞主线程,提高系统并发处理能力。 + *

+ * + * @param requestData 包含聊天请求数据的Map,应包含以下字段: + * - query: 用户的聊天消息内容(必需) + * - user: 用户标识符(必需) + * - conversation_id: 对话ID,用于维持对话上下文(可选) + * - inputs: 输入参数,用于传递额外的上下文信息(可选) + * @return SseEmitter 用于向客户端流式传输响应的SSE发射器 + */ + @PostMapping("/stream") + public SseEmitter stream(@org.springframework.web.bind.annotation.RequestBody Map requestData) { + // 创建SSE发射器,设置超时时间为5分钟(300,000毫秒) + SseEmitter emitter = new SseEmitter(300_000L); + + // 设置超时处理回调 + emitter.onTimeout(emitter::complete); + + // 设置错误处理回调 + emitter.onError(ex -> emitter.completeWithError(ex)); + + // 异步执行请求处理,避免阻塞主线程 + CompletableFuture.runAsync(() -> { + try { + sendToDifyAndStream(requestData, emitter); + } catch (Exception e) { + emitter.completeWithError(e); + } + }); + + return emitter; + } + + /** + * 发送请求到Dify并流式传输响应 + *

+ * 该方法构建请求参数,调用Dify API,并处理返回的流式响应数据。 + * 根据Dify API返回的不同事件类型,将数据通过SSE发送给客户端。 + *

+ * + * @param requestData 包含聊天请求数据的Map,包含用户消息、用户标识等信息 + * @param emitter 用于向客户端发送SSE事件的发射器 + * @throws IOException 当网络请求或IO操作失败时抛出 + */ + private void sendToDifyAndStream(Map requestData, SseEmitter emitter) throws IOException { + // 检查必需的参数是否存在且不为空 + if (!requestData.containsKey("user_name") || requestData.get("user_name") == null || requestData.get("user_name").toString().trim().isEmpty()) { + try { + emitter.send(SseEmitter.event().name("error").data("user_name 参数不能为空")); + emitter.complete(); + return; + } catch (Exception e) { + emitter.completeWithError(e); + return; + } + } + + if (!requestData.containsKey("user_token") || requestData.get("user_token") == null || requestData.get("user_token").toString().trim().isEmpty()) { + try { + emitter.send(SseEmitter.event().name("error").data("user_token 参数不能为空")); + emitter.complete(); + return; + } catch (Exception e) { + emitter.completeWithError(e); + return; + } + } + + if (!requestData.containsKey("user_role") || requestData.get("user_role") == null || requestData.get("user_role").toString().trim().isEmpty()) { + try { + emitter.send(SseEmitter.event().name("error").data("user_role 参数不能为空")); + emitter.complete(); + return; + } catch (Exception e) { + emitter.completeWithError(e); + return; + } + } + + // 构建请求体参数 + Map bodyMap = new HashMap<>(); + bodyMap.put("query", requestData.get("query")); // 用户消息内容 + bodyMap.put("user", requestData.get("user")); // 用户标识 + bodyMap.put("response_mode", "streaming"); // 设置为流式响应模式 + + // 如果存在对话ID,则添加到请求参数中 + if (requestData.containsKey("conversation_id")) { + bodyMap.put("conversation_id", requestData.get("conversation_id")); + } + + // 添加输入参数,默认为空HashMap + Map inputs = (Map) requestData.getOrDefault("inputs", new HashMap<>()); + + // 添加用户相关信息到inputs中 + inputs.put("user_name", requestData.get("user_name")); + inputs.put("user_token", requestData.get("user_token")); + inputs.put("user_role", requestData.get("user_role")); + + bodyMap.put("inputs", inputs); + + // 自动为对话生成名称 + bodyMap.put("auto_generate_name", false); + + // 将请求参数转换为JSON字符串 + String jsonBody = mapper.writeValueAsString(bodyMap); + + // 创建请求体对象 + RequestBody body = RequestBody.create(jsonBody, MediaType.get("application/json; charset=utf-8")); + + // 构建HTTP请求 + Request httpRequest = new Request.Builder() + .url(DIFY_API_URL) // 设置请求URL + .addHeader("Authorization", "Bearer " + DIFY_API_KEY) // 添加认证头 + .addHeader("Content-Type", "application/json") // 设置内容类型 + .post(body) // 设置为POST请求 + .build(); + + // 执行HTTP请求 + try (Response httpResponse = client.newCall(httpRequest).execute()) { + // 检查响应是否成功 + if (!httpResponse.isSuccessful()) { + emitter.send(SseEmitter.event().name("error") + .data("Dify 请求失败: " + httpResponse.code() + " " + httpResponse.message())); + emitter.complete(); + return; + } + + // 读取响应流数据 + try (BufferedReader reader = new BufferedReader(httpResponse.body().charStream())) { + String line; + // 逐行读取响应数据 + while ((line = reader.readLine()) != null) { + // 跳过空行和非数据行 + if (line.isEmpty() || !line.startsWith("data:")) continue; + + // 提取JSON数据部分 + String jsonData = line.substring(5).trim(); + + // 如果是结束标记则退出循环 + if ("[DONE]".equals(jsonData)) break; + + // 解析JSON数据 + JsonNode node = mapper.readTree(jsonData); + String eventType = node.get("event").asText(); + + // 根据事件类型处理不同的响应 + switch (eventType) { + case "message": + // 处理消息事件,发送回答内容 + String answer = node.has("answer") ? node.get("answer").asText() : ""; + emitter.send(SseEmitter.event().name("message").data(answer)); + break; + case "message_end": + // 处理消息结束事件,发送完整的消息节点 + emitter.send(SseEmitter.event().name("end").data(node)); + break; + case "error": + // 处理错误事件,发送错误信息 + String errorMsg = node.has("message") ? node.get("message").asText() : "未知错误"; + emitter.send(SseEmitter.event().name("error").data(errorMsg)); + break; + default: + break; + } + } + } + // 完成SSE流传输 + emitter.complete(); + } catch (Exception e) { + // 处理异常情况 + emitter.completeWithError(e); + } + } + + /** + * 提交消息反馈(点赞、点踩、撤销、文本反馈) + *

+ * 该接口代理前端调用 Dify 的反馈 API,避免在前端暴露 API Key。 + * 支持:'like', 'dislike', 或 null(撤销) + *

+ * + * @param feedbackData 包含 feedback 信息的 JSON 对象 + * 示例: + * { + * "message_id": "msg-123", + * "user": "user-1", + * "rating": "like", // "like", "dislike", 或 null + * "content": "回答很好" + * } + * @return 统一响应结果 + */ + @PostMapping("/feedback") + public AjaxResult submitFeedback(@org.springframework.web.bind.annotation.RequestBody Map feedbackData) { + // 校验必要字段 + String messageId = (String) feedbackData.get("message_id"); + if (messageId == null || messageId.trim().isEmpty()) { + return AjaxResult.error("message_id 不能为空"); + } + + // 获取 user(必填,建议前端传) + Object userObj = feedbackData.get("user"); + if (userObj == null || userObj.toString().trim().isEmpty()) { + return AjaxResult.error("user 不能为空"); + } + String user = userObj.toString(); + + // 处理 rating:支持 "like", "dislike", null(撤销) + Object ratingObj = feedbackData.get("rating"); + String rating = null; + + if (ratingObj != null) { + String raw = ratingObj.toString(); + if ("like".equals(raw)) { + rating = "like"; + } else if ("dislike".equals(raw)) { + rating = "dislike"; + } else { + return AjaxResult.error("rating 必须是 'like'、'dislike' 或 null(撤销)"); + } + } + // 如果 ratingObj 为 null,rating 保持 null,表示撤销 + + // 可选 content + String content = (String) feedbackData.get("content"); + + // 构建请求体(发送给 Dify) + Map bodyMap = new HashMap<>(); + bodyMap.put("user", user); + bodyMap.put("rating", rating); // 可以是 "like", "dislike", 或 null + bodyMap.put("content", content); // 可选文本反馈 + + try { + // 序列化为 JSON + String jsonBody = mapper.writeValueAsString(bodyMap); + okhttp3.RequestBody body = okhttp3.RequestBody.create( + jsonBody, + MediaType.get("application/json; charset=utf-8") + ); + + // 调用 Dify API + Request request = new Request.Builder() + .url(DIFY_FEEDBACK_BASE_URL + "/" + messageId + "/feedbacks") + .addHeader("Authorization", "Bearer " + DIFY_API_KEY) + .addHeader("Content-Type", "application/json") + .post(body) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (response.isSuccessful()) { + return AjaxResult.success("反馈提交成功"); + } else { + String errorMsg = "Dify 反馈失败: " + response.code(); + try (ResponseBody errorBody = response.body()) { + if (errorBody != null) { + errorMsg += " - " + errorBody.string(); + } + } catch (IOException e) { + errorMsg += " (无法读取错误详情)"; + } + return AjaxResult.error(errorMsg); + } + } + } catch (Exception e) { + return AjaxResult.error("提交反馈失败: " + e.getMessage()); + } + } + + /** + * 获取会话历史消息的端点 + *

+ * 该方法接收客户端发送的请求,获取指定会话的历史消息记录。 + *

+ * + * @param conversationId 会话ID + * @param user 用户标识符 + * @param firstId 当前页第一条聊天记录的ID,默认null + * @param limit 一次请求返回多少条记录,默认20条 + * @return AjaxResult 返回会话历史消息的结果 + */ + @GetMapping("/history") + public AjaxResult getHistoryMessages( + @RequestParam(required = false) String conversationId, + @RequestParam String user, + @RequestParam(required = false) String firstId, + @RequestParam(defaultValue = "20") int limit) { + try { + // 验证conversationId是否为空 + if (conversationId == null || conversationId.trim().isEmpty()) { + return error("会话ID不能为空"); + } + + // 构建请求参数 + HttpUrl.Builder urlBuilder = HttpUrl.parse(DIFY_API_HISTORY_URL).newBuilder(); + urlBuilder.addQueryParameter("conversation_id", conversationId); + urlBuilder.addQueryParameter("user", user); + if (firstId != null) { + urlBuilder.addQueryParameter("first_id", firstId); + } + urlBuilder.addQueryParameter("limit", String.valueOf(limit)); + + // 构建HTTP请求 + Request request = new Request.Builder() + .url(urlBuilder.build()) + .addHeader("Authorization", "Bearer " + DIFY_API_KEY) + .get() + .build(); + + // 执行HTTP请求 + try (Response response = client.newCall(request).execute()) { + // 检查响应是否成功 + if (!response.isSuccessful()) { + return error("Dify 请求失败: " + response.code() + " " + response.message()); + } + + // 解析JSON响应 + JsonNode rootNode = mapper.readTree(response.body().string()); + boolean hasMore = rootNode.path("has_more").asBoolean(false); + List> data = mapper.convertValue(rootNode.path("data"), List.class); + + // 构建返回结果 + Map result = new HashMap<>(); + result.put("limit", limit); + result.put("has_more", hasMore); + result.put("data", data); + + return success(result); + } + } catch (IOException e) { + return error("获取会话历史消息失败: " + e.getMessage()); + } + } + + /** + * 获取会话列表 + *

+ * 该方法用于获取当前用户的会话列表,默认返回最近的 20 条。 + *

+ * + * @param user 用户标识符,由开发者定义规则,需保证用户标识在应用内唯一 + * @param lastId 当前页最后一条记录的 ID,默认 null + * @param limit 一次请求返回多少条记录,默认 20 条,最大 100 条,最小 1 条 + * @param sortBy 排序字段,默认 -updated_at (按更新时间倒序排列) + * @return AjaxResult 返回会话列表的结果 + */ + @GetMapping("/conversations") + public AjaxResult getConversations( + @RequestParam String user, + @RequestParam(required = false) String lastId, + @RequestParam(defaultValue = "20") int limit, + @RequestParam(defaultValue = "-updated_at") String sortBy) { + try { + // 参数校验 + if (user == null || user.trim().isEmpty()) { + return error("用户标识不能为空"); + } + + // 限制limit的范围在1-100之间 + if (limit < 1 || limit > 100) { + return error("limit参数必须在1到100之间"); + } + + // 构建请求参数 + HttpUrl.Builder urlBuilder = HttpUrl.parse(DIFY_CONVERSATIONS_URL).newBuilder(); + urlBuilder.addQueryParameter("user", user); + if (lastId != null) { + urlBuilder.addQueryParameter("last_id", lastId); + } + urlBuilder.addQueryParameter("limit", String.valueOf(limit)); + urlBuilder.addQueryParameter("sort_by", sortBy); + + // 构建HTTP请求 + Request request = new Request.Builder() + .url(urlBuilder.build()) + .addHeader("Authorization", "Bearer " + DIFY_API_KEY) + .get() + .build(); + + // 执行HTTP请求 + try (Response response = client.newCall(request).execute()) { + // 检查响应是否成功 + if (!response.isSuccessful()) { + return error("Dify 请求失败: " + response.code() + " " + response.message()); + } + + // 解析JSON响应 + JsonNode rootNode = mapper.readTree(response.body().string()); + int resLimit = rootNode.path("limit").asInt(); + boolean hasMore = rootNode.path("has_more").asBoolean(false); + List> data = mapper.convertValue(rootNode.path("data"), List.class); + + // 构建返回结果 + Map result = new HashMap<>(); + result.put("limit", resLimit); + result.put("has_more", hasMore); + result.put("data", data); + + return success(result); + } + } catch (IOException e) { + return error("获取会话列表失败: " + e.getMessage()); + } + } + +}