对话流实现 文件上传

This commit is contained in:
2025-11-06 16:43:28 +08:00
parent d9d62e22de
commit 0bb4853d54
35 changed files with 1748 additions and 575 deletions

View File

@@ -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`

View File

@@ -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()),

View File

@@ -27,6 +27,11 @@
<artifactId>api-ai</artifactId>
<version>${school-news.version}</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>api-file</artifactId>
<version>${school-news.version}</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>

View File

@@ -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)

View File

@@ -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<FileInfo> files;
private List<DifyFileInfo> 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;
}
}

View File

@@ -30,7 +30,8 @@ public class DifyConfig {
/**
* Dify API密钥默认密钥可被智能体的密钥覆盖
*/
private String apiKey="app-PTHzp2DsLPyiUrDYTXBGxL1f";
// private String apiKey="app-PTHzp2DsLPyiUrDYTXBGxL1f";
private String apiKey="app-fwOqGFLTsZtekCQYlOmj9f8x";
/**
* 请求超时时间(秒)

View File

@@ -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<String> 返回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<String> prepareStreamChat(@RequestBody Map<String, Object> requestBody) {
String agentId = (String) requestBody.get("agentId");
String conversationId = (String) requestBody.get("conversationId");
String query = (String) requestBody.get("query");
// 解析knowledgeIds
List<String> knowledgeIdList = null;
if (knowledgeIds != null && !knowledgeIds.isEmpty()) {
knowledgeIdList = Arrays.asList(knowledgeIds.split(","));
// 转换 files 数据
@SuppressWarnings("unchecked")
List<Map<String, Object>> filesRaw = (List<Map<String, Object>>) requestBody.get("files");
List<DifyFileInfo> 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<TbAiMessage>
* @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<String> knowledgeIds = (List<String>) requestBody.get("knowledgeIds");
log.info("阻塞式对话: agentId={}, conversationId={}, query={}", agentId, conversationId, query);
return chatService.blockingChat(agentId, conversationId, query, knowledgeIds);
return chatService.blockingChat(agentId, conversationId, query);
}

View File

@@ -29,6 +29,22 @@ public class AiFileUploadController {
@Autowired
private AiUploadFileService uploadFileService;
/**
* @description 上传文件用于对话(图文多模态)
* @param file 文件
* @param agentId 智能体ID
* @return ResultDomain<Map<String, Object>> 返回Dify文件信息
* @author AI Assistant
* @since 2025-11-06
*/
@PostMapping("/upload-for-chat")
public ResultDomain<java.util.Map<String, Object>> 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<TbAiUploadFile>
* @author AI Assistant
* @since 2025-11-06
*/
@GetMapping("/message/{messageId}")
public ResultDomain<TbAiUploadFile> getMessageFiles(@PathVariable(name = "messageId") String messageId) {
log.info("查询消息文件列表: messageId={}", messageId);
return uploadFileService.listFilesByMessageId(messageId);
}
/**
* @description 查询知识库的文件列表
* @param knowledgeId 知识库ID

View File

@@ -68,4 +68,18 @@ public interface AiUploadFileMapper extends BaseMapper<TbAiUploadFile> {
* @since 2025-10-15
*/
List<TbAiUploadFile> selectAiUploadFiles(TbAiUploadFile filter);
/**
* 根据消息ID查询关联的文件列表
* @param messageId 消息ID
* @return List<TbAiUploadFile> 文件列表
*/
List<TbAiUploadFile> selectFilesByMessageId(@Param("messageId") String messageId);
/**
* 批量插入文件记录
* @param files 文件列表
* @return 插入行数
*/
int batchInsertUploadFiles(@Param("files") List<TbAiUploadFile> files);
}

View File

@@ -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();
}

View File

@@ -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) {

View File

@@ -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<TbAiConversation> 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());

View File

@@ -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<String, Object> 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<String> knowledgeIds) {
public ResultDomain<String> prepareChatSession(String agentId, String conversationId, String query, List<DifyFileInfo> filesData) {
ResultDomain<String> 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<String, Object> sessionData = new HashMap<>();
sessionData.put("agentId", agentId);
sessionData.put("conversationId", conversationId);
sessionData.put("query", query);
sessionData.put("filesData", filesData);
sessionData.put("createTime", System.currentTimeMillis());
// 存入Redis5分钟过期
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<String, Object> sessionData = (Map<String, Object>) 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<DifyFileInfo> 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<TbAiConversation> createResult = createConversation(agentId, null);
ResultDomain<TbAiConversation> 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<String> 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<String> 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<String, Object> 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<TbAiMessage> blockingChat(
String agentId,
String conversationId,
String query,
List<String> knowledgeIds) {
String query){
ResultDomain<TbAiMessage> 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<String, Object> 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<String, Object> 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());
// 存入Redis5分钟过期
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<DifyFileInfo> filesData) {
try {
List<TbAiUploadFile> 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<String> 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<String> 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;
}
}
}

View File

@@ -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<String, Object> 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<TbAiKnowledge> allKnowledges = knowledgeMapper.selectAllKnowledges(new TbAiKnowledge());
// 按部门分组知识库
Map<String, List<String>> 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<String, List<String>> entry : deptKnowledgeMap.entrySet()) {
String deptId = entry.getKey();
List<String> 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<String> 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<String> getKnowledgeIdsByDeptPath(String deptPath) {
Set<String> 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<String> 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<String> deptKnowledgeIds = (List<String>) result;
knowledgeIdSet.addAll(deptKnowledgeIds);
log.debug("从部门 {} 获取到 {} 个知识库", deptId, deptKnowledgeIds.size());
}
}
List<String> 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);
}
}
}

View File

@@ -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<Map<String, Object>> uploadFileForChat(MultipartFile file, String agentId) {
ResultDomain<Map<String, Object>> 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<TbSysFile> 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<String, Object> 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<TbAiUploadFile> uploadToKnowledge(
@@ -113,64 +228,72 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
return resultDomain;
}
// 5. 保存临时文件
File tempFile = saveTempFile(file);
if (tempFile == null) {
resultDomain.fail("保存临时文件失败");
// 5. 保存到系统文件表(永久存储)
ResultDomain<TbSysFile> 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<String> failedFiles = new ArrayList<>();
for (MultipartFile file : files) {
ResultDomain<TbAiUploadFile> uploadResult = uploadToKnowledge(
knowledgeId, file, indexingTechnique
);
ResultDomain<TbAiUploadFile> 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<TbAiUploadFile> 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<TbAiUploadFile> 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<TbAiUploadFile> result = syncFileStatus(fileId);
@@ -574,5 +703,26 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
}
}, executorService);
}
}
@Override
public ResultDomain<TbAiUploadFile> listFilesByMessageId(String messageId) {
ResultDomain<TbAiUploadFile> resultDomain = new ResultDomain<>();
try {
if (!StringUtils.hasText(messageId)) {
resultDomain.fail("消息ID不能为空");
return resultDomain;
}
List<TbAiUploadFile> 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;
}
}
}

View File

@@ -8,12 +8,7 @@
<result column="name" property="name" jdbcType="VARCHAR"/>
<result column="avatar" property="avatar" jdbcType="VARCHAR"/>
<result column="description" property="description" jdbcType="VARCHAR"/>
<result column="system_prompt" property="systemPrompt" jdbcType="LONGVARCHAR"/>
<result column="model_name" property="modelName" jdbcType="VARCHAR"/>
<result column="model_provider" property="modelProvider" jdbcType="VARCHAR"/>
<result column="temperature" property="temperature" jdbcType="DECIMAL"/>
<result column="max_tokens" property="maxTokens" jdbcType="INTEGER"/>
<result column="top_p" property="topP" jdbcType="DECIMAL"/>
<result column="connect_internet" property="connectInternet" jdbcType="INTEGER"/>
<result column="dify_app_id" property="difyAppId" jdbcType="VARCHAR"/>
<result column="dify_api_key" property="difyApiKey" jdbcType="VARCHAR"/>
<result column="status" property="status" jdbcType="INTEGER"/>
@@ -27,8 +22,7 @@
<!-- 基础字段 -->
<sql id="Base_Column_List">
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
</sql>
@@ -39,12 +33,6 @@
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="modelName != null and modelName != ''">
AND model_name = #{modelName}
</if>
<if test="modelProvider != null and modelProvider != ''">
AND model_provider = #{modelProvider}
</if>
<if test="status != null">
AND status = #{status}
</if>
@@ -54,12 +42,10 @@
<!-- 插入智能体配置 -->
<insert id="insertAgentConfig" parameterType="org.xyzh.common.dto.ai.TbAiAgentConfig">
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}
)
</insert>
@@ -71,12 +57,7 @@
<if test="name != null and name != ''">name = #{name},</if>
<if test="avatar != null">avatar = #{avatar},</if>
<if test="description != null">description = #{description},</if>
<if test="systemPrompt != null">system_prompt = #{systemPrompt},</if>
<if test="modelName != null">model_name = #{modelName},</if>
<if test="modelProvider != null">model_provider = #{modelProvider},</if>
<if test="temperature != null">temperature = #{temperature},</if>
<if test="maxTokens != null">max_tokens = #{maxTokens},</if>
<if test="topP != null">top_p = #{topP},</if>
<if test="connectInternet != null">connect_internet = #{connectInternet},</if>
<if test="difyAppId != null">dify_app_id = #{difyAppId},</if>
<if test="difyApiKey != null">dify_api_key = #{difyApiKey},</if>
<if test="status != null">status = #{status},</if>
@@ -116,9 +97,6 @@
<if test="filter.status != null">
AND status = #{filter.status}
</if>
<if test="filter.modelProvider != null and filter.modelProvider != ''">
AND model_provider = #{filter.modelProvider}
</if>
</if>
ORDER BY create_time DESC
</select>
@@ -136,9 +114,6 @@
<if test="filter.status != null">
AND status = #{filter.status}
</if>
<if test="filter.modelProvider != null and filter.modelProvider != ''">
AND model_provider = #{filter.modelProvider}
</if>
</if>
ORDER BY create_time DESC
LIMIT #{pageParam.offset}, #{pageParam.pageSize}
@@ -156,9 +131,6 @@
<if test="filter.status != null">
AND status = #{filter.status}
</if>
<if test="filter.modelProvider != null and filter.modelProvider != ''">
AND model_provider = #{filter.modelProvider}
</if>
</if>
</select>

View File

@@ -8,6 +8,8 @@
<result column="user_id" property="userID" jdbcType="VARCHAR"/>
<result column="knowledge_id" property="knowledgeId" jdbcType="VARCHAR"/>
<result column="conversation_id" property="conversationID" jdbcType="VARCHAR"/>
<result column="message_id" property="messageID" jdbcType="VARCHAR"/>
<result column="sys_file_id" property="sysFileId" jdbcType="VARCHAR"/>
<result column="file_name" property="fileName" jdbcType="VARCHAR"/>
<result column="file_path" property="filePath" jdbcType="VARCHAR"/>
<result column="file_size" property="fileSize" jdbcType="BIGINT"/>
@@ -16,12 +18,10 @@
<result column="extracted_text" property="extractedText" jdbcType="LONGVARCHAR"/>
<result column="dify_document_id" property="difyDocumentId" jdbcType="VARCHAR"/>
<result column="dify_batch_id" property="difyBatchId" jdbcType="VARCHAR"/>
<result column="vector_status" property="vectorStatus" jdbcType="INTEGER"/>
<result column="dify_upload_file_id" property="difyUploadFileId" jdbcType="VARCHAR"/>
<result column="chunk_count" property="chunkCount" jdbcType="INTEGER"/>
<result column="status" property="status" jdbcType="INTEGER"/>
<result column="error_message" property="errorMessage" jdbcType="VARCHAR"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/>
<result column="updater" property="updater" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
<result column="delete_time" property="deleteTime" jdbcType="TIMESTAMP"/>
@@ -30,9 +30,9 @@
<!-- 基础字段 -->
<sql id="Base_Column_List">
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
</sql>
@@ -56,9 +56,6 @@
<if test="filter.fileType != null and filter.fileType != ''">
AND file_type = #{filter.fileType}
</if>
<if test="filter.vectorStatus != null">
AND vector_status = #{filter.vectorStatus}
</if>
<if test="filter.status != null">
AND status = #{filter.status}
</if>
@@ -69,14 +66,14 @@
<!-- insertUploadFile插入文件记录 -->
<insert id="insertUploadFile" parameterType="org.xyzh.common.dto.ai.TbAiUploadFile">
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}
)
</insert>
@@ -88,6 +85,8 @@
<if test="userID != null">user_id = #{userID},</if>
<if test="knowledgeId != null">knowledge_id = #{knowledgeId},</if>
<if test="conversationID != null">conversation_id = #{conversationID},</if>
<if test="messageID != null">message_id = #{messageID},</if>
<if test="sysFileId != null">sys_file_id = #{sysFileId},</if>
<if test="fileName != null">file_name = #{fileName},</if>
<if test="filePath != null">file_path = #{filePath},</if>
<if test="fileSize != null">file_size = #{fileSize},</if>
@@ -96,11 +95,10 @@
<if test="extractedText != null">extracted_text = #{extractedText},</if>
<if test="difyDocumentId != null">dify_document_id = #{difyDocumentId},</if>
<if test="difyBatchId != null">dify_batch_id = #{difyBatchId},</if>
<if test="vectorStatus != null">vector_status = #{vectorStatus},</if>
<if test="difyUploadFileId != null">dify_upload_file_id = #{difyUploadFileId},</if>
<if test="chunkCount != null">chunk_count = #{chunkCount},</if>
<if test="status != null">status = #{status},</if>
<if test="errorMessage != null">error_message = #{errorMessage},</if>
<if test="updater != null">updater = #{updater},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</set>
WHERE id = #{ID} AND deleted = 0
@@ -110,8 +108,7 @@
<update id="deleteUploadFile" parameterType="org.xyzh.common.dto.ai.TbAiUploadFile">
UPDATE tb_ai_upload_file
SET deleted = 1,
delete_time = NOW(),
updater = #{updater}
delete_time = NOW()
WHERE id = #{ID} AND deleted = 0
</update>
@@ -186,4 +183,31 @@
ORDER BY create_time DESC
</select>
<!-- selectFilesByMessageId根据消息ID查询关联的文件列表 -->
<select id="selectFilesByMessageId" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM tb_ai_upload_file
WHERE message_id = #{messageId}
AND deleted = 0
ORDER BY create_time ASC
</select>
<!-- batchInsertUploadFiles批量插入文件记录 -->
<insert id="batchInsertUploadFiles" parameterType="java.util.List">
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
<foreach collection="files" item="file" separator=",">
(
#{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}
)
</foreach>
</insert>
</mapper>

View File

@@ -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<String> 返回sessionId
*/
SseEmitter streamChatWithSse(
ResultDomain<String> prepareChatSession(
String agentId,
String conversationId,
String query,
List<String> knowledgeIds
List<DifyFileInfo> filesData
);
/**
* 流式对话SSE- 使用sessionId建立SSE连接
* @param sessionId 会话标识
* @return SseEmitter 流式推送对象
*/
SseEmitter streamChatWithSse(String sessionId);
/**
* 阻塞式对话(非流式)
* @param agentId 智能体ID
@@ -42,8 +50,7 @@ public interface AiChatService {
ResultDomain<TbAiMessage> blockingChat(
String agentId,
String conversationId,
String query,
List<String> knowledgeIds
String query
);

View File

@@ -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 {
/**
* 文件IDDify返回
*/
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;
}

View File

@@ -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<Map<String, Object>> uploadFileForChat(
MultipartFile file,
String agentId
);
/**
* 上传文件到知识库同步到Dify
* @param knowledgeId 知识库ID
@@ -92,4 +104,11 @@ public interface AiUploadFileService {
* @return 同步结果
*/
ResultDomain<TbAiUploadFile> syncKnowledgeFiles(String knowledgeId);
/**
* 查询消息关联的文件列表
* @param messageId 消息ID
* @return 文件列表
*/
ResultDomain<TbAiUploadFile> listFilesByMessageId(String messageId);
}

View File

@@ -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<TbSysFile> batchDeleteFiles(String[] fileIds);
/**
* @description 保存临时文件(不入库,仅存储到磁盘)
* @param file 文件对象
* @param module 模块名称
* @return ResultDomain<String> 返回临时文件路径
* @author yslg
* @since 2025-11-06
*/
ResultDomain<String> saveTempFile(MultipartFile file, String module);
/**
* @description 删除临时文件
* @param tempFilePath 临时文件路径
* @return ResultDomain<Boolean> 删除结果
* @author yslg
* @since 2025-11-06
*/
ResultDomain<Boolean> deleteTempFile(String tempFilePath);
/**
* @description 通过相对路径获取文件的绝对路径File对象
* @param relativePath 相对路径
* @return File 文件对象
* @author yslg
* @since 2025-11-06
*/
File getFileByRelativePath(String relativePath);
}

View File

@@ -101,6 +101,10 @@
<artifactId>common-dto</artifactId>
<version>${school-news.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>

View File

@@ -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() +
'}';
}

View File

@@ -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;
}

View File

@@ -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<String> saveTempFile(MultipartFile file, String module) {
ResultDomain<String> 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<Boolean> deleteTempFile(String tempFilePath) {
ResultDomain<Boolean> 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;
}
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -137,5 +137,10 @@ public class MinIOFileStorageStrategy implements FileStorageStrategy {
public String getStorageType() {
return "minio";
}
@Override
public String getAbsolutePath(String relativePath) {
return relativePath;
}
}

View File

@@ -47,7 +47,9 @@ export const aiAgentConfigApi = {
* @returns Promise<ResultDomain<AiAgentConfig>>
*/
async getAgentById(agentId: string): Promise<ResultDomain<AiAgentConfig>> {
const response = await api.get<AiAgentConfig>(`/ai/agent/${agentId}`);
const response = await api.get<AiAgentConfig>(`/ai/agent/${agentId}`, {
showLoading: false
});
return response.data;
},
@@ -56,7 +58,9 @@ export const aiAgentConfigApi = {
* @returns Promise<ResultDomain<AiAgentConfig[]>>
*/
async listEnabledAgents(): Promise<ResultDomain<AiAgentConfig>> {
const response = await api.get<AiAgentConfig>('/ai/agent/enabled');
const response = await api.get<AiAgentConfig>('/ai/agent/enabled', {
showLoading: false
});
return response.data;
},

View File

@@ -128,7 +128,7 @@ export const chatHistoryApi = {
*/
async getRecentConversations(limit = 10): Promise<ResultDomain<AiConversation>> {
const response = await api.get<AiConversation>('/ai/chat/history/recent', {
params: { limit }
limit
});
return response.data;
}

View File

@@ -18,114 +18,132 @@ import type {
*/
export const chatApi = {
/**
* 流式对话SSE- 使用fetch支持Authorization
* 流式对话SSE- 两步法POST准备 + GET建立SSE
* @param request 对话请求
* @param callback 流式回调
* @returns Promise<ResultDomain<AiMessage>>
*/
async streamChat(request: ChatRequest, callback?: StreamCallback): Promise<ResultDomain<AiMessage>> {
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<string>('/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<AiConversation>('/ai/chat/conversation', {
agentId,
title
}, {
showLoading: false
});
return response.data;
},
@@ -193,7 +213,9 @@ export const chatApi = {
* @returns Promise<ResultDomain<AiConversation>>
*/
async updateConversation(conversation: AiConversation): Promise<ResultDomain<AiConversation>> {
const response = await api.put<AiConversation>('/ai/chat/conversation', conversation);
const response = await api.put<AiConversation>('/ai/chat/conversation', conversation, {
showLoading: false
});
return response.data;
},
@@ -203,7 +225,9 @@ export const chatApi = {
* @returns Promise<ResultDomain<boolean>>
*/
async deleteConversation(conversationId: string): Promise<ResultDomain<boolean>> {
const response = await api.delete<boolean>(`/ai/chat/conversation/${conversationId}`);
const response = await api.delete<boolean>(`/ai/chat/conversation/${conversationId}`, {
showLoading: false
});
return response.data;
},
@@ -225,7 +249,9 @@ export const chatApi = {
* @returns Promise<ResultDomain<AiMessage[]>>
*/
async listMessages(conversationId: string): Promise<ResultDomain<AiMessage>> {
const response = await api.get<AiMessage>(`/ai/chat/conversation/${conversationId}/messages`);
const response = await api.get<AiMessage>(`/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<boolean>(`/ai/chat/message/${messageId}/rate`, {
rating,
feedback
}, {
showLoading: false
});
return response.data;
}

