diff --git a/srs-admin/src/main/java/com/srs/web/controller/aitutor/AiChatController.java b/srs-admin/src/main/java/com/srs/web/controller/aitutor/AiChatController.java new file mode 100644 index 0000000..ad10d6c --- /dev/null +++ b/srs-admin/src/main/java/com/srs/web/controller/aitutor/AiChatController.java @@ -0,0 +1,591 @@ +package com.srs.web.controller.aitutor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.srs.common.core.controller.BaseController; + +// OkHttp 显式导入 +import okhttp3.*; + +// Spring 显式导入(不要用 *) +import com.srs.common.core.domain.AjaxResult; // ✅ RuoYi 的返回结果类 +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.ArrayList; +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("/aitutor/aichat") +public class AiChatController extends BaseController { + /** + * Dify API的URL地址 + * 用于发送聊天消息请求到Dify服务 + */ + + private static final String DIFY_API_URL = "http://47.112.118.149:8100/v1/chat-messages"; + //private static final String DIFY_API_URL = "http://localhost:8080/v1/chat-messages"; + + /** + * Dify反馈API的基础URL + * 用于提交消息反馈(点赞、点踩等) + */ + private static final String DIFY_FEEDBACK_BASE_URL = "http://47.112.118.149:8100/v1/messages"; + //private static final String DIFY_FEEDBACK_BASE_URL = "http://localhost:8080/v1/messages"; + + private static final String DIFY_API_HISTORY_URL = "http://47.112.118.149:8100/v1/messages"; + //private static final String DIFY_API_HISTORY_URL = "http://localhost:8080/v1/messages"; + + /** + * Dify会话API的基础URL + * 用于获取会话列表 + */ + + private static final String DIFY_CONVERSATIONS_URL = "http://47.112.118.149:8100/v1/conversations"; + //private static final String DIFY_CONVERSATIONS_URL = "http://localhost:8080/v1/conversations"; + + /** + * Dify API的访问密钥 + * 用于身份验证,授权访问Dify服务 + */ + private static final String DIFY_API_KEY = "app-2wjqcYI9n6igHTVHdH8qXlnh"; + //private static final String DIFY_API_KEY = "app-"; + + + /** + * 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) { + + SseEmitter emitter = new SseEmitter(300_000L); + + // 设置超时处理回调 + emitter.onTimeout(() -> { + System.out.println("!!! SSE连接超时 !!!"); + emitter.complete(); + }); + + // 设置错误处理回调 + emitter.onError(ex -> { + System.out.println("!!! SSE连接发生错误 !!!"); + ex.printStackTrace(); + emitter.completeWithError(ex); + }); + + // 异步执行请求处理,避免阻塞主线程 + CompletableFuture.runAsync(() -> { + try { + sendToDifyAndStream(requestData, emitter); + } catch (Exception e) { + e.printStackTrace(); + try { + emitter.send(SseEmitter.event().name("error").data("服务器内部错误: " + e.getMessage())); + } catch (IOException ioException) { + ioException.printStackTrace(); + } + 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 { + + // 构建请求体参数 + 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<>()); + + // 从requestData中获取用户相关信息并添加到inputs中(如果存在且不为null) + Object userId = requestData.get("user_id"); + if (userId != null) { + inputs.put("user_id", userId.toString()); + } + + Object userName = requestData.get("user_name"); + if (userName != null) { + inputs.put("user_name", userName); + } + + Object userToken = requestData.get("user_token"); + if (userToken != null) { + inputs.put("user_token", userToken); + } + + Object userRole = requestData.get("user_role"); + if (userRole != null) { + inputs.put("user_role", userRole); + } + + bodyMap.put("inputs", inputs); + + // 自动为对话生成名称 + bodyMap.put("auto_generate_name", false); + + // 将请求参数转换为JSON字符串 + String jsonBody = mapper.writeValueAsString(bodyMap); + + // 创建请求体对象 + RequestBody body = RequestBody.create( MediaType.get("application/json; charset=utf-8"),jsonBody); + + // 构建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()) { + String errorMsg = "Dify 请求失败: " + httpResponse.code() + " " + httpResponse.message(); + System.out.println(errorMsg); + try { + httpResponse.body().string(); + } catch (Exception e) { + System.out.println("无法读取响应体: " + e.getMessage()); + } + emitter.send(SseEmitter.event().name("error").data(errorMsg)); + 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; + 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 "workflow_started": + case "node_started": + case "node_finished": + case "message_start": + case "message": + case "message_end": + case "error": + case "ping": + // 直接转发Dify的原始SSE数据格式 + emitter.send(SseEmitter.event().data(jsonData)); + break; + default: + break; + } + } + } + // 完成SSE流传输 + emitter.complete(); + } catch (Exception e) { + // 处理异常情况 + e.printStackTrace(); + 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( + MediaType.get("application/json; charset=utf-8"), + jsonBody + ); + + // 调用 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()); + } + } + + /** + * 获取APP的消息点赞和反馈列表 + *

