From 0bb4853d547eb6a65c7cf37548590a1e97b87e69 Mon Sep 17 00:00:00 2001 From: wangys <3401275564@qq.com> Date: Thu, 6 Nov 2025 16:43:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=B5=81=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=20=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.bin/mysql/sql/createTableAI.sql | 24 +- schoolNewsServ/.bin/mysql/sql/initAllData.sql | 5 - schoolNewsServ/ai/pom.xml | 5 + .../org/xyzh/ai/client/DifyApiClient.java | 102 ++++-- .../org/xyzh/ai/client/dto/ChatRequest.java | 28 +- .../java/org/xyzh/ai/config/DifyConfig.java | 3 +- .../xyzh/ai/controller/AiChatController.java | 69 ++-- .../ai/controller/AiFileUploadController.java | 29 ++ .../xyzh/ai/mapper/AiUploadFileMapper.java | 14 + .../ai/service/AiKnowledgeRedisService.java | 25 ++ .../impl/AiAgentConfigServiceImpl.java | 11 - .../impl/AiChatHistoryServiceImpl.java | 14 +- .../ai/service/impl/AiChatServiceImpl.java | 305 ++++++++++++--- .../impl/AiKnowledgeRedisServiceImpl.java | 170 +++++++++ .../service/impl/AiUploadFileServiceImpl.java | 346 +++++++++++++----- .../resources/mapper/AiAgentConfigMapper.xml | 38 +- .../resources/mapper/AiUploadFileMapper.xml | 62 +++- .../org/xyzh/api/ai/chat/AiChatService.java | 21 +- .../org/xyzh/api/ai/dto/DifyFileInfo.java | 99 +++++ .../xyzh/api/ai/file/AiUploadFileService.java | 19 + .../java/org/xyzh/api/file/FileService.java | 30 ++ schoolNewsServ/api/pom.xml | 4 + .../xyzh/common/dto/ai/TbAiAgentConfig.java | 86 +---- .../xyzh/common/dto/ai/TbAiUploadFile.java | 39 ++ .../xyzh/file/service/FileServiceImpl.java | 116 ++++++ .../file/strategy/FileStorageStrategy.java | 9 + .../impl/LocalFileStorageStrategy.java | 5 + .../impl/MinIOFileStorageStrategy.java | 5 + schoolNewsWeb/src/apis/ai/agent-config.ts | 8 +- schoolNewsWeb/src/apis/ai/chat-history.ts | 2 +- schoolNewsWeb/src/apis/ai/chat.ts | 216 ++++++----- schoolNewsWeb/src/apis/ai/file-upload.ts | 38 +- schoolNewsWeb/src/types/ai/index.ts | 15 +- .../views/admin/manage/ai/AIConfigView.vue | 49 ++- schoolNewsWeb/src/views/public/ai/AIAgent.vue | 312 ++++++++++++---- 35 files changed, 1748 insertions(+), 575 deletions(-) create mode 100644 schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/AiKnowledgeRedisService.java create mode 100644 schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiKnowledgeRedisServiceImpl.java create mode 100644 schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/dto/DifyFileInfo.java diff --git a/schoolNewsServ/.bin/mysql/sql/createTableAI.sql b/schoolNewsServ/.bin/mysql/sql/createTableAI.sql index 87a0985..5b70c78 100644 --- a/schoolNewsServ/.bin/mysql/sql/createTableAI.sql +++ b/schoolNewsServ/.bin/mysql/sql/createTableAI.sql @@ -6,12 +6,7 @@ CREATE TABLE `tb_ai_agent_config` ( `name` VARCHAR(100) NOT NULL COMMENT '智能体名称', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '智能体头像', `description` VARCHAR(500) DEFAULT NULL COMMENT '智能体描述', - `system_prompt` TEXT COMMENT '系统提示词', - `model_name` VARCHAR(100) DEFAULT NULL COMMENT '模型名称', - `model_provider` VARCHAR(50) DEFAULT NULL COMMENT '模型提供商', - `temperature` DECIMAL(3,2) DEFAULT 0.70 COMMENT '温度值', - `max_tokens` INT(11) DEFAULT 2000 COMMENT '最大tokens', - `top_p` DECIMAL(3,2) DEFAULT 1.00 COMMENT 'Top P值', + `connect_internet` INT(4) DEFAULT 0 COMMENT '是否连接互联网(0否 1是)', `dify_app_id` VARCHAR(100) DEFAULT NULL COMMENT 'Dify应用ID', `dify_api_key` VARCHAR(255) DEFAULT NULL COMMENT 'Dify应用API密钥', `status` INT(4) DEFAULT 1 COMMENT '状态(0禁用 1启用)', @@ -125,10 +120,12 @@ CREATE TABLE `tb_ai_message` ( -- 上传文件表 DROP TABLE IF EXISTS `tb_ai_upload_file`; CREATE TABLE `tb_ai_upload_file` ( - `id` VARCHAR(50) NOT NULL COMMENT '文件ID', + `id` VARCHAR(50) NOT NULL COMMENT 'ID', `user_id` VARCHAR(50) NOT NULL COMMENT '用户ID', `knowledge_id` VARCHAR(50) DEFAULT NULL COMMENT '所属知识库ID', `conversation_id` VARCHAR(50) DEFAULT NULL COMMENT '关联会话ID(对话中上传)', + `message_id` VARCHAR(50) DEFAULT NULL COMMENT '关联消息ID(绑定到具体的用户消息)', + `sys_file_id` VARCHAR(32) DEFAULT NULL COMMENT '系统文件ID(关联tb_sys_file实现永久存储)', `file_name` VARCHAR(255) NOT NULL COMMENT '文件名', `file_path` VARCHAR(500) NOT NULL COMMENT '文件路径', `file_size` BIGINT(20) DEFAULT 0 COMMENT '文件大小(字节)', @@ -137,6 +134,7 @@ CREATE TABLE `tb_ai_upload_file` ( `extracted_text` LONGTEXT COMMENT '提取的文本内容', `dify_document_id` VARCHAR(100) DEFAULT NULL COMMENT 'Dify文档ID', `dify_batch_id` VARCHAR(100) DEFAULT NULL COMMENT 'Dify批次ID', + `dify_upload_file_id` VARCHAR(100) DEFAULT NULL COMMENT 'Dify上传文件ID(对话中上传的文件)', `chunk_count` INT(11) DEFAULT 0 COMMENT '分段数量', `status` INT(4) DEFAULT 0 COMMENT '状态(0上传中 1处理中 2已完成 3失败)', `error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息', @@ -148,11 +146,14 @@ CREATE TABLE `tb_ai_upload_file` ( KEY `idx_user` (`user_id`), KEY `idx_knowledge` (`knowledge_id`), KEY `idx_conversation` (`conversation_id`), + KEY `idx_message` (`message_id`), + KEY `idx_sys_file` (`sys_file_id`), KEY `idx_dify_document` (`dify_document_id`), + KEY `idx_dify_upload_file` (`dify_upload_file_id`), KEY `idx_status` (`status`), KEY `idx_create_time` (`create_time`), KEY `idx_deleted` (`deleted`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='上传文件表'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='上传文件表(关联系统文件表)'; -- AI使用统计表 DROP TABLE IF EXISTS `tb_ai_usage_statistics`; @@ -214,11 +215,10 @@ CREATE TABLE `tb_ai_usage_statistics` ( -- 插入默认智能体配置 INSERT INTO `tb_ai_agent_config` - (`id`, `name`, `avatar`, `description`, `system_prompt`, `model_name`, `model_provider`, `status`, `creator`, `create_time`) + (`id`, `name`, `avatar`, `description`, `connect_internet`, `status`, `creator`, `create_time`) VALUES - ('agent_default_001', '校园助手', '/img/agent/default.png', '我是您的智能校园助手,可以帮助您解答校园相关问题', - '你是一个友好、专业的校园助手。你需要基于校园知识库回答用户问题,语气亲切自然。如果知识库中没有相关信息,请诚实告知用户。', - 'gpt-3.5-turbo', 'openai', 1, '1', NOW()); + ('agent_default_001', '校园助手', NULL, '我是您的智能校园助手,可以帮助您解答校园相关问题', + 0, 1, '1', NOW()); -- 插入示例知识库(需要配合权限表使用) INSERT INTO `tb_ai_knowledge` diff --git a/schoolNewsServ/.bin/mysql/sql/initAllData.sql b/schoolNewsServ/.bin/mysql/sql/initAllData.sql index f0db3e9..50e4ace 100644 --- a/schoolNewsServ/.bin/mysql/sql/initAllData.sql +++ b/schoolNewsServ/.bin/mysql/sql/initAllData.sql @@ -1,9 +1,4 @@ use school_news; - --- 插入AI智能体配置数据 -INSERT INTO `tb_ai_agent_config` (id, name, system_prompt, model_name, temperature, max_tokens, status, creator, create_time) VALUES -('1', '思政小帮手', '你是一个专业的思政学习助手,致力于帮助用户学习思想政治理论知识。请基于提供的知识库内容,为用户提供准确、简洁的回答。', 'gpt-3.5-turbo', 0.7, 2000, 1, '1', now()); - -- 插入标签数据 (文章分类标签 tag_type=1) INSERT INTO `tb_tag` (id, tag_id, name, color, description, tag_type, creator, create_time) VALUES ('tag001', 'tag_article_001', '党史学习', '#ff6b6b', '党史学习相关文章', 1, '1', now()), diff --git a/schoolNewsServ/ai/pom.xml b/schoolNewsServ/ai/pom.xml index bc132c8..f0955fc 100644 --- a/schoolNewsServ/ai/pom.xml +++ b/schoolNewsServ/ai/pom.xml @@ -27,6 +27,11 @@ api-ai ${school-news.version} + + org.xyzh + api-file + ${school-news.version} + org.xyzh diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/client/DifyApiClient.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/client/DifyApiClient.java index e19223b..8937877 100644 --- a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/client/DifyApiClient.java +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/client/DifyApiClient.java @@ -1,7 +1,7 @@ package org.xyzh.ai.client; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; import jakarta.annotation.PostConstruct; import okhttp3.*; @@ -13,6 +13,7 @@ import org.xyzh.ai.client.dto.*; import org.xyzh.ai.client.callback.StreamCallback; import org.xyzh.ai.config.DifyConfig; import org.xyzh.ai.exception.DifyException; +import org.xyzh.api.ai.dto.DifyFileInfo; import java.io.BufferedReader; import java.io.File; @@ -37,7 +38,6 @@ public class DifyApiClient { private OkHttpClient httpClient; private OkHttpClient streamHttpClient; - private final ObjectMapper objectMapper = new ObjectMapper(); @PostConstruct public void init() { @@ -69,7 +69,7 @@ public class DifyApiClient { String url = difyConfig.getFullApiUrl("/datasets"); try { - String jsonBody = objectMapper.writeValueAsString(request); + String jsonBody = JSON.toJSONString(request); Request httpRequest = new Request.Builder() .url(url) .header("Authorization", "Bearer " + getApiKey(apiKey)) @@ -85,7 +85,7 @@ public class DifyApiClient { throw new DifyException("创建知识库失败: " + responseBody); } - return objectMapper.readValue(responseBody, DatasetCreateResponse.class); + return JSON.parseObject(responseBody, DatasetCreateResponse.class); } } catch (IOException e) { logger.error("创建知识库异常", e); @@ -114,7 +114,7 @@ public class DifyApiClient { throw new DifyException("查询知识库列表失败: " + responseBody); } - return objectMapper.readValue(responseBody, DatasetListResponse.class); + return JSON.parseObject(responseBody, DatasetListResponse.class); } } catch (IOException e) { logger.error("查询知识库列表异常", e); @@ -143,7 +143,7 @@ public class DifyApiClient { throw new DifyException("查询知识库详情失败: " + responseBody); } - return objectMapper.readValue(responseBody, DatasetDetailResponse.class); + return JSON.parseObject(responseBody, DatasetDetailResponse.class); } } catch (IOException e) { logger.error("查询知识库详情异常", e); @@ -159,7 +159,7 @@ public class DifyApiClient { String url = difyConfig.getFullApiUrl("/datasets/" + datasetId); try { - String jsonBody = objectMapper.writeValueAsString(request); + String jsonBody = JSON.toJSONString(request); Request httpRequest = new Request.Builder() .url(url) .header("Authorization", "Bearer " + getApiKey(apiKey)) @@ -207,6 +207,48 @@ public class DifyApiClient { } } + // ===================== 对话文件上传 API ===================== + + /** + * 上传文件用于对话(图文多模态) + * @param file 文件 + * @param originalFilename 原始文件名 + * @param user 用户标识 + * @param apiKey API密钥 + * @return 文件信息(包含id、name、size等) + */ + public DifyFileInfo uploadFileForChat(File file, String originalFilename, String user, String apiKey) { + String url = difyConfig.getFullApiUrl("/files/upload"); + + try { + MultipartBody.Builder bodyBuilder = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", originalFilename, + RequestBody.create(file, MediaType.parse("application/octet-stream"))) + .addFormDataPart("user", user); + + Request httpRequest = new Request.Builder() + .url(url) + .header("Authorization", "Bearer " + getApiKey(apiKey)) + .post(bodyBuilder.build()) + .build(); + + try (Response response = httpClient.newCall(httpRequest).execute()) { + String responseBody = response.body() != null ? response.body().string() : ""; + + if (!response.isSuccessful()) { + logger.error("上传对话文件失败: {} - {}", response.code(), responseBody); + throw new DifyException("上传对话文件失败: " + responseBody); + } + + return JSON.parseObject(responseBody, DifyFileInfo.class); + } + } catch (IOException e) { + logger.error("上传对话文件异常", e); + throw new DifyException("上传对话文件异常: " + e.getMessage(), e); + } + } + // ===================== 文档管理 API ===================== /** @@ -235,7 +277,7 @@ public class DifyApiClient { bodyBuilder.addFormDataPart("indexing_technique", uploadRequest.getIndexingTechnique()); } if (uploadRequest.getProcessRule() != null) { - bodyBuilder.addFormDataPart("process_rule", objectMapper.writeValueAsString(uploadRequest.getProcessRule())); + bodyBuilder.addFormDataPart("process_rule", JSON.toJSONString(uploadRequest.getProcessRule())); } Request httpRequest = new Request.Builder() @@ -252,7 +294,7 @@ public class DifyApiClient { throw new DifyException("上传文档失败: " + responseBody); } - return objectMapper.readValue(responseBody, DocumentUploadResponse.class); + return JSON.parseObject(responseBody, DocumentUploadResponse.class); } } catch (IOException e) { logger.error("上传文档异常", e); @@ -281,7 +323,7 @@ public class DifyApiClient { throw new DifyException("查询文档状态失败: " + responseBody); } - return objectMapper.readValue(responseBody, DocumentStatusResponse.class); + return JSON.parseObject(responseBody, DocumentStatusResponse.class); } } catch (IOException e) { logger.error("查询文档状态异常", e); @@ -310,7 +352,7 @@ public class DifyApiClient { throw new DifyException("查询文档列表失败: " + responseBody); } - return objectMapper.readValue(responseBody, DocumentListResponse.class); + return JSON.parseObject(responseBody, DocumentListResponse.class); } } catch (IOException e) { logger.error("查询文档列表异常", e); @@ -354,7 +396,7 @@ public class DifyApiClient { String url = difyConfig.getFullApiUrl("/datasets/" + datasetId + "/retrieve"); try { - String jsonBody = objectMapper.writeValueAsString(request); + String jsonBody = JSON.toJSONString(request); Request httpRequest = new Request.Builder() .url(url) .header("Authorization", "Bearer " + getApiKey(apiKey)) @@ -370,7 +412,7 @@ public class DifyApiClient { throw new DifyException("知识库检索失败: " + responseBody); } - return objectMapper.readValue(responseBody, RetrievalResponse.class); + return JSON.parseObject(responseBody, RetrievalResponse.class); } } catch (IOException e) { logger.error("知识库检索异常", e); @@ -390,7 +432,7 @@ public class DifyApiClient { // 设置为流式模式 request.setResponseMode("streaming"); - String jsonBody = objectMapper.writeValueAsString(request); + String jsonBody = JSON.toJSONString(request); Request httpRequest = new Request.Builder() .url(url) .header("Authorization", "Bearer " + getApiKey(apiKey)) @@ -421,9 +463,9 @@ public class DifyApiClient { } if (!data.isEmpty()) { - // 解析SSE数据 - JsonNode jsonNode = objectMapper.readTree(data); - String event = jsonNode.has("event") ? jsonNode.get("event").asText() : ""; + // 使用Fastjson2解析SSE数据 + JSONObject jsonNode = JSON.parseObject(data); + String event = jsonNode.containsKey("event") ? jsonNode.getString("event") : ""; // 转发所有事件到回调(包含完整数据) callback.onEvent(event, data); @@ -432,8 +474,8 @@ public class DifyApiClient { case "message": case "agent_message": // 消息内容 - if (jsonNode.has("answer")) { - callback.onMessage(jsonNode.get("answer").asText()); + if (jsonNode.containsKey("answer")) { + callback.onMessage(jsonNode.getString("answer")); } break; case "message_end": @@ -442,8 +484,8 @@ public class DifyApiClient { break; case "error": // 错误事件 - String errorMsg = jsonNode.has("message") ? - jsonNode.get("message").asText() : "未知错误"; + String errorMsg = jsonNode.containsKey("message") ? + jsonNode.getString("message") : "未知错误"; callback.onError(new DifyException(errorMsg)); return; // 其他事件(workflow_started、node_started、node_finished等) @@ -481,7 +523,7 @@ public class DifyApiClient { // 设置为阻塞模式 request.setResponseMode("blocking"); - String jsonBody = objectMapper.writeValueAsString(request); + String jsonBody = JSON.toJSONString(request); Request httpRequest = new Request.Builder() .url(url) .header("Authorization", "Bearer " + getApiKey(apiKey)) @@ -497,7 +539,7 @@ public class DifyApiClient { throw new DifyException("阻塞式对话失败: " + responseBody); } - return objectMapper.readValue(responseBody, ChatResponse.class); + return JSON.parseObject(responseBody, ChatResponse.class); } } catch (IOException e) { logger.error("阻塞式对话异常", e); @@ -512,7 +554,7 @@ public class DifyApiClient { String url = difyConfig.getFullApiUrl("/chat-messages/" + taskId + "/stop"); try { - String jsonBody = objectMapper.writeValueAsString(new StopRequest(userId)); + String jsonBody = JSON.toJSONString(new StopRequest(userId)); Request httpRequest = new Request.Builder() .url(url) .header("Authorization", "Bearer " + getApiKey(apiKey)) @@ -546,7 +588,7 @@ public class DifyApiClient { try { FeedbackRequest feedbackRequest = new FeedbackRequest(rating, userId, feedback); - String jsonBody = objectMapper.writeValueAsString(feedbackRequest); + String jsonBody = JSON.toJSONString(feedbackRequest); Request httpRequest = new Request.Builder() .url(url) @@ -614,7 +656,7 @@ public class DifyApiClient { throw new DifyException("获取对话历史失败: " + responseBody); } - return objectMapper.readValue(responseBody, MessageHistoryResponse.class); + return JSON.parseObject(responseBody, MessageHistoryResponse.class); } } catch (IOException e) { logger.error("获取对话历史异常", e); @@ -656,7 +698,7 @@ public class DifyApiClient { throw new DifyException("获取对话列表失败: " + responseBody); } - return objectMapper.readValue(responseBody, ConversationListResponse.class); + return JSON.parseObject(responseBody, ConversationListResponse.class); } } catch (IOException e) { logger.error("获取对话列表异常", e); @@ -710,7 +752,7 @@ public class DifyApiClient { try { String jsonBody = requestBody instanceof String ? - (String) requestBody : objectMapper.writeValueAsString(requestBody); + (String) requestBody : JSON.toJSONString(requestBody); Request httpRequest = new Request.Builder() .url(url) @@ -747,7 +789,7 @@ public class DifyApiClient { try { String jsonBody = requestBody instanceof String ? - (String) requestBody : objectMapper.writeValueAsString(requestBody); + (String) requestBody : JSON.toJSONString(requestBody); Request httpRequest = new Request.Builder() .url(url) diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/client/dto/ChatRequest.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/client/dto/ChatRequest.java index f0f1179..ea186b4 100644 --- a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/client/dto/ChatRequest.java +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/client/dto/ChatRequest.java @@ -2,6 +2,8 @@ package org.xyzh.ai.client.dto; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; +import org.xyzh.api.ai.dto.DifyFileInfo; + import java.util.List; import java.util.Map; @@ -45,7 +47,7 @@ public class ChatRequest { /** * 上传的文件列表 */ - private List files; + private List files; /** * 自动生成标题 @@ -70,29 +72,5 @@ public class ChatRequest { @JsonProperty("max_tokens") private Integer maxTokens; - @Data - public static class FileInfo { - /** - * 文件类型:image、document、audio、video - */ - private String type; - - /** - * 传输方式:remote_url、local_file - */ - @JsonProperty("transfer_method") - private String transferMethod; - - /** - * 文件URL或ID - */ - private String url; - - /** - * 本地文件上传ID - */ - @JsonProperty("upload_file_id") - private String uploadFileId; - } } diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/config/DifyConfig.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/config/DifyConfig.java index f348c8f..9bacf91 100644 --- a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/config/DifyConfig.java +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/config/DifyConfig.java @@ -30,7 +30,8 @@ public class DifyConfig { /** * Dify API密钥(默认密钥,可被智能体的密钥覆盖) */ - private String apiKey="app-PTHzp2DsLPyiUrDYTXBGxL1f"; + // private String apiKey="app-PTHzp2DsLPyiUrDYTXBGxL1f"; + private String apiKey="app-fwOqGFLTsZtekCQYlOmj9f8x"; /** * 请求超时时间(秒) diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/controller/AiChatController.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/controller/AiChatController.java index 8ebe39f..03334b1 100644 --- a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/controller/AiChatController.java +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/controller/AiChatController.java @@ -1,5 +1,6 @@ package org.xyzh.ai.controller; +import com.alibaba.fastjson2.JSON; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; @@ -10,12 +11,13 @@ import org.xyzh.api.ai.history.AiChatHistoryService; import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.core.page.PageDomain; import org.xyzh.common.core.page.PageParam; +import org.xyzh.api.ai.dto.DifyFileInfo; import org.xyzh.common.dto.ai.TbAiConversation; import org.xyzh.common.dto.ai.TbAiMessage; -import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * @description AI对话控制器 @@ -38,35 +40,55 @@ public class AiChatController { // ===================== 对话相关 ===================== /** - * @description 流式对话(SSE) - * @param agentId 智能体ID - * @param conversationId 会话ID - * @param query 用户问题 - * @param knowledgeIds 知识库ID列表(逗号分隔) - * @return SseEmitter SSE流式推送对象 + * @description 准备流式对话会话(POST接收复杂参数) + * @param requestBody 请求体(agentId, conversationId, query, files) + * @return ResultDomain 返回sessionId * @author AI Assistant - * @since 2025-11-04 + * @since 2025-11-06 */ - @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter streamChat( - @RequestParam(name = "agentId") String agentId, - @RequestParam(name = "conversationId", required = false) String conversationId, - @RequestParam(name = "query") String query, - @RequestParam(name = "knowledgeIds", required = false) String knowledgeIds) { + @PostMapping("/stream/prepare") + public ResultDomain prepareStreamChat(@RequestBody Map requestBody) { + String agentId = (String) requestBody.get("agentId"); + String conversationId = (String) requestBody.get("conversationId"); + String query = (String) requestBody.get("query"); - // 解析knowledgeIds - List knowledgeIdList = null; - if (knowledgeIds != null && !knowledgeIds.isEmpty()) { - knowledgeIdList = Arrays.asList(knowledgeIds.split(",")); + // 转换 files 数据 + @SuppressWarnings("unchecked") + List> filesRaw = (List>) requestBody.get("files"); + + List filesData = null; + if (filesRaw != null && !filesRaw.isEmpty()) { + filesData = filesRaw.stream() + .map(fileMap -> { + // 使用Fastjson2转换Map为对象 + String json = JSON.toJSONString(fileMap); + return JSON.parseObject(json, DifyFileInfo.class); + }) + .collect(Collectors.toList()); } - log.info("流式对话: agentId={}, conversationId={}, query={}", agentId, conversationId, query); - return chatService.streamChatWithSse(agentId, conversationId, query, knowledgeIdList); + log.info("准备流式对话会话: agentId={}, query={}, files={}", + agentId, query, filesData != null ? filesData.size() : 0); + + return chatService.prepareChatSession(agentId, conversationId, query, filesData); + } + + /** + * @description 流式对话(SSE)- GET建立SSE连接 + * @param sessionId 会话标识 + * @return SseEmitter SSE流式推送对象 + * @author AI Assistant + * @since 2025-11-06 + */ + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamChat(@RequestParam(name = "sessionId") String sessionId) { + log.info("建立SSE连接: sessionId={}", sessionId); + return chatService.streamChatWithSse(sessionId); } /** * @description 阻塞式对话 - * @param requestBody 请求体(agentId, conversationId, query, knowledgeIds) + * @param requestBody 请求体(agentId, conversationId, query) * @return ResultDomain * @author AI Assistant * @since 2025-11-04 @@ -76,11 +98,8 @@ public class AiChatController { String agentId = (String) requestBody.get("agentId"); String conversationId = (String) requestBody.get("conversationId"); String query = (String) requestBody.get("query"); - @SuppressWarnings("unchecked") - List knowledgeIds = (List) requestBody.get("knowledgeIds"); - log.info("阻塞式对话: agentId={}, conversationId={}, query={}", agentId, conversationId, query); - return chatService.blockingChat(agentId, conversationId, query, knowledgeIds); + return chatService.blockingChat(agentId, conversationId, query); } diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/controller/AiFileUploadController.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/controller/AiFileUploadController.java index 5ffbaf7..3b2c688 100644 --- a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/controller/AiFileUploadController.java +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/controller/AiFileUploadController.java @@ -29,6 +29,22 @@ public class AiFileUploadController { @Autowired private AiUploadFileService uploadFileService; + /** + * @description 上传文件用于对话(图文多模态) + * @param file 文件 + * @param agentId 智能体ID + * @return ResultDomain> 返回Dify文件信息 + * @author AI Assistant + * @since 2025-11-06 + */ + @PostMapping("/upload-for-chat") + public ResultDomain> uploadFileForChat( + @RequestParam("file") MultipartFile file, + @RequestParam("agentId") String agentId) { + log.info("上传对话文件: fileName={}, agentId={}", file.getOriginalFilename(), agentId); + return uploadFileService.uploadFileForChat(file, agentId); + } + /** * @description 上传文件到知识库 * @param knowledgeId 知识库ID @@ -78,6 +94,19 @@ public class AiFileUploadController { return uploadFileService.getFileById(fileId); } + /** + * @description 查询消息关联的文件列表 + * @param messageId 消息ID + * @return ResultDomain + * @author AI Assistant + * @since 2025-11-06 + */ + @GetMapping("/message/{messageId}") + public ResultDomain getMessageFiles(@PathVariable(name = "messageId") String messageId) { + log.info("查询消息文件列表: messageId={}", messageId); + return uploadFileService.listFilesByMessageId(messageId); + } + /** * @description 查询知识库的文件列表 * @param knowledgeId 知识库ID diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/mapper/AiUploadFileMapper.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/mapper/AiUploadFileMapper.java index 84d605f..12c5499 100644 --- a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/mapper/AiUploadFileMapper.java +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/mapper/AiUploadFileMapper.java @@ -68,4 +68,18 @@ public interface AiUploadFileMapper extends BaseMapper { * @since 2025-10-15 */ List selectAiUploadFiles(TbAiUploadFile filter); + + /** + * 根据消息ID查询关联的文件列表 + * @param messageId 消息ID + * @return List 文件列表 + */ + List selectFilesByMessageId(@Param("messageId") String messageId); + + /** + * 批量插入文件记录 + * @param files 文件列表 + * @return 插入行数 + */ + int batchInsertUploadFiles(@Param("files") List files); } diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/AiKnowledgeRedisService.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/AiKnowledgeRedisService.java new file mode 100644 index 0000000..903ac87 --- /dev/null +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/AiKnowledgeRedisService.java @@ -0,0 +1,25 @@ +package org.xyzh.ai.service; + +import java.util.List; + +/** + * @description AI知识库Redis管理服务接口 + * @filename AiKnowledgeRedisService.java + * @author AI Assistant + * @copyright xyzh + * @since 2025-11-06 + */ +public interface AiKnowledgeRedisService { + + /** + * 初始化所有知识库到Redis + * 在系统启动时调用 + */ + void initializeAllKnowledgeToRedis(); + + /** + * 清除所有知识库配置 + */ + void clearAllKnowledgeConfig(); +} + diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiAgentConfigServiceImpl.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiAgentConfigServiceImpl.java index 3df7471..4f83721 100644 --- a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiAgentConfigServiceImpl.java +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiAgentConfigServiceImpl.java @@ -76,17 +76,6 @@ public class AiAgentConfigServiceImpl implements AiAgentConfigService { agentConfig.setStatus(1); // 默认启用 } - // 设置默认模型参数 - if (agentConfig.getTemperature() == null) { - agentConfig.setTemperature(new BigDecimal("0.7")); - } - if (agentConfig.getMaxTokens() == null) { - agentConfig.setMaxTokens(2000); - } - if (agentConfig.getTopP() == null) { - agentConfig.setTopP(new BigDecimal("1.0")); - } - // 5. 插入数据库 int rows = agentConfigMapper.insertAgentConfig(agentConfig); if (rows > 0) { diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiChatHistoryServiceImpl.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiChatHistoryServiceImpl.java index 7e72fc1..0401cde 100644 --- a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiChatHistoryServiceImpl.java +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiChatHistoryServiceImpl.java @@ -1,7 +1,7 @@ package org.xyzh.ai.service.impl; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONWriter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -38,8 +38,6 @@ public class AiChatHistoryServiceImpl implements AiChatHistoryService { @Autowired private AiMessageMapper messageMapper; - private final ObjectMapper objectMapper = new ObjectMapper(); - @Override public PageDomain pageUserConversations( String agentId, @@ -539,17 +537,13 @@ public class AiChatHistoryServiceImpl implements AiChatHistoryService { exportData.put("messages", messages); exportData.put("exportTime", new Date()); - // 转JSON - String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(exportData); + // 使用Fastjson2转JSON(格式化输出) + String json = JSON.toJSONString(exportData, JSONWriter.Feature.PrettyFormat); log.info("导出会话JSON成功: {}", conversationId); resultDomain.success("导出成功", json); return resultDomain; - } catch (JsonProcessingException e) { - log.error("JSON序列化失败", e); - resultDomain.fail("导出失败: JSON序列化错误"); - return resultDomain; } catch (Exception e) { log.error("导出会话JSON失败", e); resultDomain.fail("导出失败: " + e.getMessage()); diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiChatServiceImpl.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiChatServiceImpl.java index 7142f3b..f461327 100644 --- a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiChatServiceImpl.java +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiChatServiceImpl.java @@ -1,7 +1,7 @@ package org.xyzh.ai.service.impl; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -12,24 +12,31 @@ import org.xyzh.ai.client.DifyApiClient; import org.xyzh.ai.client.callback.StreamCallback; import org.xyzh.ai.client.dto.ChatRequest; import org.xyzh.ai.client.dto.ChatResponse; -import org.xyzh.ai.config.DifyConfig; import org.xyzh.ai.exception.DifyException; import org.xyzh.ai.mapper.AiAgentConfigMapper; import org.xyzh.ai.mapper.AiConversationMapper; import org.xyzh.ai.mapper.AiMessageMapper; +import org.xyzh.ai.mapper.AiUploadFileMapper; +import org.xyzh.ai.service.AiKnowledgeRedisService; import org.xyzh.api.ai.chat.AiChatService; +import org.xyzh.api.ai.dto.DifyFileInfo; +import org.xyzh.common.core.domain.LoginDomain; import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.dto.ai.TbAiAgentConfig; import org.xyzh.common.dto.ai.TbAiConversation; import org.xyzh.common.dto.ai.TbAiMessage; +import org.xyzh.common.dto.ai.TbAiUploadFile; import org.xyzh.common.dto.user.TbSysUser; +import org.xyzh.common.vo.UserDeptRoleVO; import org.xyzh.system.utils.LoginUtil; +import org.springframework.data.redis.core.RedisTemplate; import java.io.IOException; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; /** @@ -56,20 +63,104 @@ public class AiChatServiceImpl implements AiChatService { private DifyApiClient difyApiClient; @Autowired - private DifyConfig difyConfig; + private AiKnowledgeRedisService knowledgeRedisService; - private final ObjectMapper objectMapper = new ObjectMapper(); + @Autowired + private AiUploadFileMapper uploadFileMapper; + + @Autowired + private RedisTemplate redisTemplate; // 异步任务线程池(用于异步生成摘要等后台任务) private final ExecutorService executorService = Executors.newFixedThreadPool(3); + + // Redis会话key前缀 + private static final String CHAT_SESSION_PREFIX = "chat:session:"; @Override - public SseEmitter streamChatWithSse(String agentId, String conversationId, String query, List knowledgeIds) { + public ResultDomain prepareChatSession(String agentId, String conversationId, String query, List filesData) { + ResultDomain resultDomain = new ResultDomain<>(); + try { + // 参数验证 + if (!StringUtils.hasText(agentId)) { + resultDomain.fail("智能体ID不能为空"); + return resultDomain; + } + if (!StringUtils.hasText(query)) { + resultDomain.fail("问题不能为空"); + return resultDomain; + } + + // 生成sessionId + String sessionId = UUID.randomUUID().toString(); + + // 构建会话数据 + Map sessionData = new HashMap<>(); + sessionData.put("agentId", agentId); + sessionData.put("conversationId", conversationId); + sessionData.put("query", query); + sessionData.put("filesData", filesData); + sessionData.put("createTime", System.currentTimeMillis()); + + // 存入Redis,5分钟过期 + String redisKey = CHAT_SESSION_PREFIX + sessionId; + redisTemplate.opsForValue().set(redisKey, sessionData, 5, TimeUnit.MINUTES); + + log.info("创建对话会话: sessionId={}, agentId={}", sessionId, agentId); + + resultDomain.success("会话创建成功", sessionId); + return resultDomain; + } catch (Exception e) { + log.error("创建对话会话失败", e); + resultDomain.fail("创建会话失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + @SuppressWarnings("unchecked") + public SseEmitter streamChatWithSse(String sessionId) { // 创建SseEmitter,设置超时时间为5分钟 SseEmitter emitter = new SseEmitter(5 * 60 * 1000L); try { - // 1. 参数验证 + // 1. 从Redis获取并删除会话数据 + String redisKey = CHAT_SESSION_PREFIX + sessionId; + Map sessionData = (Map) redisTemplate.opsForValue().get(redisKey); + + if (sessionData == null) { + emitter.send(SseEmitter.event().name("error").data("会话不存在或已过期")); + emitter.complete(); + return emitter; + } + + // 删除Redis中的会话数据(一次性使用) + redisTemplate.delete(redisKey); + + String agentId = (String) sessionData.get("agentId"); + String conversationId = (String) sessionData.get("conversationId"); + String query = (String) sessionData.get("query"); + + // 转换文件数据 + List filesData = null; + Object filesObj = sessionData.get("filesData"); + if (filesObj != null) { + if (filesObj instanceof List) { + filesData = new ArrayList<>(); + for (Object fileObj : (List) filesObj) { + if (fileObj instanceof Map) { + // 使用Fastjson2转换Map为对象 + String json = JSON.toJSONString(fileObj); + DifyFileInfo fileInfo = JSON.parseObject(json, DifyFileInfo.class); + filesData.add(fileInfo); + } else if (fileObj instanceof DifyFileInfo) { + filesData.add((DifyFileInfo) fileObj); + } + } + } + } + + // 2. 参数验证 if (!StringUtils.hasText(agentId)) { emitter.send(SseEmitter.event().name("error").data("智能体ID不能为空")); emitter.complete(); @@ -118,7 +209,7 @@ public class AiChatServiceImpl implements AiChatService { } } else { // 创建新会话 - ResultDomain createResult = createConversation(agentId, null); + ResultDomain createResult = createConversation(agentId, query.substring(0, 20)); if (!createResult.isSuccess()) { emitter.send(SseEmitter.event().name("error").data(createResult.getMessage())); emitter.complete(); @@ -131,7 +222,8 @@ public class AiChatServiceImpl implements AiChatService { // 5. 创建用户消息记录 TbAiMessage userMessage = new TbAiMessage(); - userMessage.setID(UUID.randomUUID().toString()); + String userMessageId = UUID.randomUUID().toString(); + userMessage.setID(userMessageId); userMessage.setConversationID(finalConversationId); userMessage.setAgentID(agentId); userMessage.setRole("user"); @@ -140,10 +232,39 @@ public class AiChatServiceImpl implements AiChatService { userMessage.setUpdateTime(new Date()); userMessage.setDeleted(false); userMessage.setUserID(currentUser.getID()); + + // 处理文件关联(将文件ID列表转换为JSON数组保存) + if (filesData != null && !filesData.isEmpty()) { + try { + // 提取系统文件ID列表(从前端传来的sysFileId) + List sysFileIds = new ArrayList<>(); + for (DifyFileInfo fileInfo : filesData) { + // 使用sysFileId字段 + if (fileInfo.getSysFileId() != null) { + sysFileIds.add(fileInfo.getSysFileId()); + } + } + if (!sysFileIds.isEmpty()) { + // 使用Fastjson2序列化为JSON字符串 + userMessage.setFileIDs(JSON.toJSONString(sysFileIds)); + } + } catch (Exception e) { + log.warn("保存文件ID列表失败", e); + } + } + messageMapper.insertMessage(userMessage); + + // 6. 保存文件关联记录到tb_ai_upload_file + if (filesData != null && !filesData.isEmpty()) { + saveMessageFileRecords(userMessageId, finalConversationId, currentUser.getID(), filesData); + } // 注意:AI消息记录将在获取到Dify的task_id后创建 + // 6. 从Redis获取当前用户可访问的知识库ID列表 + List knowledgeIds = getKnowledgeIdsByUser(currentUser); + // 7. 构建Dify请求 ChatRequest chatRequest = new ChatRequest(); chatRequest.setQuery(query); @@ -152,15 +273,18 @@ public class AiChatServiceImpl implements AiChatService { if (StringUtils.hasText(conversation.getDifyConversationId())) { chatRequest.setConversationId(conversation.getDifyConversationId()); } + + // 设置知识库ID列表(从Redis获取) if (knowledgeIds != null && !knowledgeIds.isEmpty()) { chatRequest.setDatasetIds(knowledgeIds); + log.info("使用知识库: {}", knowledgeIds); } - - chatRequest.setTemperature(agent.getTemperature() != null ? - agent.getTemperature().doubleValue() : difyConfig.getChat().getDefaultTemperature()); - chatRequest.setMaxTokens(agent.getMaxTokens() != null ? - agent.getMaxTokens() : difyConfig.getChat().getDefaultMaxTokens()); + chatRequest.setResponseMode("streaming"); + Map inputs = new HashMap<>(); + inputs.put("connectInternet", agent.getConnectInternet()); + chatRequest.setInputs(inputs); + chatRequest.setFiles(filesData); // 6. 调用Dify流式对话 final TbAiConversation finalConversation = conversation; StringBuilder fullAnswer = new StringBuilder(); @@ -194,13 +318,13 @@ public class AiChatServiceImpl implements AiChatService { return; // 已停止,不再处理 } try { - // 解析metadata - JsonNode json = objectMapper.readTree(metadata); - if (json.has("conversation_id")) { - difyConversationId.set(json.get("conversation_id").asText()); + // 使用Fastjson2解析metadata + JSONObject json = JSON.parseObject(metadata); + if (json.containsKey("conversation_id")) { + difyConversationId.set(json.getString("conversation_id")); } - if (json.has("id")) { - difyMessageId.set(json.get("id").asText()); + if (json.containsKey("id")) { + difyMessageId.set(json.getString("id")); } // 更新AI消息内容(使用task_id作为消息ID) @@ -247,9 +371,9 @@ public class AiChatServiceImpl implements AiChatService { // 如果还没有创建消息记录,尝试从任何事件中提取task_id if (!messageCreated.get()) { - JsonNode json = objectMapper.readTree(eventData); - if (json.has("task_id")) { - String difyTaskId = json.get("task_id").asText(); + JSONObject json = JSON.parseObject(eventData); + if (json.containsKey("task_id")) { + String difyTaskId = json.getString("task_id"); // 只有在taskId为空时才设置并创建消息 if (taskId.get() == null) { @@ -363,9 +487,7 @@ public class AiChatServiceImpl implements AiChatService { public ResultDomain blockingChat( String agentId, String conversationId, - String query, - List knowledgeIds) { - + String query){ ResultDomain resultDomain = new ResultDomain<>(); try { @@ -426,21 +548,12 @@ public class AiChatServiceImpl implements AiChatService { if (StringUtils.hasText(conversation.getDifyConversationId())) { chatRequest.setConversationId(conversation.getDifyConversationId()); } - if (knowledgeIds != null && !knowledgeIds.isEmpty()) { - chatRequest.setDatasetIds(knowledgeIds); - } - if (agent.getTemperature() != null) { - chatRequest.setTemperature(agent.getTemperature().doubleValue()); - } else { - chatRequest.setTemperature(difyConfig.getChat().getDefaultTemperature()); - } - - if (agent.getMaxTokens() != null) { - chatRequest.setMaxTokens(agent.getMaxTokens()); - } else { - chatRequest.setMaxTokens(difyConfig.getChat().getDefaultMaxTokens()); - } + chatRequest.setResponseMode("blocking"); + Map inputs = new HashMap<>(); + inputs.put("connectInternet", agent.getConnectInternet()); + chatRequest.setInputs(inputs); + chatRequest.setFiles(null); // 调用Dify阻塞式对话 ChatResponse chatResponse = difyApiClient.blockingChat(chatRequest, agent.getDifyApiKey()); @@ -809,7 +922,7 @@ public class AiChatServiceImpl implements AiChatService { TbAiMessage userQuestion = null; for (int i = messages.size() - 1; i >= 0; i--) { if ("user".equals(messages.get(i).getRole()) && - messages.get(i).getCreateTime().before(originalMessage.getCreateTime())) { + !messages.get(i).getCreateTime().after(originalMessage.getCreateTime())) { userQuestion = messages.get(i); break; } @@ -821,13 +934,23 @@ public class AiChatServiceImpl implements AiChatService { return emitter; } - // 直接返回streamChatWithSse的结果 - return streamChatWithSse( - originalMessage.getAgentID(), - originalMessage.getConversationID(), - userQuestion.getContent(), - null - ); + // 重新生成:创建临时session并调用新的streamChatWithSse + String sessionId = UUID.randomUUID().toString(); + + // 构建会话数据 + Map sessionData = new HashMap<>(); + sessionData.put("agentId", originalMessage.getAgentID()); + sessionData.put("conversationId", originalMessage.getConversationID()); + sessionData.put("query", userQuestion.getContent()); + sessionData.put("filesData", null); // 重新生成不需要传文件 + sessionData.put("createTime", System.currentTimeMillis()); + + // 存入Redis,5分钟过期 + String redisKey = CHAT_SESSION_PREFIX + sessionId; + redisTemplate.opsForValue().set(redisKey, sessionData, 5, TimeUnit.MINUTES); + + log.info("重新生成回答: messageId={}, sessionId={}", messageId, sessionId); + return streamChatWithSse(sessionId); } catch (Exception e) { log.error("重新生成回答异常", e); @@ -971,5 +1094,91 @@ public class AiChatServiceImpl implements AiChatService { return resultDomain; } } + + /** + * 保存消息关联的文件记录 + * @param messageId 消息ID + * @param conversationId 会话ID + * @param userId 用户ID + * @param filesData 文件数据列表 + */ + private void saveMessageFileRecords(String messageId, String conversationId, String userId, List filesData) { + try { + List fileRecords = new ArrayList<>(); + Date now = new Date(); + + for (DifyFileInfo fileInfo : filesData) { + TbAiUploadFile uploadFile = new TbAiUploadFile(); + uploadFile.setID(UUID.randomUUID().toString()); + uploadFile.setUserID(userId); + uploadFile.setConversationID(conversationId); + uploadFile.setMessageID(messageId); // 绑定到消息 + + // 从文件信息中提取系统文件ID(前端传递sys_file_id字段) + uploadFile.setSysFileId(fileInfo.getSysFileId()); + + uploadFile.setFileName(fileInfo.getName()); + uploadFile.setFilePath(fileInfo.getFilePath() != null ? fileInfo.getFilePath() : ""); // 从系统文件表获取的文件路径 + uploadFile.setFileSize(fileInfo.getSize() != null ? fileInfo.getSize().longValue() : 0L); + uploadFile.setFileType(fileInfo.getExtension()); + uploadFile.setMimeType(fileInfo.getMimeType()); + uploadFile.setDifyUploadFileId(fileInfo.getUploadFileId()); // Dify的上传文件ID + uploadFile.setStatus(2); // 已完成(对话文件直接可用) + uploadFile.setCreateTime(now); + uploadFile.setUpdateTime(now); + uploadFile.setDeleted(false); + + fileRecords.add(uploadFile); + } + + if (!fileRecords.isEmpty()) { + uploadFileMapper.batchInsertUploadFiles(fileRecords); + log.info("消息文件记录已保存: messageId={}, fileCount={}", messageId, fileRecords.size()); + } + } catch (Exception e) { + log.error("保存消息文件记录失败: messageId={}", messageId, e); + // 不抛出异常,不影响主流程 + } + } + + /** + * 根据用户获取可访问的知识库ID列表(基于部门路径) + * @param user 当前用户 + * @return 知识库ID列表 + */ + private List getKnowledgeIdsByUser(TbSysUser user) { + try { + // 获取当前登录用户的完整信息(包含部门角色列表) + LoginDomain loginDomain = LoginUtil.getCurrentLoginDomain(); + if (loginDomain == null || loginDomain.getRoles() == null || loginDomain.getRoles().isEmpty()) { + log.warn("用户 {} 没有部门角色信息,返回空知识库列表", user.getID()); + return null; + } + + // 获取用户的第一个部门角色(通常用户属于一个主部门) + UserDeptRoleVO userRole = loginDomain.getRoles().get(0); + String deptPath = userRole.getDeptPath(); + + if (deptPath == null || deptPath.isEmpty()) { + log.warn("用户 {} 的部门路径为空,返回空知识库列表", user.getID()); + return null; + } + + // 根据部门路径获取知识库ID列表(包含父部门的知识库) + List knowledgeIds = ((AiKnowledgeRedisServiceImpl) knowledgeRedisService).getKnowledgeIdsByDeptPath(deptPath); + + if (knowledgeIds == null || knowledgeIds.isEmpty()) { + log.warn("用户 {} 所在部门路径 {} 没有关联的知识库", user.getID(), deptPath); + return null; + } + + log.info("用户 {} 从部门路径 {} 获取到 {} 个知识库", user.getID(), deptPath, knowledgeIds.size()); + return knowledgeIds; + + } catch (Exception e) { + log.error("根据用户获取知识库ID失败: userId={}", user.getID(), e); + return null; + } + } } diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiKnowledgeRedisServiceImpl.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiKnowledgeRedisServiceImpl.java new file mode 100644 index 0000000..5514f63 --- /dev/null +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiKnowledgeRedisServiceImpl.java @@ -0,0 +1,170 @@ +package org.xyzh.ai.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.xyzh.ai.mapper.AiKnowledgeMapper; +import org.xyzh.ai.service.AiKnowledgeRedisService; +import org.xyzh.common.dto.ai.TbAiKnowledge; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * @description AI知识库Redis管理服务实现类 + * @filename AiKnowledgeRedisServiceImpl.java + * @author AI Assistant + * @copyright xyzh + * @since 2025-11-06 + */ +@Slf4j +@Service +public class AiKnowledgeRedisServiceImpl implements AiKnowledgeRedisService, CommandLineRunner { + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private AiKnowledgeMapper knowledgeMapper; + + // Redis Key前缀:ai:dept:knowledge:{deptId} + private static final String REDIS_KEY_DEPT_KNOWLEDGE = "ai:dept:knowledge:"; + + // Redis过期时间(7天) + private static final long REDIS_EXPIRE_DAYS = 7; + + /** + * 系统启动时自动初始化 + */ + @Override + public void run(String... args) throws Exception { + log.info("=== 开始初始化知识库到Redis ==="); + initializeAllKnowledgeToRedis(); + log.info("=== 知识库初始化完成 ==="); + } + + @Override + public void initializeAllKnowledgeToRedis() { + try { + // 查询所有已同步到Dify的知识库 + List allKnowledges = knowledgeMapper.selectAllKnowledges(new TbAiKnowledge()); + + // 按部门分组知识库 + Map> deptKnowledgeMap = new HashMap<>(); + + for (TbAiKnowledge knowledge : allKnowledges) { + // 只处理已同步到Dify且未删除的知识库 + if (knowledge.getDifyDatasetId() != null && !knowledge.getDifyDatasetId().isEmpty() + && !knowledge.getDeleted()) { + + String deptId = knowledge.getCreatorDept(); + if (deptId == null || deptId.isEmpty()) { + log.warn("知识库 {} 没有部门信息,跳过", knowledge.getID()); + continue; + } + + // 将知识库添加到对应部门的列表中 + deptKnowledgeMap.computeIfAbsent(deptId, k -> new ArrayList<>()) + .add(knowledge.getDifyDatasetId()); + } + } + + if (deptKnowledgeMap.isEmpty()) { + log.warn("未找到已同步到Dify且有部门信息的知识库"); + return; + } + + // 为每个部门设置知识库 + int totalCount = 0; + for (Map.Entry> entry : deptKnowledgeMap.entrySet()) { + String deptId = entry.getKey(); + List knowledgeIds = entry.getValue(); + setDeptKnowledgeIds(deptId, knowledgeIds); + totalCount += knowledgeIds.size(); + log.info("已设置部门 {} 的知识库,共 {} 个", deptId, knowledgeIds.size()); + } + + log.info("知识库初始化完成,共 {} 个部门,{} 个知识库", deptKnowledgeMap.size(), totalCount); + + } catch (Exception e) { + log.error("初始化知识库到Redis失败", e); + } + } + + /** + * 设置部门的知识库ID列表 + */ + public void setDeptKnowledgeIds(String deptId, List knowledgeIds) { + try { + String key = REDIS_KEY_DEPT_KNOWLEDGE + deptId; + redisTemplate.opsForValue().set(key, knowledgeIds, REDIS_EXPIRE_DAYS, TimeUnit.DAYS); + log.debug("已设置部门 {} 的知识库: {}", deptId, knowledgeIds); + } catch (Exception e) { + log.error("设置部门知识库失败: deptId={}", deptId, e); + } + } + + /** + * 根据部门路径获取知识库ID列表(包含父部门的知识库) + * @param deptPath 部门路径,例如:/1/3/5/ + * @return 知识库ID列表(已去重) + */ + @SuppressWarnings("unchecked") + public List getKnowledgeIdsByDeptPath(String deptPath) { + Set knowledgeIdSet = new HashSet<>(); + + try { + if (deptPath == null || deptPath.isEmpty()) { + log.warn("部门路径为空,返回空知识库列表"); + return new ArrayList<>(); + } + + // 解析部门路径,例如:/1/3/5/ -> [1, 3, 5] + String[] deptIds = deptPath.split("/"); + List deptIdList = new ArrayList<>(); + for (String deptId : deptIds) { + if (deptId != null && !deptId.isEmpty()) { + deptIdList.add(deptId); + } + } + + if (deptIdList.isEmpty()) { + log.warn("解析部门路径失败: {}", deptPath); + return new ArrayList<>(); + } + + // 依次查询当前部门及所有父部门的知识库 + for (String deptId : deptIdList) { + String key = REDIS_KEY_DEPT_KNOWLEDGE + deptId; + Object result = redisTemplate.opsForValue().get(key); + if (result != null) { + List deptKnowledgeIds = (List) result; + knowledgeIdSet.addAll(deptKnowledgeIds); + log.debug("从部门 {} 获取到 {} 个知识库", deptId, deptKnowledgeIds.size()); + } + } + + List resultList = new ArrayList<>(knowledgeIdSet); + log.info("根据部门路径 {} 获取到 {} 个知识库(已去重)", deptPath, resultList.size()); + return resultList; + + } catch (Exception e) { + log.error("根据部门路径获取知识库失败: deptPath={}", deptPath, e); + return new ArrayList<>(); + } + } + + @Override + public void clearAllKnowledgeConfig() { + try { + // 删除所有知识库相关的Redis Key + redisTemplate.delete(redisTemplate.keys(REDIS_KEY_DEPT_KNOWLEDGE + "*")); + log.info("已清除所有知识库配置"); + } catch (Exception e) { + log.error("清除所有知识库配置失败", e); + } + } +} + diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiUploadFileServiceImpl.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiUploadFileServiceImpl.java index f537573..ab57b4c 100644 --- a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiUploadFileServiceImpl.java +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiUploadFileServiceImpl.java @@ -8,19 +8,24 @@ import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import org.xyzh.ai.client.DifyApiClient; import org.xyzh.ai.client.dto.DocumentStatusResponse; +import org.xyzh.api.ai.dto.DifyFileInfo; import org.xyzh.ai.client.dto.DocumentUploadRequest; import org.xyzh.ai.client.dto.DocumentUploadResponse; import org.xyzh.ai.config.DifyConfig; import org.xyzh.ai.exception.DifyException; import org.xyzh.ai.exception.FileProcessException; +import org.xyzh.ai.mapper.AiAgentConfigMapper; import org.xyzh.ai.mapper.AiKnowledgeMapper; import org.xyzh.ai.mapper.AiUploadFileMapper; import org.xyzh.api.ai.file.AiUploadFileService; +import org.xyzh.api.file.FileService; import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.core.page.PageDomain; import org.xyzh.common.core.page.PageParam; +import org.xyzh.common.dto.ai.TbAiAgentConfig; import org.xyzh.common.dto.ai.TbAiKnowledge; import org.xyzh.common.dto.ai.TbAiUploadFile; +import org.xyzh.common.dto.system.TbSysFile; import org.xyzh.common.dto.user.TbSysUser; import org.xyzh.system.utils.LoginUtil; @@ -58,9 +63,119 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { @Autowired private DifyConfig difyConfig; + @Autowired + private AiAgentConfigMapper agentConfigMapper; + + @Autowired + private FileService fileService; + // 异步处理线程池 private final ExecutorService executorService = Executors.newFixedThreadPool(5); + @Override + public ResultDomain> uploadFileForChat(MultipartFile file, String agentId) { + ResultDomain> resultDomain = new ResultDomain<>(); + + try { + // 1. 参数验证 + if (file == null || file.isEmpty()) { + resultDomain.fail("文件不能为空"); + return resultDomain; + } + if (!StringUtils.hasText(agentId)) { + resultDomain.fail("智能体ID不能为空"); + return resultDomain; + } + + // 2. 获取当前用户 + TbSysUser currentUser = LoginUtil.getCurrentUser(); + if (currentUser == null) { + resultDomain.fail("用户未登录"); + return resultDomain; + } + + // 3. 验证文件类型和大小 + String originalFilename = file.getOriginalFilename(); + if (!isValidFileType(originalFilename)) { + resultDomain.fail("不支持的文件类型"); + return resultDomain; + } + + long maxSize = difyConfig.getUpload().getMaxSize() * 1024 * 1024; // MB转字节 + if (file.getSize() > maxSize) { + resultDomain.fail("文件大小超过限制: " + (maxSize / 1024 / 1024) + "MB"); + return resultDomain; + } + + // 4. 获取智能体配置 + TbAiAgentConfig agent = agentConfigMapper.selectAgentConfigById(agentId); + if (agent == null || agent.getDeleted()) { + resultDomain.fail("智能体不存在"); + return resultDomain; + } + + // 5. 先保存到系统文件表(永久存储) + ResultDomain uploadResult = fileService.uploadFile( + file, + "ai-agent", // 模块名 + agentId, // 业务ID(智能体ID) + currentUser.getID() // 上传者 + ); + + if (!uploadResult.isSuccess() || uploadResult.getData() == null) { + resultDomain.fail("保存文件失败: " + uploadResult.getMessage()); + return resultDomain; + } + + TbSysFile sysFile = (TbSysFile) uploadResult.getData(); + log.info("文件已保存到系统文件表: sysFileId={}, fileName={}", sysFile.getID(), sysFile.getOriginalName()); + + // 6. 获取已保存文件的File对象,直接用于上传到Dify(不需要再保存临时文件) + File fileToUpload = fileService.getFileByRelativePath(sysFile.getFilePath()); + + try { + // 7. 调用Dify API上传文件 + DifyFileInfo difyResponse = difyApiClient.uploadFileForChat( + fileToUpload, + originalFilename, + currentUser.getID(), + agent.getDifyApiKey()); + + // 9. 转换为前端需要的格式,包含系统文件ID和文件路径 + Map fileInfo = new HashMap<>(); + fileInfo.put("id", difyResponse.getId()); // Dify文件ID + fileInfo.put("sys_file_id", sysFile.getID()); // 系统文件ID(重要:用于关联消息) + fileInfo.put("file_path", sysFile.getFilePath()); // 文件路径(重要:用于保存记录) + fileInfo.put("name", difyResponse.getName()); + fileInfo.put("size", difyResponse.getSize()); + fileInfo.put("extension", difyResponse.getExtension()); + fileInfo.put("mime_type", difyResponse.getMimeType()); + fileInfo.put("type", getFileType(originalFilename)); + fileInfo.put("transfer_method", "local_file"); + fileInfo.put("upload_file_id", difyResponse.getId()); // Dify上传文件ID + fileInfo.put("file_url", sysFile.getFileUrl()); // 文件访问URL + + log.info("对话文件上传成功: sysFileId={}, difyFileId={}", sysFile.getID(), difyResponse.getId()); + resultDomain.success("文件上传成功", fileInfo); + return resultDomain; + + } catch (DifyException e) { + log.error("上传文件到Dify失败", e); + resultDomain.fail("上传文件失败: " + e.getMessage()); + return resultDomain; + } catch (Exception e) { + log.error("上传文件异常", e); + resultDomain.fail("上传文件异常: " + e.getMessage()); + return resultDomain; + } + + } catch (Exception e) { + log.error("文件上传处理异常", e); + resultDomain.fail("文件上传处理异常: " + e.getMessage()); + return resultDomain; + } + } + @Override @Transactional(rollbackFor = Exception.class) public ResultDomain uploadToKnowledge( @@ -113,64 +228,72 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { return resultDomain; } - // 5. 保存临时文件 - File tempFile = saveTempFile(file); - if (tempFile == null) { - resultDomain.fail("保存临时文件失败"); + // 5. 先保存到系统文件表(永久存储) + ResultDomain uploadResult = fileService.uploadFile( + file, + "ai-knowledge", // 模块名 + knowledgeId, // 业务ID(知识库ID) + currentUser.getID() // 上传者 + ); + + if (!uploadResult.isSuccess() || uploadResult.getData() == null) { + resultDomain.fail("保存文件失败: " + uploadResult.getMessage()); return resultDomain; } - try { - // 6. 上传到Dify - DocumentUploadRequest uploadRequest = new DocumentUploadRequest(); - uploadRequest.setName(originalFilename); - - if (!StringUtils.hasText(indexingTechnique)) { - indexingTechnique = knowledge.getDifyIndexingTechnique(); - } - uploadRequest.setIndexingTechnique(indexingTechnique); + TbSysFile sysFile = (TbSysFile) uploadResult.getData(); + log.info("文件已保存到系统文件表: sysFileId={}, fileName={}", sysFile.getID(), sysFile.getOriginalName()); - DocumentUploadResponse difyResponse = difyApiClient.uploadDocumentByFile( - knowledge.getDifyDatasetId(), - tempFile, - originalFilename, - uploadRequest, - difyConfig.getApiKey() - ); + // 6. 获取已保存文件的File对象,直接用于上传到Dify + File fileToUpload = fileService.getFileByRelativePath(sysFile.getFilePath()); - // 7. 保存到本地数据库 - TbAiUploadFile uploadFile = new TbAiUploadFile(); - uploadFile.setID(UUID.randomUUID().toString()); - uploadFile.setKnowledgeId(knowledgeId); - uploadFile.setFileName(originalFilename); - uploadFile.setFilePath(tempFile.getAbsolutePath()); - uploadFile.setFileSize(file.getSize()); - uploadFile.setFileType(getFileExtension(originalFilename)); - uploadFile.setDifyDocumentId(difyResponse.getId()); - uploadFile.setDifyBatchId(difyResponse.getBatch()); - uploadFile.setStatus(1); // 1=处理中 - uploadFile.setChunkCount(0); - uploadFile.setCreateTime(new Date()); - uploadFile.setUpdateTime(new Date()); - uploadFile.setDeleted(false); + // 7. 上传到Dify + DocumentUploadRequest uploadRequest = new DocumentUploadRequest(); + uploadRequest.setName(originalFilename); - int rows = uploadFileMapper.insertUploadFile(uploadFile); - if (rows > 0) { - log.info("文件上传成功: {} - {}", uploadFile.getID(), originalFilename); - - // 8. 异步更新向量化状态 - asyncUpdateVectorStatus(uploadFile.getID()); - - resultDomain.success("文件上传成功", uploadFile); - return resultDomain; - } else { - resultDomain.fail("保存文件记录失败"); - return resultDomain; - } + if (!StringUtils.hasText(indexingTechnique)) { + indexingTechnique = knowledge.getDifyIndexingTechnique(); + } + uploadRequest.setIndexingTechnique(indexingTechnique); - } finally { - // 清理临时文件 - deleteTempFile(tempFile); + DocumentUploadResponse difyResponse = difyApiClient.uploadDocumentByFile( + knowledge.getDifyDatasetId(), + fileToUpload, + originalFilename, + uploadRequest, + difyConfig.getApiKey()); + + // 8. 保存到本地数据库 + TbAiUploadFile uploadFile = new TbAiUploadFile(); + uploadFile.setID(UUID.randomUUID().toString()); + uploadFile.setUserID(currentUser.getID()); + uploadFile.setKnowledgeId(knowledgeId); + uploadFile.setSysFileId(sysFile.getID()); // 关联系统文件ID + uploadFile.setFileName(originalFilename); + uploadFile.setFilePath(sysFile.getFilePath()); // 保存系统文件的相对路径 + uploadFile.setFileSize(file.getSize()); + uploadFile.setFileType(getFileExtension(originalFilename)); + uploadFile.setDifyDocumentId(difyResponse.getId()); + uploadFile.setDifyBatchId(difyResponse.getBatch()); + uploadFile.setStatus(1); // 1=处理中 + uploadFile.setChunkCount(0); + uploadFile.setCreateTime(new Date()); + uploadFile.setUpdateTime(new Date()); + uploadFile.setDeleted(false); + + int rows = uploadFileMapper.insertUploadFile(uploadFile); + if (rows > 0) { + log.info("知识库文件上传成功: uploadFileId={}, sysFileId={}, fileName={}", + uploadFile.getID(), sysFile.getID(), originalFilename); + + // 9. 异步更新向量化状态 + asyncUpdateVectorStatus(uploadFile.getID()); + + resultDomain.success("文件上传成功", uploadFile); + return resultDomain; + } else { + resultDomain.fail("保存文件记录失败"); + return resultDomain; } } catch (DifyException e) { @@ -203,10 +326,8 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { List failedFiles = new ArrayList<>(); for (MultipartFile file : files) { - ResultDomain uploadResult = uploadToKnowledge( - knowledgeId, file, indexingTechnique - ); - + ResultDomain uploadResult = uploadToKnowledge(knowledgeId, file, indexingTechnique); + if (uploadResult.isSuccess()) { uploadedFiles.add(uploadResult.getData()); } else { @@ -252,14 +373,13 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { } // 3. 删除Dify中的文档 - if (StringUtils.hasText(file.getDifyDocumentId()) && - StringUtils.hasText(knowledge.getDifyDatasetId())) { + if (StringUtils.hasText(file.getDifyDocumentId()) && + StringUtils.hasText(knowledge.getDifyDatasetId())) { try { difyApiClient.deleteDocument( knowledge.getDifyDatasetId(), file.getDifyDocumentId(), - difyConfig.getApiKey() - ); + difyConfig.getApiKey()); log.info("Dify文档删除成功: {}", file.getDifyDocumentId()); } catch (DifyException e) { log.error("删除Dify文档失败,继续删除本地记录", e); @@ -268,7 +388,7 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { // 4. 获取当前用户 TbSysUser currentUser = LoginUtil.getCurrentUser(); - + // 5. 逻辑删除本地记录 TbAiUploadFile deleteEntity = new TbAiUploadFile(); deleteEntity.setID(fileId); @@ -347,7 +467,7 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { try { // 查询列表 List files = uploadFileMapper.selectUploadFilesPage(filter, pageParam); - + // 查询总数 long total = uploadFileMapper.countUploadFiles(filter); @@ -355,7 +475,7 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { PageParam resultPageParam = new PageParam(pageParam.getPageNumber(), pageParam.getPageSize()); resultPageParam.setTotalElements(total); resultPageParam.setTotalPages((int) Math.ceil((double) total / pageParam.getPageSize())); - + return new PageDomain<>(resultPageParam, files); } catch (Exception e) { @@ -393,19 +513,18 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { DocumentStatusResponse statusResponse = difyApiClient.getDocumentStatus( knowledge.getDifyDatasetId(), file.getDifyBatchId(), - difyConfig.getApiKey() - ); + difyConfig.getApiKey()); // 4. 更新本地状态 TbAiUploadFile update = new TbAiUploadFile(); update.setID(fileId); - + // 映射Dify状态到本地状态:completed=2, processing=1, error=3 // DocumentStatusResponse返回的是文档列表,取第一个 if (statusResponse.getData() != null && !statusResponse.getData().isEmpty()) { DocumentStatusResponse.DocumentStatus docStatus = statusResponse.getData().get(0); String indexingStatus = docStatus.getIndexingStatus(); - + if ("completed".equals(indexingStatus)) { update.setStatus(2); // 已完成 } else if ("error".equals(indexingStatus)) { @@ -413,12 +532,12 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { } else { update.setStatus(1); // 处理中 } - + if (docStatus.getCompletedSegments() != null) { update.setChunkCount(docStatus.getCompletedSegments()); } } - + update.setUpdateTime(new Date()); uploadFileMapper.updateUploadFile(update); @@ -449,7 +568,7 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { try { // 查询知识库的所有文件 List files = uploadFileMapper.selectFilesByKnowledgeId(knowledgeId); - + if (files.isEmpty()) { resultDomain.success("没有需要同步的文件", files); return resultDomain; @@ -488,10 +607,10 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { if (!StringUtils.hasText(filename)) { return false; } - + String extension = getFileExtension(filename).toLowerCase(); String[] allowedTypes = difyConfig.getUpload().getAllowedTypes(); - + for (String type : allowedTypes) { if (type.equalsIgnoreCase(extension)) { return true; @@ -507,7 +626,7 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { if (!StringUtils.hasText(filename)) { return ""; } - + int lastDot = filename.lastIndexOf('.'); if (lastDot > 0 && lastDot < filename.length() - 1) { return filename.substring(lastDot + 1); @@ -516,35 +635,45 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { } /** - * 保存临时文件 + * 获取文件类型(使用具体的文件扩展名) + * 图片类型返回 "image",其他类型返回具体扩展名(pdf, docx, txt等) */ - private File saveTempFile(MultipartFile file) { - try { - String tempDir = System.getProperty("java.io.tmpdir"); - String filename = UUID.randomUUID().toString() + "_" + file.getOriginalFilename(); - Path tempPath = Paths.get(tempDir, filename); - - Files.copy(file.getInputStream(), tempPath); - - return tempPath.toFile(); - } catch (IOException e) { - log.error("保存临时文件失败", e); - return null; + private String getFileType(String filename) { + if (!StringUtils.hasText(filename)) { + return "file"; } - } - /** - * 删除临时文件 - */ - private void deleteTempFile(File file) { - if (file != null && file.exists()) { - try { - Files.delete(file.toPath()); - log.debug("临时文件已删除: {}", file.getAbsolutePath()); - } catch (IOException e) { - log.warn("删除临时文件失败: {}", file.getAbsolutePath(), e); + // 转换为大写以匹配数组中的类型 + String extension = getFileExtension(filename).toUpperCase(); + + // 图片类型统一返回 "image" + String[] imageTypes = { "JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG" }; + String[] documentTypes = { "TXT", "MD", "MARKDOWN", "MDX", "PDF", "HTML", "XLSX", "XLS", "VTT", "PROPERTIES", + "DOC", "DOCX", "CSV", "EML", "MSG", "PPTX", "PPT", "XML", "EPUB" }; + String[] audioTypes = { "MP3", "M4A", "WAV", "WEBM", "MPGA" }; + String[] videoTypes = { "MP4", "MOV", "MPEG", "WEBM" }; + + for (String type : imageTypes) { + if (type.equals(extension)) { + return "image"; } } + for (String type : documentTypes) { + if (type.equals(extension)) { + return "document"; + } + } + for (String type : audioTypes) { + if (type.equals(extension)) { + return "audio"; + } + } + for (String type : videoTypes) { + if (type.equals(extension)) { + return "video"; + } + } + return "custom"; } /** @@ -555,7 +684,7 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { try { // 等待3秒后开始检查状态 Thread.sleep(3000); - + // 最多检查10次,每次间隔3秒 for (int i = 0; i < 10; i++) { ResultDomain result = syncFileStatus(fileId); @@ -574,5 +703,26 @@ public class AiUploadFileServiceImpl implements AiUploadFileService { } }, executorService); } -} + @Override + public ResultDomain listFilesByMessageId(String messageId) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + if (!StringUtils.hasText(messageId)) { + resultDomain.fail("消息ID不能为空"); + return resultDomain; + } + + List files = uploadFileMapper.selectFilesByMessageId(messageId); + log.info("查询消息文件列表成功: messageId={}, fileCount={}", messageId, files.size()); + resultDomain.success("查询成功", files); + return resultDomain; + + } catch (Exception e) { + log.error("查询消息文件列表异常: messageId={}", messageId, e); + resultDomain.fail("查询失败: " + e.getMessage()); + return resultDomain; + } + } +} diff --git a/schoolNewsServ/ai/src/main/resources/mapper/AiAgentConfigMapper.xml b/schoolNewsServ/ai/src/main/resources/mapper/AiAgentConfigMapper.xml index 9285b67..4fe80a8 100644 --- a/schoolNewsServ/ai/src/main/resources/mapper/AiAgentConfigMapper.xml +++ b/schoolNewsServ/ai/src/main/resources/mapper/AiAgentConfigMapper.xml @@ -8,12 +8,7 @@ - - - - - - + @@ -27,8 +22,7 @@ - id, name, avatar, description, system_prompt, model_name, model_provider, - temperature, max_tokens, top_p, dify_app_id, dify_api_key, status, + id, name, avatar, description, connect_internet, dify_app_id, dify_api_key, status, creator, updater, create_time, update_time, delete_time, deleted @@ -39,12 +33,6 @@ AND name LIKE CONCAT('%', #{name}, '%') - - AND model_name = #{modelName} - - - AND model_provider = #{modelProvider} - AND status = #{status} @@ -54,12 +42,10 @@ INSERT INTO tb_ai_agent_config ( - id, name, avatar, description, system_prompt, model_name, model_provider, - temperature, max_tokens, top_p, dify_app_id, dify_api_key, status, + id, name, avatar, description, connect_internet, dify_app_id, dify_api_key, status, creator, updater, create_time, update_time, deleted ) VALUES ( - #{id}, #{name}, #{avatar}, #{description}, #{systemPrompt}, #{modelName}, #{modelProvider}, - #{temperature}, #{maxTokens}, #{topP}, #{difyAppId}, #{difyApiKey}, #{status}, + #{id}, #{name}, #{avatar}, #{description}, #{connectInternet}, #{difyAppId}, #{difyApiKey}, #{status}, #{creator}, #{updater}, #{createTime}, #{updateTime}, #{deleted} ) @@ -71,12 +57,7 @@ name = #{name}, avatar = #{avatar}, description = #{description}, - system_prompt = #{systemPrompt}, - model_name = #{modelName}, - model_provider = #{modelProvider}, - temperature = #{temperature}, - max_tokens = #{maxTokens}, - top_p = #{topP}, + connect_internet = #{connectInternet}, dify_app_id = #{difyAppId}, dify_api_key = #{difyApiKey}, status = #{status}, @@ -116,9 +97,6 @@ AND status = #{filter.status} - - AND model_provider = #{filter.modelProvider} - ORDER BY create_time DESC @@ -136,9 +114,6 @@ AND status = #{filter.status} - - AND model_provider = #{filter.modelProvider} - ORDER BY create_time DESC LIMIT #{pageParam.offset}, #{pageParam.pageSize} @@ -156,9 +131,6 @@ AND status = #{filter.status} - - AND model_provider = #{filter.modelProvider} - diff --git a/schoolNewsServ/ai/src/main/resources/mapper/AiUploadFileMapper.xml b/schoolNewsServ/ai/src/main/resources/mapper/AiUploadFileMapper.xml index 09db9c4..6ef9def 100644 --- a/schoolNewsServ/ai/src/main/resources/mapper/AiUploadFileMapper.xml +++ b/schoolNewsServ/ai/src/main/resources/mapper/AiUploadFileMapper.xml @@ -8,6 +8,8 @@ + + @@ -16,12 +18,10 @@ - + - - @@ -30,9 +30,9 @@ - id, user_id, knowledge_id, conversation_id, file_name, file_path, file_size, - file_type, mime_type, extracted_text, dify_document_id, dify_batch_id, - vector_status, chunk_count, status, error_message, creator, updater, + id, user_id, knowledge_id, conversation_id, message_id, sys_file_id, file_name, file_path, file_size, + file_type, mime_type, extracted_text, dify_document_id, dify_batch_id, dify_upload_file_id, + chunk_count, status, error_message, create_time, update_time, delete_time, deleted @@ -56,9 +56,6 @@ AND file_type = #{filter.fileType} - - AND vector_status = #{filter.vectorStatus} - AND status = #{filter.status} @@ -69,14 +66,14 @@ INSERT INTO tb_ai_upload_file ( - id, user_id, knowledge_id, conversation_id, file_name, file_path, file_size, - file_type, mime_type, extracted_text, dify_document_id, dify_batch_id, - vector_status, chunk_count, status, error_message, creator, updater, + id, user_id, knowledge_id, conversation_id, message_id, sys_file_id, file_name, file_path, file_size, + file_type, mime_type, extracted_text, dify_document_id, dify_batch_id, dify_upload_file_id, + chunk_count, status, error_message, create_time, update_time, deleted ) VALUES ( - #{ID}, #{userID}, #{knowledgeId}, #{conversationID}, #{fileName}, #{filePath}, #{fileSize}, - #{fileType}, #{mimeType}, #{extractedText}, #{difyDocumentId}, #{difyBatchId}, - #{vectorStatus}, #{chunkCount}, #{status}, #{errorMessage}, #{creator}, #{updater}, + #{ID}, #{userID}, #{knowledgeId}, #{conversationID}, #{messageID}, #{sysFileId}, #{fileName}, #{filePath}, #{fileSize}, + #{fileType}, #{mimeType}, #{extractedText}, #{difyDocumentId}, #{difyBatchId}, #{difyUploadFileId}, + #{chunkCount}, #{status}, #{errorMessage}, #{createTime}, #{updateTime}, #{deleted} ) @@ -88,6 +85,8 @@ user_id = #{userID}, knowledge_id = #{knowledgeId}, conversation_id = #{conversationID}, + message_id = #{messageID}, + sys_file_id = #{sysFileId}, file_name = #{fileName}, file_path = #{filePath}, file_size = #{fileSize}, @@ -96,11 +95,10 @@ extracted_text = #{extractedText}, dify_document_id = #{difyDocumentId}, dify_batch_id = #{difyBatchId}, - vector_status = #{vectorStatus}, + dify_upload_file_id = #{difyUploadFileId}, chunk_count = #{chunkCount}, status = #{status}, error_message = #{errorMessage}, - updater = #{updater}, update_time = #{updateTime}, WHERE id = #{ID} AND deleted = 0 @@ -110,8 +108,7 @@ UPDATE tb_ai_upload_file SET deleted = 1, - delete_time = NOW(), - updater = #{updater} + delete_time = NOW() WHERE id = #{ID} AND deleted = 0 @@ -186,4 +183,31 @@ ORDER BY create_time DESC + + + + + + INSERT INTO tb_ai_upload_file ( + id, user_id, knowledge_id, conversation_id, message_id, sys_file_id, file_name, file_path, file_size, + file_type, mime_type, dify_document_id, dify_batch_id, dify_upload_file_id, + chunk_count, status, create_time, update_time, deleted + ) VALUES + + ( + #{file.ID}, #{file.userID}, #{file.knowledgeId}, #{file.conversationID}, #{file.messageID}, + #{file.sysFileId}, #{file.fileName}, #{file.filePath}, #{file.fileSize}, + #{file.fileType}, #{file.mimeType}, #{file.difyDocumentId}, #{file.difyBatchId}, #{file.difyUploadFileId}, + #{file.chunkCount}, #{file.status}, #{file.createTime}, #{file.updateTime}, #{file.deleted} + ) + + + diff --git a/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/chat/AiChatService.java b/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/chat/AiChatService.java index 5224830..ceb58e9 100644 --- a/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/chat/AiChatService.java +++ b/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/chat/AiChatService.java @@ -2,6 +2,7 @@ package org.xyzh.api.ai.chat; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.api.ai.dto.DifyFileInfo; import org.xyzh.common.dto.ai.TbAiConversation; import org.xyzh.common.dto.ai.TbAiMessage; @@ -17,20 +18,27 @@ import java.util.List; public interface AiChatService { /** - * 流式对话(SSE)- 使用SseEmitter实现真正的流式推送 + * 准备对话会话(POST传递复杂参数) * @param agentId 智能体ID * @param conversationId 会话ID(可选,为空则创建新会话) * @param query 用户问题 - * @param knowledgeIds 使用的知识库ID列表(可选,用于知识库隔离) - * @return SseEmitter 流式推送对象 + * @param filesData 上传的文件列表(Dify文件信息) + * @return ResultDomain 返回sessionId */ - SseEmitter streamChatWithSse( + ResultDomain prepareChatSession( String agentId, String conversationId, String query, - List knowledgeIds + List filesData ); + /** + * 流式对话(SSE)- 使用sessionId建立SSE连接 + * @param sessionId 会话标识 + * @return SseEmitter 流式推送对象 + */ + SseEmitter streamChatWithSse(String sessionId); + /** * 阻塞式对话(非流式) * @param agentId 智能体ID @@ -42,8 +50,7 @@ public interface AiChatService { ResultDomain blockingChat( String agentId, String conversationId, - String query, - List knowledgeIds + String query ); diff --git a/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/dto/DifyFileInfo.java b/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/dto/DifyFileInfo.java new file mode 100644 index 0000000..9a1e769 --- /dev/null +++ b/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/dto/DifyFileInfo.java @@ -0,0 +1,99 @@ +package org.xyzh.api.ai.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * @description Dify文件信息(用于对话文件上传和请求) + * @filename DifyFileInfo.java + * @author AI Assistant + * @copyright xyzh + * @since 2025-11-06 + */ +@Data +public class DifyFileInfo { + /** + * 文件ID(Dify返回) + */ + private String id; + + /** + * 文件名 + */ + private String name; + + /** + * 文件大小(字节) + */ + private Integer size; + + /** + * 文件扩展名 + */ + private String extension; + + /** + * 文件MIME类型 + */ + @JsonProperty("mime_type") + private String mimeType; + + /** + * 上传人ID + */ + @JsonProperty("created_by") + private String createdBy; + + /** + * 上传时间(时间戳) + */ + @JsonProperty("created_at") + private Long createdAt; + + /** + * 预览URL + */ + @JsonProperty("preview_url") + private String previewUrl; + + /** + * 源文件URL + */ + @JsonProperty("source_url") + private String sourceUrl; + + /** + * 文件类型:image、document、audio、video、file + */ + private String type; + + /** + * 传输方式:remote_url、local_file + */ + @JsonProperty("transfer_method") + private String transferMethod; + + /** + * 文件URL或ID + */ + private String url; + + /** + * 本地文件上传ID + */ + @JsonProperty("upload_file_id") + private String uploadFileId; + + /** + * 系统文件ID + */ + @JsonProperty("sys_file_id") + private String sysFileId; + + /** + * 文件路径(从系统文件表获取) + */ + @JsonProperty("file_path") + private String filePath; +} + diff --git a/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/file/AiUploadFileService.java b/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/file/AiUploadFileService.java index 6c53b1d..ec12f34 100644 --- a/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/file/AiUploadFileService.java +++ b/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/file/AiUploadFileService.java @@ -7,6 +7,7 @@ import org.xyzh.common.core.page.PageParam; import org.xyzh.common.dto.ai.TbAiUploadFile; import java.util.List; +import java.util.Map; /** * @description AI文件上传服务接口 @@ -17,6 +18,17 @@ import java.util.List; */ public interface AiUploadFileService { + /** + * 上传文件用于对话(图文多模态) + * @param file 上传的文件 + * @param agentId 智能体ID + * @return Dify文件信息(包含id、name、size等) + */ + ResultDomain> uploadFileForChat( + MultipartFile file, + String agentId + ); + /** * 上传文件到知识库(同步到Dify) * @param knowledgeId 知识库ID @@ -92,4 +104,11 @@ public interface AiUploadFileService { * @return 同步结果 */ ResultDomain syncKnowledgeFiles(String knowledgeId); + + /** + * 查询消息关联的文件列表 + * @param messageId 消息ID + * @return 文件列表 + */ + ResultDomain listFilesByMessageId(String messageId); } diff --git a/schoolNewsServ/api/api-file/src/main/java/org/xyzh/api/file/FileService.java b/schoolNewsServ/api/api-file/src/main/java/org/xyzh/api/file/FileService.java index f803f30..7a9c93c 100644 --- a/schoolNewsServ/api/api-file/src/main/java/org/xyzh/api/file/FileService.java +++ b/schoolNewsServ/api/api-file/src/main/java/org/xyzh/api/file/FileService.java @@ -1,5 +1,7 @@ package org.xyzh.api.file; +import java.io.File; + import org.springframework.web.multipart.MultipartFile; import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.dto.system.TbSysFile; @@ -120,5 +122,33 @@ public interface FileService { * @since 2025-10-16 */ ResultDomain batchDeleteFiles(String[] fileIds); + + /** + * @description 保存临时文件(不入库,仅存储到磁盘) + * @param file 文件对象 + * @param module 模块名称 + * @return ResultDomain 返回临时文件路径 + * @author yslg + * @since 2025-11-06 + */ + ResultDomain saveTempFile(MultipartFile file, String module); + + /** + * @description 删除临时文件 + * @param tempFilePath 临时文件路径 + * @return ResultDomain 删除结果 + * @author yslg + * @since 2025-11-06 + */ + ResultDomain deleteTempFile(String tempFilePath); + + /** + * @description 通过相对路径获取文件的绝对路径File对象 + * @param relativePath 相对路径 + * @return File 文件对象 + * @author yslg + * @since 2025-11-06 + */ + File getFileByRelativePath(String relativePath); } diff --git a/schoolNewsServ/api/pom.xml b/schoolNewsServ/api/pom.xml index 472f3ea..eae8c2a 100644 --- a/schoolNewsServ/api/pom.xml +++ b/schoolNewsServ/api/pom.xml @@ -101,6 +101,10 @@ common-dto ${school-news.version} + + org.projectlombok + lombok + diff --git a/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/ai/TbAiAgentConfig.java b/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/ai/TbAiAgentConfig.java index 6cfb449..18fade1 100644 --- a/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/ai/TbAiAgentConfig.java +++ b/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/ai/TbAiAgentConfig.java @@ -30,34 +30,11 @@ public class TbAiAgentConfig extends BaseDTO { private String description; /** - * @description 系统提示词 + * @description 是否连接互联网 0不连接 1连接 */ - private String systemPrompt; - - /** - * @description 模型名称 - */ - private String modelName; - - /** - * @description 模型提供商 - */ - private String modelProvider; - - /** - * @description 温度值 - */ - private BigDecimal temperature; - - /** - * @description 最大tokens - */ - private Integer maxTokens; - - /** - * @description Top P值 - */ - private BigDecimal topP; + private Integer connectInternet; + + /** * @description Dify应用ID @@ -108,52 +85,12 @@ public class TbAiAgentConfig extends BaseDTO { this.description = description; } - public String getSystemPrompt() { - return systemPrompt; + public Integer getConnectInternet() { + return connectInternet; } - public void setSystemPrompt(String systemPrompt) { - this.systemPrompt = systemPrompt; - } - - public String getModelName() { - return modelName; - } - - public void setModelName(String modelName) { - this.modelName = modelName; - } - - public String getModelProvider() { - return modelProvider; - } - - public void setModelProvider(String modelProvider) { - this.modelProvider = modelProvider; - } - - public BigDecimal getTemperature() { - return temperature; - } - - public void setTemperature(BigDecimal temperature) { - this.temperature = temperature; - } - - public Integer getMaxTokens() { - return maxTokens; - } - - public void setMaxTokens(Integer maxTokens) { - this.maxTokens = maxTokens; - } - - public BigDecimal getTopP() { - return topP; - } - - public void setTopP(BigDecimal topP) { - this.topP = topP; + public void setConnectInternet(Integer connectInternet) { + this.connectInternet = connectInternet; } public String getDifyAppId() { @@ -201,9 +138,12 @@ public class TbAiAgentConfig extends BaseDTO { return "TbAiAgentConfig{" + "id=" + getID() + ", name='" + name + '\'' + - ", modelName='" + modelName + '\'' + - ", modelProvider='" + modelProvider + '\'' + + ", connectInternet=" + connectInternet + + ", difyAppId='" + difyAppId + '\'' + + ", difyApiKey='" + difyApiKey + '\'' + ", status=" + status + + ", creator='" + creator + '\'' + + ", updater='" + updater + '\'' + ", createTime=" + getCreateTime() + '}'; } diff --git a/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/ai/TbAiUploadFile.java b/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/ai/TbAiUploadFile.java index d88a8fa..47b04fe 100644 --- a/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/ai/TbAiUploadFile.java +++ b/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/ai/TbAiUploadFile.java @@ -28,6 +28,16 @@ public class TbAiUploadFile extends BaseDTO { */ private String conversationID; + /** + * @description 关联消息ID(绑定到具体的用户消息) + */ + private String messageID; + + /** + * @description 系统文件ID(关联tb_sys_file) + */ + private String sysFileId; + /** * @description 文件名 */ @@ -68,6 +78,11 @@ public class TbAiUploadFile extends BaseDTO { */ private String difyBatchId; + /** + * @description Dify上传文件ID(对话中上传的文件) + */ + private String difyUploadFileId; + /** * @description 分段数量 */ @@ -107,6 +122,22 @@ public class TbAiUploadFile extends BaseDTO { this.conversationID = conversationID; } + public String getMessageID() { + return messageID; + } + + public void setMessageID(String messageID) { + this.messageID = messageID; + } + + public String getSysFileId() { + return sysFileId; + } + + public void setSysFileId(String sysFileId) { + this.sysFileId = sysFileId; + } + public String getFileName() { return fileName; } @@ -171,6 +202,14 @@ public class TbAiUploadFile extends BaseDTO { this.difyBatchId = difyBatchId; } + public String getDifyUploadFileId() { + return difyUploadFileId; + } + + public void setDifyUploadFileId(String difyUploadFileId) { + this.difyUploadFileId = difyUploadFileId; + } + public Integer getChunkCount() { return chunkCount; } diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/service/FileServiceImpl.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/service/FileServiceImpl.java index bee4871..d5ca829 100644 --- a/schoolNewsServ/file/src/main/java/org/xyzh/file/service/FileServiceImpl.java +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/service/FileServiceImpl.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.File; import org.springframework.web.multipart.MultipartFile; import org.xyzh.api.file.FileService; import org.xyzh.common.core.domain.ResultDomain; @@ -371,5 +372,120 @@ public class FileServiceImpl implements FileService { return resultDomain; } } + + @Override + public ResultDomain saveTempFile(MultipartFile file, String module) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + if (file == null || file.isEmpty()) { + resultDomain.fail("文件不能为空"); + return resultDomain; + } + + // 获取存储策略 + FileStorageStrategy strategy = strategyFactory.getDefaultStrategy(); + + // 生成唯一文件名 + String originalFileName = file.getOriginalFilename(); + String fileExtension = ""; + if (originalFileName != null && originalFileName.contains(".")) { + fileExtension = originalFileName.substring(originalFileName.lastIndexOf(".")); + } + String fileName = UUID.randomUUID().toString().replace("-", "") + fileExtension; + + // 上传到临时目录 + String tempModule = "temp/" + module; + String relativePath = strategy.upload(file, fileName, tempModule); + + // 转换为绝对路径(用于后续文件操作) + String absolutePath = strategy.getAbsolutePath(relativePath); + + log.info("临时文件保存成功: relativePath={}, absolutePath={}, originalName={}", + relativePath, absolutePath, originalFileName); + + resultDomain.success("临时文件保存成功", absolutePath); + return resultDomain; + } catch (Exception e) { + log.error("保存临时文件失败", e); + resultDomain.fail("保存临时文件失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + public ResultDomain deleteTempFile(String tempFilePath) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + if (tempFilePath == null || tempFilePath.isEmpty()) { + resultDomain.fail("文件路径不能为空"); + return resultDomain; + } + + // 获取存储策略 + FileStorageStrategy strategy = strategyFactory.getDefaultStrategy(); + + // 判断是绝对路径还是相对路径 + File file = new File(tempFilePath); + + if (file.isAbsolute()) { + // 如果是绝对路径,直接删除文件 + if (file.exists()) { + boolean deleted = file.delete(); + if (deleted) { + log.info("临时文件删除成功(绝对路径): filePath={}", tempFilePath); + resultDomain.success("临时文件删除成功", true); + } else { + log.warn("临时文件删除失败(绝对路径): filePath={}", tempFilePath); + resultDomain.success("文件删除失败", false); + } + } else { + log.warn("临时文件不存在(绝对路径): filePath={}", tempFilePath); + resultDomain.success("文件不存在或已删除", false); + } + } else { + // 如果是相对路径,使用策略删除 + boolean deleted = strategy.delete(tempFilePath); + + if (deleted) { + log.info("临时文件删除成功(相对路径): filePath={}", tempFilePath); + resultDomain.success("临时文件删除成功", true); + } else { + log.warn("临时文件删除失败,可能文件不存在(相对路径): filePath={}", tempFilePath); + resultDomain.success("文件不存在或已删除", false); + } + } + + return resultDomain; + } catch (Exception e) { + log.error("删除临时文件失败: filePath={}", tempFilePath, e); + resultDomain.fail("删除临时文件失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + public File getFileByRelativePath(String relativePath) { + if (relativePath == null || relativePath.isEmpty()) { + log.warn("相对路径为空,无法获取文件对象"); + return null; + } + + try { + FileStorageStrategy strategy = strategyFactory.getDefaultStrategy(); + String absolutePath = strategy.getAbsolutePath(relativePath); + File file = new File(absolutePath); + + if (!file.exists()) { + log.warn("文件不存在: relativePath={}, absolutePath={}", relativePath, absolutePath); + } + + return file; + } catch (Exception e) { + log.error("获取文件对象失败: relativePath={}", relativePath, e); + return null; + } + } } diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/FileStorageStrategy.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/FileStorageStrategy.java index 6b843b3..9d426ee 100644 --- a/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/FileStorageStrategy.java +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/FileStorageStrategy.java @@ -61,5 +61,14 @@ public interface FileStorageStrategy { * @since 2025-10-16 */ String getStorageType(); + + /** + * @description 获取文件的绝对路径(用于临时文件) + * @param relativePath 相对路径 + * @return String 绝对路径 + * @author AI Assistant + * @since 2025-11-06 + */ + String getAbsolutePath(String relativePath); } diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/LocalFileStorageStrategy.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/LocalFileStorageStrategy.java index 0cc47d7..070750e 100644 --- a/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/LocalFileStorageStrategy.java +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/LocalFileStorageStrategy.java @@ -76,5 +76,10 @@ public class LocalFileStorageStrategy implements FileStorageStrategy { public String getStorageType() { return "local"; } + + @Override + public String getAbsolutePath(String relativePath) { + return basePath + File.separator + relativePath; + } } diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/MinIOFileStorageStrategy.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/MinIOFileStorageStrategy.java index 40db853..aff8794 100644 --- a/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/MinIOFileStorageStrategy.java +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/MinIOFileStorageStrategy.java @@ -137,5 +137,10 @@ public class MinIOFileStorageStrategy implements FileStorageStrategy { public String getStorageType() { return "minio"; } + + @Override + public String getAbsolutePath(String relativePath) { + return relativePath; + } } diff --git a/schoolNewsWeb/src/apis/ai/agent-config.ts b/schoolNewsWeb/src/apis/ai/agent-config.ts index a9c8d93..845badc 100644 --- a/schoolNewsWeb/src/apis/ai/agent-config.ts +++ b/schoolNewsWeb/src/apis/ai/agent-config.ts @@ -47,7 +47,9 @@ export const aiAgentConfigApi = { * @returns Promise> */ async getAgentById(agentId: string): Promise> { - const response = await api.get(`/ai/agent/${agentId}`); + const response = await api.get(`/ai/agent/${agentId}`, { + showLoading: false + }); return response.data; }, @@ -56,7 +58,9 @@ export const aiAgentConfigApi = { * @returns Promise> */ async listEnabledAgents(): Promise> { - const response = await api.get('/ai/agent/enabled'); + const response = await api.get('/ai/agent/enabled', { + showLoading: false + }); return response.data; }, diff --git a/schoolNewsWeb/src/apis/ai/chat-history.ts b/schoolNewsWeb/src/apis/ai/chat-history.ts index d5ea712..293f607 100644 --- a/schoolNewsWeb/src/apis/ai/chat-history.ts +++ b/schoolNewsWeb/src/apis/ai/chat-history.ts @@ -128,7 +128,7 @@ export const chatHistoryApi = { */ async getRecentConversations(limit = 10): Promise> { const response = await api.get('/ai/chat/history/recent', { - params: { limit } + limit }); return response.data; } diff --git a/schoolNewsWeb/src/apis/ai/chat.ts b/schoolNewsWeb/src/apis/ai/chat.ts index 38a23f6..7a99faf 100644 --- a/schoolNewsWeb/src/apis/ai/chat.ts +++ b/schoolNewsWeb/src/apis/ai/chat.ts @@ -18,114 +18,132 @@ import type { */ export const chatApi = { /** - * 流式对话(SSE)- 使用fetch支持Authorization + * 流式对话(SSE)- 两步法:POST准备 + GET建立SSE * @param request 对话请求 * @param callback 流式回调 * @returns Promise> */ async streamChat(request: ChatRequest, callback?: StreamCallback): Promise> { - const token = localStorage.getItem('token'); - const tokenData = token ? JSON.parse(token) : null; return new Promise((resolve, reject) => { - // 使用相对路径走Vite代理,避免跨域 - const eventSource = new EventSource( - `/api/ai/chat/stream?` + - new URLSearchParams({ + // 使用IIFE包装async逻辑,避免Promise executor是async的警告 + (async () => { + try { + const token = localStorage.getItem('token'); + const tokenData = token ? JSON.parse(token).value : ''; + // 第1步:POST准备会话,获取sessionId + const prepareResponse = await api.post('/ai/chat/stream/prepare', { agentId: request.agentId, conversationId: request.conversationId || '', query: request.query, - knowledgeIds: request.knowledgeIds?.join(',') || '', - token: tokenData?.value || '' - }) - ); + files: request.files || [] + }, { + showLoading: false + }); - // 通知外部EventSource已创建 - callback?.onStart?.(eventSource); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let fullMessage = ''; // 累积完整消息内容 - - // 监听初始化事件(包含messageId和conversationId) - eventSource.addEventListener('init', (event) => { - try { - const initData = JSON.parse(event.data); - console.log('[初始化数据]', initData); - // 通知外部保存messageId(用于停止生成) - if (callback?.onInit) { - callback.onInit(initData); + if (!prepareResponse.data.success || !prepareResponse.data.data) { + throw new Error(prepareResponse.data.message || '准备会话失败'); } - } catch (e) { - console.warn('解析init事件失败:', event.data); - } - }); - // 监听标准消息事件 - eventSource.addEventListener('message', (event) => { - const data = event.data; - fullMessage += data; - callback?.onMessage?.(data); - }); + const sessionId = prepareResponse.data.data; + console.log('[会话创建成功] sessionId:', sessionId); - // 监听结束事件 - eventSource.addEventListener('end', (event) => { - const metadata = JSON.parse(event.data); - callback?.onMessageEnd?.(metadata); - eventSource.close(); + // 第2步:GET建立SSE连接 + const eventSource = new EventSource( + `/api/ai/chat/stream?sessionId=${sessionId}&token=${tokenData}` + ); - resolve({ - code: 200, - success: true, - login: true, - auth: true, - data: metadata as AiMessage, - message: '对话成功' - }); - }); + // 通知外部EventSource已创建 + callback?.onStart?.(eventSource); - // 监听所有Dify原始事件(workflow_started, node_started等) - const difyEventTypes = [ - 'dify_workflow_started', - 'dify_node_started', - 'dify_node_finished', - 'dify_workflow_finished', - 'dify_message', - 'dify_agent_message', - 'dify_message_end', - 'dify_message_file', - 'dify_agent_thought', - 'dify_ping' - ]; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let fullMessage = ''; // 累积完整消息内容 - difyEventTypes.forEach(eventType => { - eventSource.addEventListener(eventType, (event: any) => { - try { - const eventData = JSON.parse(event.data); - console.log(`[Dify事件] ${eventType}:`, eventData); - - // 调用自定义的Dify事件回调 - if (callback?.onDifyEvent) { - const cleanEventType = eventType.replace('dify_', ''); - callback.onDifyEvent(cleanEventType, eventData); + // 监听初始化事件(包含messageId和conversationId) + eventSource.addEventListener('init', (event) => { + try { + const initData = JSON.parse(event.data); + console.log('[初始化数据]', initData); + // 通知外部保存messageId(用于停止生成) + if (callback?.onInit) { + callback.onInit(initData); + } + } catch (e) { + console.warn('解析init事件失败:', event.data); } - } catch (e) { - console.warn(`解析Dify事件失败 ${eventType}:`, event.data); - } - }); - }); + }); - // 监听错误事件 - eventSource.addEventListener('error', (event: any) => { - const error = new Error(event.data || '对话失败'); - callback?.onError?.(error); - eventSource.close(); - reject(error); - }); + // 监听标准消息事件 + eventSource.addEventListener('message', (event) => { + const data = event.data; + fullMessage += data; + callback?.onMessage?.(data); + }); - eventSource.onerror = (error) => { - callback?.onError?.(error as unknown as Error); - eventSource.close(); - reject(error); - }; + // 监听结束事件 + eventSource.addEventListener('end', (event) => { + const metadata = JSON.parse(event.data); + callback?.onMessageEnd?.(metadata); + eventSource.close(); + + resolve({ + code: 200, + success: true, + login: true, + auth: true, + data: metadata as AiMessage, + message: '对话成功' + }); + }); + + // 监听所有Dify原始事件(workflow_started, node_started等) + const difyEventTypes = [ + 'dify_workflow_started', + 'dify_node_started', + 'dify_node_finished', + 'dify_workflow_finished', + 'dify_message', + 'dify_agent_message', + 'dify_message_end', + 'dify_message_file', + 'dify_agent_thought', + 'dify_ping' + ]; + + difyEventTypes.forEach(eventType => { + eventSource.addEventListener(eventType, (event: any) => { + try { + const eventData = JSON.parse(event.data); + + // 调用自定义的Dify事件回调 + if (callback?.onDifyEvent) { + const cleanEventType = eventType.replace('dify_', ''); + callback.onDifyEvent(cleanEventType, eventData); + } + } catch (e) { + console.warn(`解析Dify事件失败 ${eventType}:`, event.data); + } + }); + }); + + // 监听错误事件 + eventSource.addEventListener('error', (event: any) => { + const error = new Error(event.data || '对话失败'); + callback?.onError?.(error); + eventSource.close(); + reject(error); + }); + + eventSource.onerror = (error) => { + callback?.onError?.(error as unknown as Error); + eventSource.close(); + reject(error); + }; + } catch (error) { + console.error('流式对话失败:', error); + callback?.onError?.(error as Error); + reject(error); + } + })(); // 立即执行IIFE }); }, @@ -173,6 +191,8 @@ export const chatApi = { const response = await api.post('/ai/chat/conversation', { agentId, title + }, { + showLoading: false }); return response.data; }, @@ -193,7 +213,9 @@ export const chatApi = { * @returns Promise> */ async updateConversation(conversation: AiConversation): Promise> { - const response = await api.put('/ai/chat/conversation', conversation); + const response = await api.put('/ai/chat/conversation', conversation, { + showLoading: false + }); return response.data; }, @@ -203,7 +225,9 @@ export const chatApi = { * @returns Promise> */ async deleteConversation(conversationId: string): Promise> { - const response = await api.delete(`/ai/chat/conversation/${conversationId}`); + const response = await api.delete(`/ai/chat/conversation/${conversationId}`, { + showLoading: false + }); return response.data; }, @@ -225,7 +249,9 @@ export const chatApi = { * @returns Promise> */ async listMessages(conversationId: string): Promise> { - const response = await api.get(`/ai/chat/conversation/${conversationId}/messages`); + const response = await api.get(`/ai/chat/conversation/${conversationId}/messages`, { + showLoading: false + }); return response.data; }, @@ -318,9 +344,7 @@ export const chatApi = { difyEventTypes.forEach(eventType => { eventSource.addEventListener(eventType, (event: any) => { try { - const eventData = JSON.parse(event.data); - console.log(`[Dify事件] ${eventType}:`, eventData); - + const eventData = JSON.parse(event.data); // 调用自定义的Dify事件回调 if (callback?.onDifyEvent) { const cleanEventType = eventType.replace('dify_', ''); @@ -369,6 +393,8 @@ export const chatApi = { const response = await api.post(`/ai/chat/message/${messageId}/rate`, { rating, feedback + }, { + showLoading: false }); return response.data; } diff --git a/schoolNewsWeb/src/apis/ai/file-upload.ts b/schoolNewsWeb/src/apis/ai/file-upload.ts index b53a35c..9f83944 100644 --- a/schoolNewsWeb/src/apis/ai/file-upload.ts +++ b/schoolNewsWeb/src/apis/ai/file-upload.ts @@ -10,6 +10,24 @@ import type { AiUploadFile, ResultDomain, FileUploadResponse, PageParam } from ' * 文件上传API服务 */ export const fileUploadApi = { + /** + * 上传文件用于对话(图文多模态) + * @param file 文件对象 + * @param agentId 智能体ID + * @returns Promise>> 返回Dify文件信息 + */ + async uploadFileForChat(file: File, agentId: string): Promise>> { + const formData = new FormData(); + formData.append('file', file); + formData.append('agentId', agentId); + + // 关闭加载提示,避免影响用户体验 + const response = await api.post>('/ai/file/upload-for-chat', formData, { + showLoading: false + }); + return response.data; + }, + /** * 上传单个文件到知识库 * @param knowledgeId 知识库ID @@ -21,7 +39,9 @@ export const fileUploadApi = { formData.append('file', file); formData.append('knowledgeId', knowledgeId); - const response = await api.post('/ai/file/upload', formData); + const response = await api.post('/ai/file/upload', formData, { + showLoading: false + }); return response.data; }, @@ -38,7 +58,9 @@ export const fileUploadApi = { }); formData.append('knowledgeId', knowledgeId); - const response = await api.post('/ai/file/batch-upload', formData); + const response = await api.post('/ai/file/batch-upload', formData, { + showLoading: false + }); return response.data; }, @@ -104,5 +126,17 @@ export const fileUploadApi = { async batchSyncFileStatus(fileIds: string[]): Promise> { const response = await api.post('/ai/file/batch-sync', { fileIds }); return response.data; + }, + + /** + * 查询消息关联的文件列表 + * @param messageId 消息ID + * @returns Promise> + */ + async listFilesByMessage(messageId: string): Promise> { + const response = await api.get(`/ai/file/message/${messageId}`, { + showLoading: false + }); + return response.data; } }; diff --git a/schoolNewsWeb/src/types/ai/index.ts b/schoolNewsWeb/src/types/ai/index.ts index a094d87..cd221ce 100644 --- a/schoolNewsWeb/src/types/ai/index.ts +++ b/schoolNewsWeb/src/types/ai/index.ts @@ -19,6 +19,8 @@ export interface AiAgentConfig extends BaseDTO { description?: string; /** 系统提示词 */ systemPrompt?: string; + /** 是否连接互联网(0否 1是) */ + connectInternet?: number; /** 模型名称 */ modelName?: string; /** 模型提供商 */ @@ -73,6 +75,8 @@ export interface AiKnowledge extends BaseDTO { export interface AiUploadFile extends BaseDTO { /** 知识库ID */ knowledgeId?: string; + /** 系统文件ID(关联tb_sys_file) */ + sysFileId?: string; /** 文件名 */ fileName?: string; /** 文件路径 */ @@ -139,6 +143,8 @@ export interface AiMessage extends BaseDTO { content?: string; /** 关联文件ID(JSON数组) */ fileIDs?: string; + /** 关联文件列表(前端附加,用于显示文件详情) */ + files?: AiUploadFile[]; /** 引用知识ID(JSON数组) */ knowledgeIDs?: string; /** 知识库引用详情(JSON数组) */ @@ -185,10 +191,15 @@ export interface ChatRequest { conversationId?: string; /** 用户问题 */ query: string; - /** 指定的知识库ID列表(可选) */ - knowledgeIds?: string[]; /** 是否流式返回 */ stream?: boolean; + fileIDs?: string; + /** 上传的文件列表(Dify文件信息) */ + files?: Array<{ + type: string; + transfer_method: string; + upload_file_id: string; + }>; } /** diff --git a/schoolNewsWeb/src/views/admin/manage/ai/AIConfigView.vue b/schoolNewsWeb/src/views/admin/manage/ai/AIConfigView.vue index a37ac97..3509ce8 100644 --- a/schoolNewsWeb/src/views/admin/manage/ai/AIConfigView.vue +++ b/schoolNewsWeb/src/views/admin/manage/ai/AIConfigView.vue @@ -55,6 +55,21 @@ show-word-limit /> + + +
+ +
+ 启用后,AI助手可以访问互联网获取实时信息 +
+
+
@@ -90,11 +105,20 @@ const configForm = ref({ name: '', avatar: '', systemPrompt: '', + connectInternet: 0, modelName: '', modelProvider: 'dify', status: 1 }); +// 联网开关(用于双向绑定) +const internetEnabled = computed({ + get: () => configForm.value.connectInternet || 0, + set: (val) => { + configForm.value.connectInternet = val; + } +}); + // 状态 const saving = ref(false); const loading = ref(false); @@ -119,9 +143,9 @@ async function loadConfig() { loading.value = true; // 获取启用的智能体列表 const result = await aiAgentConfigApi.listEnabledAgents(); - if (result.success && result.data && result.data.length > 0) { + if (result.success && result.dataList && result.dataList.length > 0) { // 使用第一个启用的智能体 - Object.assign(configForm.value, result.data[0]); + Object.assign(configForm.value, result.dataList[0]); } } catch (error) { console.error('加载配置失败:', error); @@ -391,12 +415,33 @@ async function handleReset() { } } +.internet-switch-container { + display: flex; + flex-direction: column; + gap: 8px; + + .internet-description { + font-size: 13px; + color: #6B7240; + line-height: 1.5; + } +} + :deep(.el-switch) { --el-switch-on-color: #E7000B; .el-switch__label { font-size: 14px; color: #0A0A0A; + font-weight: 500; + } + + &.is-checked .el-switch__label--left { + color: #6B7240; + } + + &:not(.is-checked) .el-switch__label--right { + color: #6B7240; } } diff --git a/schoolNewsWeb/src/views/public/ai/AIAgent.vue b/schoolNewsWeb/src/views/public/ai/AIAgent.vue index 5c4eb13..ad547f5 100644 --- a/schoolNewsWeb/src/views/public/ai/AIAgent.vue +++ b/schoolNewsWeb/src/views/public/ai/AIAgent.vue @@ -21,7 +21,7 @@
- @@ -83,6 +83,19 @@
{{ message.content }}
+ +
{{ formatMessageTime(message.createTime) }}
@@ -96,53 +109,36 @@
- -
+ +
+ + +
- - -
-
- - - -
-
-
- AI助手 - AI助手 -
-
-
-
- - - +
@@ -191,8 +187,8 @@