View File

@@ -10,6 +10,24 @@ import type { AiUploadFile, ResultDomain, FileUploadResponse, PageParam } from '
* 文件上传API服务
*/
export const fileUploadApi = {
/**
* 上传文件用于对话(图文多模态)
* @param file 文件对象
* @param agentId 智能体ID
* @returns Promise<ResultDomain<Record<string, any>>> 返回Dify文件信息
*/
async uploadFileForChat(file: File, agentId: string): Promise<ResultDomain<Record<string, any>>> {
const formData = new FormData();
formData.append('file', file);
formData.append('agentId', agentId);
// 关闭加载提示,避免影响用户体验
const response = await api.post<Record<string, any>>('/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<FileUploadResponse>('/ai/file/upload', formData);
const response = await api.post<FileUploadResponse>('/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<FileUploadResponse[]>('/ai/file/batch-upload', formData);
const response = await api.post<FileUploadResponse[]>('/ai/file/batch-upload', formData, {
showLoading: false
});
return response.data;
},
@@ -104,5 +126,17 @@ export const fileUploadApi = {
async batchSyncFileStatus(fileIds: string[]): Promise<ResultDomain<number>> {
const response = await api.post<number>('/ai/file/batch-sync', { fileIds });
return response.data;
},
/**
* 查询消息关联的文件列表
* @param messageId 消息ID
* @returns Promise<ResultDomain<AiUploadFile[]>>
*/
async listFilesByMessage(messageId: string): Promise<ResultDomain<AiUploadFile[]>> {
const response = await api.get<AiUploadFile[]>(`/ai/file/message/${messageId}`, {
showLoading: false
});
return response.data;
}
};

View File

@@ -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;
/** 关联文件IDJSON数组 */
fileIDs?: string;
/** 关联文件列表(前端附加,用于显示文件详情) */
files?: AiUploadFile[];
/** 引用知识IDJSON数组 */
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;
}>;
}
/**

View File

@@ -55,6 +55,21 @@
show-word-limit
/>
</el-form-item>
<el-form-item label="联网功能">
<div class="internet-switch-container">
<el-switch
v-model="internetEnabled"
active-text="启用联网"
inactive-text="关闭联网"
:active-value="1"
:inactive-value="0"
/>
<div class="internet-description">
启用后AI助手可以访问互联网获取实时信息
</div>
</div>
</el-form-item>
</div>
<!-- 操作按钮 -->
@@ -90,11 +105,20 @@ const configForm = ref<AiAgentConfig>({
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;
}
}
</style>

View File

@@ -21,7 +21,7 @@
<div v-if="!historyCollapsed" class="history-list">
<!-- 新建对话按钮 -->
<button class="new-chat-btn" @click="() => createNewConversation()">
<button class="new-chat-btn" @click="prepareNewConversation">
+ 新建对话
</button>
@@ -83,6 +83,19 @@
</div>
<div class="message-content">
<div class="message-text">{{ message.content }}</div>
<!-- 文件列表 -->
<div v-if="message.files && message.files.length > 0" class="message-files">
<div v-for="file in message.files" :key="file.id" class="message-file-item">
<a :href="`/api/file/download/${file.sysFileId || file.filePath}`"
:download="file.fileName"
target="_blank"
class="file-link">
<span class="file-icon">📎</span>
<span class="file-name">{{ file.fileName }}</span>
<span class="file-size">({{ formatFileSize(file.fileSize) }})</span>
</a>
</div>
</div>
<div class="message-time">{{ formatMessageTime(message.createTime) }}</div>
</div>
</div>
@@ -96,53 +109,36 @@
</div>
</div>
<div class="message-content">
<!-- 如果内容为空且正在生成显示加载动画 -->
<div v-if="!message.content && isGenerating" class="typing-indicator">
<!-- 显示消息内容 -->
<div v-if="message.content" class="message-text" v-html="formatMarkdown(message.content)"></div>
<!-- 正在生成中的加载动画 -->
<div v-if="isGenerating && messages[messages.length - 1]?.id === message.id" class="typing-indicator-inline">
<span></span>
<span></span>
<span></span>
</div>
<!-- 否则显示实际内容 -->
<template v-else>
<div class="message-text" v-html="formatMarkdown(message.content || '')"></div>
<div class="message-footer">
<div class="message-time">{{ formatMessageTime(message.createTime) }}</div>
<div class="message-actions">
<button @click="copyMessage(message.content || '')" class="msg-action-btn" title="复制">
📋
</button>
<button @click="regenerateMessage(message.id || '')" class="msg-action-btn" title="重新生成">
🔄
</button>
<button @click="rateMessage(message.id || '', 1)" class="msg-action-btn"
:class="{ active: message.rating === 1 }" title="好评">
👍
</button>
<button @click="rateMessage(message.id || '', -1)" class="msg-action-btn"
:class="{ active: message.rating === -1 }" title="差评">
👎
</button>
</div>
<!-- 消息底部时间和操作按钮 -->
<div v-if="message.content" class="message-footer">
<div class="message-time">{{ formatMessageTime(message.createTime) }}</div>
<div class="message-actions">
<button @click="copyMessage(message.content || '')" class="msg-action-btn" title="复制">
📋
</button>
<button @click="regenerateMessage(message.id || '')" class="msg-action-btn" title="重新生成">
🔄
</button>
<button @click="rateMessage(message.id || '', 1)" class="msg-action-btn"
:class="{ active: message.rating === 1 }" title="好评">
👍
</button>
<button @click="rateMessage(message.id || '', -1)" class="msg-action-btn"
:class="{ active: message.rating === -1 }" title="差评">
👎
</button>
</div>
</template>
</div>
</div>
</div>
<!-- 加载中提示只在还没有AI消息时显示 -->
<div v-if="isGenerating && (!messages.length || messages[messages.length - 1]?.role !== 'assistant')"
class="message ai-message generating">
<div class="message-avatar">
<div class="avatar-circle ai-avatar">
<img v-if="agentAvatarUrl" :src="agentAvatarUrl" alt="AI助手" class="ai-avatar-img" />
<img v-else src="@/assets/imgs/assistant.svg" alt="AI助手" class="ai-avatar-img" />
</div>
</div>
<div class="message-content">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
@@ -191,8 +187,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { chatApi, chatHistoryApi, aiAgentConfigApi } from '@/apis/ai';
import type { AiConversation, AiMessage, AiAgentConfig } from '@/types/ai';
import { chatApi, chatHistoryApi, aiAgentConfigApi, fileUploadApi } from '@/apis/ai';
import type { AiConversation, AiMessage, AiAgentConfig, AiUploadFile } from '@/types/ai';
interface AIAgentProps {
agentId?: string;
@@ -480,21 +476,27 @@ async function loadMoreConversations() {
// TODO: 实现分页加载
}
// 新建对话
// 准备新对话只清空状态不创建conversation
function prepareNewConversation() {
currentConversation.value = null;
messages.value = [];
ElMessage.success('已准备新对话,发送消息后将自动创建');
}
// 创建新对话(内部使用,在发送第一条消息时调用)
async function createNewConversation(title?: string) {
try {
const result = await chatApi.createConversation(agentConfig.value!.id!, title);
if (result.success && result.data) {
currentConversation.value = result.data;
// 将新创建的对话添加到列表开头
conversations.value.unshift(result.data);
messages.value = [];
if (!title) {
ElMessage.success('已创建新对话');
}
return result.data;
}
} catch (error) {
console.error('创建对话失败:', error);
ElMessage.error('创建对话失败');
return null;
}
}
@@ -594,6 +596,9 @@ const difyEventData = ref<Record<string, any>>({}); // 存储Dify事件数据
const chatContentRef = ref<HTMLElement | null>(null);
const inputRef = ref<HTMLTextAreaElement | null>(null);
// 消息文件列表缓存
const messageFilesCache = ref<Record<string, any[]>>({});
// 加载消息
async function loadMessages(conversationId: string) {
try {
@@ -602,6 +607,14 @@ async function loadMessages(conversationId: string) {
// 后端返回List所以数据在dataList字段
const messageList = result.dataList || result.data || [];
messages.value = Array.isArray(messageList) ? messageList : [];
// 加载每条用户消息的关联文件
for (const message of messages.value) {
if (message.role === 'user' && message.id) {
await loadMessageFiles(message.id);
}
}
await nextTick();
scrollToBottom();
}
@@ -610,6 +623,24 @@ async function loadMessages(conversationId: string) {
}
}
// 加载消息关联的文件列表
async function loadMessageFiles(messageId: string) {
try {
const result = await fileUploadApi.listFilesByMessage(messageId);
if (result.success && result.dataList) {
messageFilesCache.value[messageId] = result.dataList;
// 将文件列表附加到消息对象上
const message = messages.value.find(m => m.id === messageId);
if (message) {
(message as any).files = result.dataList;
}
}
} catch (error) {
console.error('加载消息文件失败:', error);
}
}
// 发送消息
async function sendMessage() {
if (!canSend.value) return;
@@ -620,10 +651,13 @@ async function sendMessage() {
// 如果没有当前对话,创建新对话,使用第一个问题作为标题
const isFirstMessage = !currentConversation.value;
if (isFirstMessage) {
// 限制标题长度为50字符
const title = message.length > 50 ? message.substring(0, 50) + '...' : message;
await createNewConversation(title);
if (!currentConversation.value) return;
// 限制标题长度为20字符
const title = message.length > 20 ? message.substring(0, 20) + '...' : message;
const newConv = await createNewConversation(title);
if (!newConv) {
ElMessage.error('创建对话失败');
return;
}
}
// 添加用户消息到界面
@@ -648,11 +682,20 @@ async function sendMessage() {
try {
let aiMessageContent = '';
await chatApi.streamChat({
chatApi.streamChat({
agentId: agentConfig.value!.id!,
conversationId: currentConversation.value?.id || '',
query: message,
knowledgeIds: []
files: uploadedFiles.value.map(f => ({
id: f.id, // Dify文件ID
sys_file_id: f.sys_file_id, // 系统文件ID用于保存关联记录
file_path: f.file_path, // 文件路径(用于保存记录)
name: f.name,
size: f.size,
type: f.type,
transfer_method: f.transfer_method,
upload_file_id: f.upload_file_id
}))
},
{
onStart: (eventSource: EventSource) => {
@@ -681,7 +724,6 @@ async function sendMessage() {
updateTime: new Date().toISOString()
});
}
// 累加内容包括空chunk因为后端可能分块发送
if (chunk) {
aiMessageContent += chunk;
@@ -697,7 +739,7 @@ async function sendMessage() {
},
onDifyEvent: (eventType: string, eventData: any) => {
// 处理Dify原始事件包含完整信息
console.log(`[Dify事件] ${eventType}:`, eventData);
// 存储事件数据
difyEventData.value[eventType] = eventData;
@@ -712,17 +754,12 @@ async function sendMessage() {
// 例如node_started, node_finished, agent_thought等
},
onMessageEnd: () => {
// 消息结束,如果是第一条消息,更新会话列表中的标题
if (isFirstMessage && currentConversation.value) {
const convIndex = conversations.value.findIndex(c => c.id === currentConversation.value!.id);
if (convIndex !== -1) {
conversations.value[convIndex].title = currentConversation.value.title;
}
}
isGenerating.value = false;
currentEventSource.value = null;
currentTaskId.value = null;
currentMessageId.value = null;
// 清空已上传的文件列表(文件仅对一次消息发送生效)
uploadedFiles.value = [];
},
onError: (error: Error) => {
console.error('对话失败:', error);
@@ -731,14 +768,27 @@ async function sendMessage() {
currentEventSource.value = null;
currentTaskId.value = null;
currentMessageId.value = null;
// 发送失败也清空文件列表
uploadedFiles.value = [];
}
}
);
// 把文件放到message里面转换为AiUploadFile格式
userMessage.files = uploadedFiles.value.map(f => ({
id: f.id,
sysFileId: f.sys_file_id,
fileName: f.name,
filePath: f.file_path,
fileSize: f.size,
fileType: f.type
} as AiUploadFile));
} catch (error) {
console.error('发送消息失败:', error);
ElMessage.error('发送消息失败');
isGenerating.value = false;
currentEventSource.value = null;
// 发送失败也清空文件列表
uploadedFiles.value = [];
}
}
@@ -866,8 +916,6 @@ async function regenerateMessage(messageId: string) {
nextTick(() => scrollToBottom());
},
onDifyEvent: (eventType: string, eventData: any) => {
// 处理Dify原始事件包含完整信息
console.log(`[Dify事件-重新生成] ${eventType}:`, eventData);
// 存储事件数据
difyEventData.value[eventType] = eventData;
@@ -923,24 +971,64 @@ async function rateMessage(messageId: string, rating: number) {
}
// ===== 文件上传相关 =====
const uploadedFiles = ref<File[]>([]);
interface DifyFile {
id: string;
name: string;
size: number;
type: string;
transfer_method: string;
upload_file_id: string;
localFile?: File; // 保留原始File对象用于显示
sys_file_id: string;
file_path: string;
}
const uploadedFiles = ref<DifyFile[]>([]);
const fileInputRef = ref<HTMLInputElement | null>(null);
const isUploading = ref(false);
function triggerFileUpload() {
fileInputRef.value?.click();
}
function handleFileUpload(e: Event) {
async function handleFileUpload(e: Event) {
const target = e.target as HTMLInputElement;
const files = target.files;
if (files && files.length > 0) {
uploadedFiles.value.push(...Array.from(files));
ElMessage.success(`已添加 ${files.length} 个文件`);
if (!files || files.length === 0) return;
if (!agentConfig.value?.id) {
ElMessage.error('智能体未加载');
return;
}
// 清空input允许重复选择同一文件
target.value = '';
isUploading.value = true;
try {
// 逐个上传文件到Dify
for (const file of Array.from(files)) {
try {
const result = await fileUploadApi.uploadFileForChat(file, agentConfig.value.id);
if (result.success && result.data) {
// 保存Dify返回的文件信息
uploadedFiles.value.push({
...result.data as DifyFile,
localFile: file // 保留原始文件用于显示
});
ElMessage.success(`${file.name} 上传成功`);
} else {
ElMessage.error(`${file.name} 上传失败: ${result.message}`);
}
} catch (error) {
console.error(`上传文件失败: ${file.name}`, error);
ElMessage.error(`${file.name} 上传失败`);
}
}
} finally {
isUploading.value = false;
// 清空input允许重复选择同一文件
target.value = '';
}
}
function removeUploadedFile(index: number) {
@@ -974,6 +1062,15 @@ function formatMessageTime(dateStr: string | undefined) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
// 格式化文件大小
function formatFileSize(bytes: number | undefined): string {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
}
function formatMarkdown(content: string) {
// 简单的 Markdown 转换(可以使用 marked.js 等库进行更复杂的转换)
let html = content;
@@ -1507,6 +1604,30 @@ onUnmounted(() => {
}
}
}
// 内联加载动画(在消息内容下方显示)
.typing-indicator-inline {
display: flex;
gap: 4px;
padding: 8px 0;
width: fit-content;
span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #9CA3AF;
animation: typing 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
.current-chat-input {
@@ -1546,6 +1667,49 @@ onUnmounted(() => {
}
}
.message-files {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
.message-file-item {
.file-link {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
text-decoration: none;
color: inherit;
transition: all 0.2s;
font-size: 13px;
&:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateX(2px);
}
.file-icon {
font-size: 16px;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
opacity: 0.7;
font-size: 12px;
}
}
}
}
.input-area {
display: flex;
gap: 12px;