+ * 该接口用于获取应用的终端用户反馈、点赞列表 + *

+ * + * @param page 页码,默认值:1 + * @param limit 每页数量,默认值:20 + * @return 包含点赞、反馈列表的统一响应结果 + */ + @GetMapping("/app/feedbacks") + public AjaxResult getAppFeedbacks( + @RequestParam(value = "page", defaultValue = "1") String page, + @RequestParam(value = "limit", defaultValue = "20") String limit) { + + try { + // 构建请求URL + String url = "http://47.112.118.149:8100/v1/app/feedbacks?page=" + page + "&limit=" + limit; + //String url = "http://localhost:8080/v1/app/feedbacks?page=" + page + "&limit=" + limit; + // 构建请求 + Request request = new Request.Builder() + .url(url) + .addHeader("Authorization", "Bearer " + DIFY_API_KEY) + .addHeader("Content-Type", "application/json") + .get() + .build(); + + // 发送请求 + try (Response response = client.newCall(request).execute()) { + if (response.isSuccessful()) { + String responseBody = response.body().string(); + JsonNode rootNode = mapper.readTree(responseBody); + + // 解析数据 + JsonNode dataNode = rootNode.get("data"); + List> feedbackList = new ArrayList<>(); + + if (dataNode != null && dataNode.isArray()) { + for (JsonNode feedbackNode : dataNode) { + Map feedbackItem = new HashMap<>(); + + // 提取反馈信息 + feedbackItem.put("id", feedbackNode.has("id") ? feedbackNode.get("id").asText() : null); + feedbackItem.put("message_id", feedbackNode.has("message_id") ? feedbackNode.get("message_id").asText() : null); + feedbackItem.put("rating", feedbackNode.has("rating") ? feedbackNode.get("rating").asText() : null); + feedbackItem.put("content", feedbackNode.has("content") ? feedbackNode.get("content").asText() : null); + feedbackItem.put("created_at", feedbackNode.has("created_at") ? feedbackNode.get("created_at").asLong() : null); + feedbackItem.put("app_id", feedbackNode.has("app_id") ? feedbackNode.get("app_id").asText() : null); + feedbackItem.put("conversation_id", feedbackNode.has("conversation_id") ? feedbackNode.get("conversation_id").asText() : null); + + // 提取用户信息 + if (feedbackNode.has("from_end_user")) { + JsonNode userNode = feedbackNode.get("from_end_user"); + Map userMap = new HashMap<>(); + userMap.put("id", userNode.has("id") ? userNode.get("id").asText() : null); + userMap.put("name", userNode.has("name") ? userNode.get("name").asText() : null); + userMap.put("email", userNode.has("email") ? userNode.get("email").asText() : null); + feedbackItem.put("from_end_user", userMap); + } + feedbackList.add(feedbackItem); + } + } + + // 构建返回结果 + Map result = new HashMap<>(); + result.put("data", feedbackList); + result.put("page", rootNode.has("page") ? rootNode.get("page").asInt() : Integer.parseInt(page)); + result.put("limit", rootNode.has("limit") ? rootNode.get("limit").asInt() : Integer.parseInt(limit)); + result.put("has_more", rootNode.has("has_more") ? rootNode.get("has_more").asBoolean() : false); + + return AjaxResult.success(result); + } else { + String errorMsg = "获取反馈列表失败: " + 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 = new ArrayList<>(); + + // 解析会话列表数据 + JsonNode dataArray = rootNode.path("data"); + if (dataArray.isArray()) { + for (JsonNode conversationNode : dataArray) { + Map conversation = new HashMap<>(); + conversation.put("id", conversationNode.path("id").asText()); + conversation.put("name", conversationNode.path("name").asText()); + conversation.put("inputs", mapper.convertValue(conversationNode.path("inputs"), Map.class)); + conversation.put("status", conversationNode.path("status").asText()); + conversation.put("introduction", conversationNode.path("introduction").asText()); + conversation.put("created_at", conversationNode.path("created_at").asLong()); + conversation.put("updated_at", conversationNode.path("updated_at").asLong()); + data.add(conversation); + } + } + + // 构建返回结果 + 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()); + } + } +}