对话流实现 文件上传
This commit is contained in:
@@ -6,12 +6,7 @@ CREATE TABLE `tb_ai_agent_config` (
|
|||||||
`name` VARCHAR(100) NOT NULL COMMENT '智能体名称',
|
`name` VARCHAR(100) NOT NULL COMMENT '智能体名称',
|
||||||
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '智能体头像',
|
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '智能体头像',
|
||||||
`description` VARCHAR(500) DEFAULT NULL COMMENT '智能体描述',
|
`description` VARCHAR(500) DEFAULT NULL COMMENT '智能体描述',
|
||||||
`system_prompt` TEXT COMMENT '系统提示词',
|
`connect_internet` INT(4) DEFAULT 0 COMMENT '是否连接互联网(0否 1是)',
|
||||||
`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值',
|
|
||||||
`dify_app_id` VARCHAR(100) DEFAULT NULL COMMENT 'Dify应用ID',
|
`dify_app_id` VARCHAR(100) DEFAULT NULL COMMENT 'Dify应用ID',
|
||||||
`dify_api_key` VARCHAR(255) DEFAULT NULL COMMENT 'Dify应用API密钥',
|
`dify_api_key` VARCHAR(255) DEFAULT NULL COMMENT 'Dify应用API密钥',
|
||||||
`status` INT(4) DEFAULT 1 COMMENT '状态(0禁用 1启用)',
|
`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`;
|
DROP TABLE IF EXISTS `tb_ai_upload_file`;
|
||||||
CREATE TABLE `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',
|
`user_id` VARCHAR(50) NOT NULL COMMENT '用户ID',
|
||||||
`knowledge_id` VARCHAR(50) DEFAULT NULL COMMENT '所属知识库ID',
|
`knowledge_id` VARCHAR(50) DEFAULT NULL COMMENT '所属知识库ID',
|
||||||
`conversation_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_name` VARCHAR(255) NOT NULL COMMENT '文件名',
|
||||||
`file_path` VARCHAR(500) NOT NULL COMMENT '文件路径',
|
`file_path` VARCHAR(500) NOT NULL COMMENT '文件路径',
|
||||||
`file_size` BIGINT(20) DEFAULT 0 COMMENT '文件大小(字节)',
|
`file_size` BIGINT(20) DEFAULT 0 COMMENT '文件大小(字节)',
|
||||||
@@ -137,6 +134,7 @@ CREATE TABLE `tb_ai_upload_file` (
|
|||||||
`extracted_text` LONGTEXT COMMENT '提取的文本内容',
|
`extracted_text` LONGTEXT COMMENT '提取的文本内容',
|
||||||
`dify_document_id` VARCHAR(100) DEFAULT NULL COMMENT 'Dify文档ID',
|
`dify_document_id` VARCHAR(100) DEFAULT NULL COMMENT 'Dify文档ID',
|
||||||
`dify_batch_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 '分段数量',
|
`chunk_count` INT(11) DEFAULT 0 COMMENT '分段数量',
|
||||||
`status` INT(4) DEFAULT 0 COMMENT '状态(0上传中 1处理中 2已完成 3失败)',
|
`status` INT(4) DEFAULT 0 COMMENT '状态(0上传中 1处理中 2已完成 3失败)',
|
||||||
`error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息',
|
`error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息',
|
||||||
@@ -148,11 +146,14 @@ CREATE TABLE `tb_ai_upload_file` (
|
|||||||
KEY `idx_user` (`user_id`),
|
KEY `idx_user` (`user_id`),
|
||||||
KEY `idx_knowledge` (`knowledge_id`),
|
KEY `idx_knowledge` (`knowledge_id`),
|
||||||
KEY `idx_conversation` (`conversation_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_document` (`dify_document_id`),
|
||||||
|
KEY `idx_dify_upload_file` (`dify_upload_file_id`),
|
||||||
KEY `idx_status` (`status`),
|
KEY `idx_status` (`status`),
|
||||||
KEY `idx_create_time` (`create_time`),
|
KEY `idx_create_time` (`create_time`),
|
||||||
KEY `idx_deleted` (`deleted`)
|
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使用统计表
|
-- AI使用统计表
|
||||||
DROP TABLE IF EXISTS `tb_ai_usage_statistics`;
|
DROP TABLE IF EXISTS `tb_ai_usage_statistics`;
|
||||||
@@ -214,11 +215,10 @@ CREATE TABLE `tb_ai_usage_statistics` (
|
|||||||
|
|
||||||
-- 插入默认智能体配置
|
-- 插入默认智能体配置
|
||||||
INSERT INTO `tb_ai_agent_config`
|
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
|
VALUES
|
||||||
('agent_default_001', '校园助手', '/img/agent/default.png', '我是您的智能校园助手,可以帮助您解答校园相关问题',
|
('agent_default_001', '校园助手', NULL, '我是您的智能校园助手,可以帮助您解答校园相关问题',
|
||||||
'你是一个友好、专业的校园助手。你需要基于校园知识库回答用户问题,语气亲切自然。如果知识库中没有相关信息,请诚实告知用户。',
|
0, 1, '1', NOW());
|
||||||
'gpt-3.5-turbo', 'openai', 1, '1', NOW());
|
|
||||||
|
|
||||||
-- 插入示例知识库(需要配合权限表使用)
|
-- 插入示例知识库(需要配合权限表使用)
|
||||||
INSERT INTO `tb_ai_knowledge`
|
INSERT INTO `tb_ai_knowledge`
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
use school_news;
|
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)
|
-- 插入标签数据 (文章分类标签 tag_type=1)
|
||||||
INSERT INTO `tb_tag` (id, tag_id, name, color, description, tag_type, creator, create_time) VALUES
|
INSERT INTO `tb_tag` (id, tag_id, name, color, description, tag_type, creator, create_time) VALUES
|
||||||
('tag001', 'tag_article_001', '党史学习', '#ff6b6b', '党史学习相关文章', 1, '1', now()),
|
('tag001', 'tag_article_001', '党史学习', '#ff6b6b', '党史学习相关文章', 1, '1', now()),
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
<artifactId>api-ai</artifactId>
|
<artifactId>api-ai</artifactId>
|
||||||
<version>${school-news.version}</version>
|
<version>${school-news.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xyzh</groupId>
|
||||||
|
<artifactId>api-file</artifactId>
|
||||||
|
<version>${school-news.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.xyzh</groupId>
|
<groupId>org.xyzh</groupId>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package org.xyzh.ai.client;
|
package org.xyzh.ai.client;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.alibaba.fastjson2.JSON;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import okhttp3.*;
|
import okhttp3.*;
|
||||||
@@ -13,6 +13,7 @@ import org.xyzh.ai.client.dto.*;
|
|||||||
import org.xyzh.ai.client.callback.StreamCallback;
|
import org.xyzh.ai.client.callback.StreamCallback;
|
||||||
import org.xyzh.ai.config.DifyConfig;
|
import org.xyzh.ai.config.DifyConfig;
|
||||||
import org.xyzh.ai.exception.DifyException;
|
import org.xyzh.ai.exception.DifyException;
|
||||||
|
import org.xyzh.api.ai.dto.DifyFileInfo;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@@ -37,7 +38,6 @@ public class DifyApiClient {
|
|||||||
|
|
||||||
private OkHttpClient httpClient;
|
private OkHttpClient httpClient;
|
||||||
private OkHttpClient streamHttpClient;
|
private OkHttpClient streamHttpClient;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
@@ -69,7 +69,7 @@ public class DifyApiClient {
|
|||||||
String url = difyConfig.getFullApiUrl("/datasets");
|
String url = difyConfig.getFullApiUrl("/datasets");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String jsonBody = objectMapper.writeValueAsString(request);
|
String jsonBody = JSON.toJSONString(request);
|
||||||
Request httpRequest = new Request.Builder()
|
Request httpRequest = new Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Authorization", "Bearer " + getApiKey(apiKey))
|
.header("Authorization", "Bearer " + getApiKey(apiKey))
|
||||||
@@ -85,7 +85,7 @@ public class DifyApiClient {
|
|||||||
throw new DifyException("创建知识库失败: " + responseBody);
|
throw new DifyException("创建知识库失败: " + responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectMapper.readValue(responseBody, DatasetCreateResponse.class);
|
return JSON.parseObject(responseBody, DatasetCreateResponse.class);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("创建知识库异常", e);
|
logger.error("创建知识库异常", e);
|
||||||
@@ -114,7 +114,7 @@ public class DifyApiClient {
|
|||||||
throw new DifyException("查询知识库列表失败: " + responseBody);
|
throw new DifyException("查询知识库列表失败: " + responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectMapper.readValue(responseBody, DatasetListResponse.class);
|
return JSON.parseObject(responseBody, DatasetListResponse.class);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("查询知识库列表异常", e);
|
logger.error("查询知识库列表异常", e);
|
||||||
@@ -143,7 +143,7 @@ public class DifyApiClient {
|
|||||||
throw new DifyException("查询知识库详情失败: " + responseBody);
|
throw new DifyException("查询知识库详情失败: " + responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectMapper.readValue(responseBody, DatasetDetailResponse.class);
|
return JSON.parseObject(responseBody, DatasetDetailResponse.class);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("查询知识库详情异常", e);
|
logger.error("查询知识库详情异常", e);
|
||||||
@@ -159,7 +159,7 @@ public class DifyApiClient {
|
|||||||
String url = difyConfig.getFullApiUrl("/datasets/" + datasetId);
|
String url = difyConfig.getFullApiUrl("/datasets/" + datasetId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String jsonBody = objectMapper.writeValueAsString(request);
|
String jsonBody = JSON.toJSONString(request);
|
||||||
Request httpRequest = new Request.Builder()
|
Request httpRequest = new Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Authorization", "Bearer " + getApiKey(apiKey))
|
.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 =====================
|
// ===================== 文档管理 API =====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,7 +277,7 @@ public class DifyApiClient {
|
|||||||
bodyBuilder.addFormDataPart("indexing_technique", uploadRequest.getIndexingTechnique());
|
bodyBuilder.addFormDataPart("indexing_technique", uploadRequest.getIndexingTechnique());
|
||||||
}
|
}
|
||||||
if (uploadRequest.getProcessRule() != null) {
|
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()
|
Request httpRequest = new Request.Builder()
|
||||||
@@ -252,7 +294,7 @@ public class DifyApiClient {
|
|||||||
throw new DifyException("上传文档失败: " + responseBody);
|
throw new DifyException("上传文档失败: " + responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectMapper.readValue(responseBody, DocumentUploadResponse.class);
|
return JSON.parseObject(responseBody, DocumentUploadResponse.class);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("上传文档异常", e);
|
logger.error("上传文档异常", e);
|
||||||
@@ -281,7 +323,7 @@ public class DifyApiClient {
|
|||||||
throw new DifyException("查询文档状态失败: " + responseBody);
|
throw new DifyException("查询文档状态失败: " + responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectMapper.readValue(responseBody, DocumentStatusResponse.class);
|
return JSON.parseObject(responseBody, DocumentStatusResponse.class);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("查询文档状态异常", e);
|
logger.error("查询文档状态异常", e);
|
||||||
@@ -310,7 +352,7 @@ public class DifyApiClient {
|
|||||||
throw new DifyException("查询文档列表失败: " + responseBody);
|
throw new DifyException("查询文档列表失败: " + responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectMapper.readValue(responseBody, DocumentListResponse.class);
|
return JSON.parseObject(responseBody, DocumentListResponse.class);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("查询文档列表异常", e);
|
logger.error("查询文档列表异常", e);
|
||||||
@@ -354,7 +396,7 @@ public class DifyApiClient {
|
|||||||
String url = difyConfig.getFullApiUrl("/datasets/" + datasetId + "/retrieve");
|
String url = difyConfig.getFullApiUrl("/datasets/" + datasetId + "/retrieve");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String jsonBody = objectMapper.writeValueAsString(request);
|
String jsonBody = JSON.toJSONString(request);
|
||||||
Request httpRequest = new Request.Builder()
|
Request httpRequest = new Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Authorization", "Bearer " + getApiKey(apiKey))
|
.header("Authorization", "Bearer " + getApiKey(apiKey))
|
||||||
@@ -370,7 +412,7 @@ public class DifyApiClient {
|
|||||||
throw new DifyException("知识库检索失败: " + responseBody);
|
throw new DifyException("知识库检索失败: " + responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectMapper.readValue(responseBody, RetrievalResponse.class);
|
return JSON.parseObject(responseBody, RetrievalResponse.class);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("知识库检索异常", e);
|
logger.error("知识库检索异常", e);
|
||||||
@@ -390,7 +432,7 @@ public class DifyApiClient {
|
|||||||
// 设置为流式模式
|
// 设置为流式模式
|
||||||
request.setResponseMode("streaming");
|
request.setResponseMode("streaming");
|
||||||
|
|
||||||
String jsonBody = objectMapper.writeValueAsString(request);
|
String jsonBody = JSON.toJSONString(request);
|
||||||
Request httpRequest = new Request.Builder()
|
Request httpRequest = new Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Authorization", "Bearer " + getApiKey(apiKey))
|
.header("Authorization", "Bearer " + getApiKey(apiKey))
|
||||||
@@ -421,9 +463,9 @@ public class DifyApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!data.isEmpty()) {
|
if (!data.isEmpty()) {
|
||||||
// 解析SSE数据
|
// 使用Fastjson2解析SSE数据
|
||||||
JsonNode jsonNode = objectMapper.readTree(data);
|
JSONObject jsonNode = JSON.parseObject(data);
|
||||||
String event = jsonNode.has("event") ? jsonNode.get("event").asText() : "";
|
String event = jsonNode.containsKey("event") ? jsonNode.getString("event") : "";
|
||||||
|
|
||||||
// 转发所有事件到回调(包含完整数据)
|
// 转发所有事件到回调(包含完整数据)
|
||||||
callback.onEvent(event, data);
|
callback.onEvent(event, data);
|
||||||
@@ -432,8 +474,8 @@ public class DifyApiClient {
|
|||||||
case "message":
|
case "message":
|
||||||
case "agent_message":
|
case "agent_message":
|
||||||
// 消息内容
|
// 消息内容
|
||||||
if (jsonNode.has("answer")) {
|
if (jsonNode.containsKey("answer")) {
|
||||||
callback.onMessage(jsonNode.get("answer").asText());
|
callback.onMessage(jsonNode.getString("answer"));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "message_end":
|
case "message_end":
|
||||||
@@ -442,8 +484,8 @@ public class DifyApiClient {
|
|||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
// 错误事件
|
// 错误事件
|
||||||
String errorMsg = jsonNode.has("message") ?
|
String errorMsg = jsonNode.containsKey("message") ?
|
||||||
jsonNode.get("message").asText() : "未知错误";
|
jsonNode.getString("message") : "未知错误";
|
||||||
callback.onError(new DifyException(errorMsg));
|
callback.onError(new DifyException(errorMsg));
|
||||||
return;
|
return;
|
||||||
// 其他事件(workflow_started、node_started、node_finished等)
|
// 其他事件(workflow_started、node_started、node_finished等)
|
||||||
@@ -481,7 +523,7 @@ public class DifyApiClient {
|
|||||||
// 设置为阻塞模式
|
// 设置为阻塞模式
|
||||||
request.setResponseMode("blocking");
|
request.setResponseMode("blocking");
|
||||||
|
|
||||||
String jsonBody = objectMapper.writeValueAsString(request);
|
String jsonBody = JSON.toJSONString(request);
|
||||||
Request httpRequest = new Request.Builder()
|
Request httpRequest = new Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Authorization", "Bearer " + getApiKey(apiKey))
|
.header("Authorization", "Bearer " + getApiKey(apiKey))
|
||||||
@@ -497,7 +539,7 @@ public class DifyApiClient {
|
|||||||
throw new DifyException("阻塞式对话失败: " + responseBody);
|
throw new DifyException("阻塞式对话失败: " + responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectMapper.readValue(responseBody, ChatResponse.class);
|
return JSON.parseObject(responseBody, ChatResponse.class);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("阻塞式对话异常", e);
|
logger.error("阻塞式对话异常", e);
|
||||||
@@ -512,7 +554,7 @@ public class DifyApiClient {
|
|||||||
String url = difyConfig.getFullApiUrl("/chat-messages/" + taskId + "/stop");
|
String url = difyConfig.getFullApiUrl("/chat-messages/" + taskId + "/stop");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String jsonBody = objectMapper.writeValueAsString(new StopRequest(userId));
|
String jsonBody = JSON.toJSONString(new StopRequest(userId));
|
||||||
Request httpRequest = new Request.Builder()
|
Request httpRequest = new Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Authorization", "Bearer " + getApiKey(apiKey))
|
.header("Authorization", "Bearer " + getApiKey(apiKey))
|
||||||
@@ -546,7 +588,7 @@ public class DifyApiClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
FeedbackRequest feedbackRequest = new FeedbackRequest(rating, userId, feedback);
|
FeedbackRequest feedbackRequest = new FeedbackRequest(rating, userId, feedback);
|
||||||
String jsonBody = objectMapper.writeValueAsString(feedbackRequest);
|
String jsonBody = JSON.toJSONString(feedbackRequest);
|
||||||
|
|
||||||
Request httpRequest = new Request.Builder()
|
Request httpRequest = new Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
@@ -614,7 +656,7 @@ public class DifyApiClient {
|
|||||||
throw new DifyException("获取对话历史失败: " + responseBody);
|
throw new DifyException("获取对话历史失败: " + responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectMapper.readValue(responseBody, MessageHistoryResponse.class);
|
return JSON.parseObject(responseBody, MessageHistoryResponse.class);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("获取对话历史异常", e);
|
logger.error("获取对话历史异常", e);
|
||||||
@@ -656,7 +698,7 @@ public class DifyApiClient {
|
|||||||
throw new DifyException("获取对话列表失败: " + responseBody);
|
throw new DifyException("获取对话列表失败: " + responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectMapper.readValue(responseBody, ConversationListResponse.class);
|
return JSON.parseObject(responseBody, ConversationListResponse.class);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("获取对话列表异常", e);
|
logger.error("获取对话列表异常", e);
|
||||||
@@ -710,7 +752,7 @@ public class DifyApiClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String jsonBody = requestBody instanceof String ?
|
String jsonBody = requestBody instanceof String ?
|
||||||
(String) requestBody : objectMapper.writeValueAsString(requestBody);
|
(String) requestBody : JSON.toJSONString(requestBody);
|
||||||
|
|
||||||
Request httpRequest = new Request.Builder()
|
Request httpRequest = new Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
@@ -747,7 +789,7 @@ public class DifyApiClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String jsonBody = requestBody instanceof String ?
|
String jsonBody = requestBody instanceof String ?
|
||||||
(String) requestBody : objectMapper.writeValueAsString(requestBody);
|
(String) requestBody : JSON.toJSONString(requestBody);
|
||||||
|
|
||||||
Request httpRequest = new Request.Builder()
|
Request httpRequest = new Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package org.xyzh.ai.client.dto;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import org.xyzh.api.ai.dto.DifyFileInfo;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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")
|
@JsonProperty("max_tokens")
|
||||||
private Integer maxTokens;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ public class DifyConfig {
|
|||||||
/**
|
/**
|
||||||
* Dify API密钥(默认密钥,可被智能体的密钥覆盖)
|
* Dify API密钥(默认密钥,可被智能体的密钥覆盖)
|
||||||
*/
|
*/
|
||||||
private String apiKey="app-PTHzp2DsLPyiUrDYTXBGxL1f";
|
// private String apiKey="app-PTHzp2DsLPyiUrDYTXBGxL1f";
|
||||||
|
private String apiKey="app-fwOqGFLTsZtekCQYlOmj9f8x";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 请求超时时间(秒)
|
* 请求超时时间(秒)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.xyzh.ai.controller;
|
package org.xyzh.ai.controller;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.MediaType;
|
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.domain.ResultDomain;
|
||||||
import org.xyzh.common.core.page.PageDomain;
|
import org.xyzh.common.core.page.PageDomain;
|
||||||
import org.xyzh.common.core.page.PageParam;
|
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.TbAiConversation;
|
||||||
import org.xyzh.common.dto.ai.TbAiMessage;
|
import org.xyzh.common.dto.ai.TbAiMessage;
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description AI对话控制器
|
* @description AI对话控制器
|
||||||
@@ -38,35 +40,55 @@ public class AiChatController {
|
|||||||
// ===================== 对话相关 =====================
|
// ===================== 对话相关 =====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 流式对话(SSE)
|
* @description 准备流式对话会话(POST接收复杂参数)
|
||||||
* @param agentId 智能体ID
|
* @param requestBody 请求体(agentId, conversationId, query, files)
|
||||||
* @param conversationId 会话ID
|
* @return ResultDomain<String> 返回sessionId
|
||||||
* @param query 用户问题
|
|
||||||
* @param knowledgeIds 知识库ID列表(逗号分隔)
|
|
||||||
* @return SseEmitter SSE流式推送对象
|
|
||||||
* @author AI Assistant
|
* @author AI Assistant
|
||||||
* @since 2025-11-04
|
* @since 2025-11-06
|
||||||
*/
|
*/
|
||||||
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
@PostMapping("/stream/prepare")
|
||||||
public SseEmitter streamChat(
|
public ResultDomain<String> prepareStreamChat(@RequestBody Map<String, Object> requestBody) {
|
||||||
@RequestParam(name = "agentId") String agentId,
|
String agentId = (String) requestBody.get("agentId");
|
||||||
@RequestParam(name = "conversationId", required = false) String conversationId,
|
String conversationId = (String) requestBody.get("conversationId");
|
||||||
@RequestParam(name = "query") String query,
|
String query = (String) requestBody.get("query");
|
||||||
@RequestParam(name = "knowledgeIds", required = false) String knowledgeIds) {
|
|
||||||
|
|
||||||
// 解析knowledgeIds
|
// 转换 files 数据
|
||||||
List<String> knowledgeIdList = null;
|
@SuppressWarnings("unchecked")
|
||||||
if (knowledgeIds != null && !knowledgeIds.isEmpty()) {
|
List<Map<String, Object>> filesRaw = (List<Map<String, Object>>) requestBody.get("files");
|
||||||
knowledgeIdList = Arrays.asList(knowledgeIds.split(","));
|
|
||||||
|
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);
|
log.info("准备流式对话会话: agentId={}, query={}, files={}",
|
||||||
return chatService.streamChatWithSse(agentId, conversationId, query, knowledgeIdList);
|
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 阻塞式对话
|
* @description 阻塞式对话
|
||||||
* @param requestBody 请求体(agentId, conversationId, query, knowledgeIds)
|
* @param requestBody 请求体(agentId, conversationId, query)
|
||||||
* @return ResultDomain<TbAiMessage>
|
* @return ResultDomain<TbAiMessage>
|
||||||
* @author AI Assistant
|
* @author AI Assistant
|
||||||
* @since 2025-11-04
|
* @since 2025-11-04
|
||||||
@@ -76,11 +98,8 @@ public class AiChatController {
|
|||||||
String agentId = (String) requestBody.get("agentId");
|
String agentId = (String) requestBody.get("agentId");
|
||||||
String conversationId = (String) requestBody.get("conversationId");
|
String conversationId = (String) requestBody.get("conversationId");
|
||||||
String query = (String) requestBody.get("query");
|
String query = (String) requestBody.get("query");
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<String> knowledgeIds = (List<String>) requestBody.get("knowledgeIds");
|
|
||||||
|
|
||||||
log.info("阻塞式对话: agentId={}, conversationId={}, query={}", agentId, conversationId, query);
|
log.info("阻塞式对话: agentId={}, conversationId={}, query={}", agentId, conversationId, query);
|
||||||
return chatService.blockingChat(agentId, conversationId, query, knowledgeIds);
|
return chatService.blockingChat(agentId, conversationId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ public class AiFileUploadController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private AiUploadFileService uploadFileService;
|
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 上传文件到知识库
|
* @description 上传文件到知识库
|
||||||
* @param knowledgeId 知识库ID
|
* @param knowledgeId 知识库ID
|
||||||
@@ -78,6 +94,19 @@ public class AiFileUploadController {
|
|||||||
return uploadFileService.getFileById(fileId);
|
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 查询知识库的文件列表
|
* @description 查询知识库的文件列表
|
||||||
* @param knowledgeId 知识库ID
|
* @param knowledgeId 知识库ID
|
||||||
|
|||||||
@@ -68,4 +68,18 @@ public interface AiUploadFileMapper extends BaseMapper<TbAiUploadFile> {
|
|||||||
* @since 2025-10-15
|
* @since 2025-10-15
|
||||||
*/
|
*/
|
||||||
List<TbAiUploadFile> selectAiUploadFiles(TbAiUploadFile filter);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -76,17 +76,6 @@ public class AiAgentConfigServiceImpl implements AiAgentConfigService {
|
|||||||
agentConfig.setStatus(1); // 默认启用
|
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. 插入数据库
|
// 5. 插入数据库
|
||||||
int rows = agentConfigMapper.insertAgentConfig(agentConfig);
|
int rows = agentConfigMapper.insertAgentConfig(agentConfig);
|
||||||
if (rows > 0) {
|
if (rows > 0) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package org.xyzh.ai.service.impl;
|
package org.xyzh.ai.service.impl;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.alibaba.fastjson2.JSON;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.alibaba.fastjson2.JSONWriter;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -38,8 +38,6 @@ public class AiChatHistoryServiceImpl implements AiChatHistoryService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private AiMessageMapper messageMapper;
|
private AiMessageMapper messageMapper;
|
||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageDomain<TbAiConversation> pageUserConversations(
|
public PageDomain<TbAiConversation> pageUserConversations(
|
||||||
String agentId,
|
String agentId,
|
||||||
@@ -539,17 +537,13 @@ public class AiChatHistoryServiceImpl implements AiChatHistoryService {
|
|||||||
exportData.put("messages", messages);
|
exportData.put("messages", messages);
|
||||||
exportData.put("exportTime", new Date());
|
exportData.put("exportTime", new Date());
|
||||||
|
|
||||||
// 转JSON
|
// 使用Fastjson2转JSON(格式化输出)
|
||||||
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(exportData);
|
String json = JSON.toJSONString(exportData, JSONWriter.Feature.PrettyFormat);
|
||||||
|
|
||||||
log.info("导出会话JSON成功: {}", conversationId);
|
log.info("导出会话JSON成功: {}", conversationId);
|
||||||
resultDomain.success("导出成功", json);
|
resultDomain.success("导出成功", json);
|
||||||
return resultDomain;
|
return resultDomain;
|
||||||
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
log.error("JSON序列化失败", e);
|
|
||||||
resultDomain.fail("导出失败: JSON序列化错误");
|
|
||||||
return resultDomain;
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("导出会话JSON失败", e);
|
log.error("导出会话JSON失败", e);
|
||||||
resultDomain.fail("导出失败: " + e.getMessage());
|
resultDomain.fail("导出失败: " + e.getMessage());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package org.xyzh.ai.service.impl;
|
package org.xyzh.ai.service.impl;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.alibaba.fastjson2.JSON;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
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.callback.StreamCallback;
|
||||||
import org.xyzh.ai.client.dto.ChatRequest;
|
import org.xyzh.ai.client.dto.ChatRequest;
|
||||||
import org.xyzh.ai.client.dto.ChatResponse;
|
import org.xyzh.ai.client.dto.ChatResponse;
|
||||||
import org.xyzh.ai.config.DifyConfig;
|
|
||||||
import org.xyzh.ai.exception.DifyException;
|
import org.xyzh.ai.exception.DifyException;
|
||||||
import org.xyzh.ai.mapper.AiAgentConfigMapper;
|
import org.xyzh.ai.mapper.AiAgentConfigMapper;
|
||||||
import org.xyzh.ai.mapper.AiConversationMapper;
|
import org.xyzh.ai.mapper.AiConversationMapper;
|
||||||
import org.xyzh.ai.mapper.AiMessageMapper;
|
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.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.core.domain.ResultDomain;
|
||||||
import org.xyzh.common.dto.ai.TbAiAgentConfig;
|
import org.xyzh.common.dto.ai.TbAiAgentConfig;
|
||||||
import org.xyzh.common.dto.ai.TbAiConversation;
|
import org.xyzh.common.dto.ai.TbAiConversation;
|
||||||
import org.xyzh.common.dto.ai.TbAiMessage;
|
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.dto.user.TbSysUser;
|
||||||
|
import org.xyzh.common.vo.UserDeptRoleVO;
|
||||||
import org.xyzh.system.utils.LoginUtil;
|
import org.xyzh.system.utils.LoginUtil;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,20 +63,104 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
private DifyApiClient difyApiClient;
|
private DifyApiClient difyApiClient;
|
||||||
|
|
||||||
@Autowired
|
@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);
|
private final ExecutorService executorService = Executors.newFixedThreadPool(3);
|
||||||
|
|
||||||
|
// Redis会话key前缀
|
||||||
|
private static final String CHAT_SESSION_PREFIX = "chat:session:";
|
||||||
|
|
||||||
@Override
|
@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());
|
||||||
|
|
||||||
|
// 存入Redis,5分钟过期
|
||||||
|
String redisKey = CHAT_SESSION_PREFIX + sessionId;
|
||||||
|
redisTemplate.opsForValue().set(redisKey, sessionData, 5, TimeUnit.MINUTES);
|
||||||
|
|
||||||
|
log.info("创建对话会话: sessionId={}, agentId={}", sessionId, agentId);
|
||||||
|
|
||||||
|
resultDomain.success("会话创建成功", sessionId);
|
||||||
|
return resultDomain;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("创建对话会话失败", e);
|
||||||
|
resultDomain.fail("创建会话失败: " + e.getMessage());
|
||||||
|
return resultDomain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public SseEmitter streamChatWithSse(String sessionId) {
|
||||||
// 创建SseEmitter,设置超时时间为5分钟
|
// 创建SseEmitter,设置超时时间为5分钟
|
||||||
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
|
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
|
||||||
|
|
||||||
try {
|
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)) {
|
if (!StringUtils.hasText(agentId)) {
|
||||||
emitter.send(SseEmitter.event().name("error").data("智能体ID不能为空"));
|
emitter.send(SseEmitter.event().name("error").data("智能体ID不能为空"));
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
@@ -118,7 +209,7 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 创建新会话
|
// 创建新会话
|
||||||
ResultDomain<TbAiConversation> createResult = createConversation(agentId, null);
|
ResultDomain<TbAiConversation> createResult = createConversation(agentId, query.substring(0, 20));
|
||||||
if (!createResult.isSuccess()) {
|
if (!createResult.isSuccess()) {
|
||||||
emitter.send(SseEmitter.event().name("error").data(createResult.getMessage()));
|
emitter.send(SseEmitter.event().name("error").data(createResult.getMessage()));
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
@@ -131,7 +222,8 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
|
|
||||||
// 5. 创建用户消息记录
|
// 5. 创建用户消息记录
|
||||||
TbAiMessage userMessage = new TbAiMessage();
|
TbAiMessage userMessage = new TbAiMessage();
|
||||||
userMessage.setID(UUID.randomUUID().toString());
|
String userMessageId = UUID.randomUUID().toString();
|
||||||
|
userMessage.setID(userMessageId);
|
||||||
userMessage.setConversationID(finalConversationId);
|
userMessage.setConversationID(finalConversationId);
|
||||||
userMessage.setAgentID(agentId);
|
userMessage.setAgentID(agentId);
|
||||||
userMessage.setRole("user");
|
userMessage.setRole("user");
|
||||||
@@ -140,10 +232,39 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
userMessage.setUpdateTime(new Date());
|
userMessage.setUpdateTime(new Date());
|
||||||
userMessage.setDeleted(false);
|
userMessage.setDeleted(false);
|
||||||
userMessage.setUserID(currentUser.getID());
|
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);
|
messageMapper.insertMessage(userMessage);
|
||||||
|
|
||||||
|
// 6. 保存文件关联记录到tb_ai_upload_file
|
||||||
|
if (filesData != null && !filesData.isEmpty()) {
|
||||||
|
saveMessageFileRecords(userMessageId, finalConversationId, currentUser.getID(), filesData);
|
||||||
|
}
|
||||||
|
|
||||||
// 注意:AI消息记录将在获取到Dify的task_id后创建
|
// 注意:AI消息记录将在获取到Dify的task_id后创建
|
||||||
|
|
||||||
|
// 6. 从Redis获取当前用户可访问的知识库ID列表
|
||||||
|
List<String> knowledgeIds = getKnowledgeIdsByUser(currentUser);
|
||||||
|
|
||||||
// 7. 构建Dify请求
|
// 7. 构建Dify请求
|
||||||
ChatRequest chatRequest = new ChatRequest();
|
ChatRequest chatRequest = new ChatRequest();
|
||||||
chatRequest.setQuery(query);
|
chatRequest.setQuery(query);
|
||||||
@@ -152,15 +273,18 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
if (StringUtils.hasText(conversation.getDifyConversationId())) {
|
if (StringUtils.hasText(conversation.getDifyConversationId())) {
|
||||||
chatRequest.setConversationId(conversation.getDifyConversationId());
|
chatRequest.setConversationId(conversation.getDifyConversationId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置知识库ID列表(从Redis获取)
|
||||||
if (knowledgeIds != null && !knowledgeIds.isEmpty()) {
|
if (knowledgeIds != null && !knowledgeIds.isEmpty()) {
|
||||||
chatRequest.setDatasetIds(knowledgeIds);
|
chatRequest.setDatasetIds(knowledgeIds);
|
||||||
|
log.info("使用知识库: {}", knowledgeIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
chatRequest.setTemperature(agent.getTemperature() != null ?
|
chatRequest.setResponseMode("streaming");
|
||||||
agent.getTemperature().doubleValue() : difyConfig.getChat().getDefaultTemperature());
|
Map<String, Object> inputs = new HashMap<>();
|
||||||
chatRequest.setMaxTokens(agent.getMaxTokens() != null ?
|
inputs.put("connectInternet", agent.getConnectInternet());
|
||||||
agent.getMaxTokens() : difyConfig.getChat().getDefaultMaxTokens());
|
chatRequest.setInputs(inputs);
|
||||||
|
chatRequest.setFiles(filesData);
|
||||||
// 6. 调用Dify流式对话
|
// 6. 调用Dify流式对话
|
||||||
final TbAiConversation finalConversation = conversation;
|
final TbAiConversation finalConversation = conversation;
|
||||||
StringBuilder fullAnswer = new StringBuilder();
|
StringBuilder fullAnswer = new StringBuilder();
|
||||||
@@ -194,13 +318,13 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
return; // 已停止,不再处理
|
return; // 已停止,不再处理
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// 解析metadata
|
// 使用Fastjson2解析metadata
|
||||||
JsonNode json = objectMapper.readTree(metadata);
|
JSONObject json = JSON.parseObject(metadata);
|
||||||
if (json.has("conversation_id")) {
|
if (json.containsKey("conversation_id")) {
|
||||||
difyConversationId.set(json.get("conversation_id").asText());
|
difyConversationId.set(json.getString("conversation_id"));
|
||||||
}
|
}
|
||||||
if (json.has("id")) {
|
if (json.containsKey("id")) {
|
||||||
difyMessageId.set(json.get("id").asText());
|
difyMessageId.set(json.getString("id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新AI消息内容(使用task_id作为消息ID)
|
// 更新AI消息内容(使用task_id作为消息ID)
|
||||||
@@ -247,9 +371,9 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
|
|
||||||
// 如果还没有创建消息记录,尝试从任何事件中提取task_id
|
// 如果还没有创建消息记录,尝试从任何事件中提取task_id
|
||||||
if (!messageCreated.get()) {
|
if (!messageCreated.get()) {
|
||||||
JsonNode json = objectMapper.readTree(eventData);
|
JSONObject json = JSON.parseObject(eventData);
|
||||||
if (json.has("task_id")) {
|
if (json.containsKey("task_id")) {
|
||||||
String difyTaskId = json.get("task_id").asText();
|
String difyTaskId = json.getString("task_id");
|
||||||
|
|
||||||
// 只有在taskId为空时才设置并创建消息
|
// 只有在taskId为空时才设置并创建消息
|
||||||
if (taskId.get() == null) {
|
if (taskId.get() == null) {
|
||||||
@@ -363,9 +487,7 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
public ResultDomain<TbAiMessage> blockingChat(
|
public ResultDomain<TbAiMessage> blockingChat(
|
||||||
String agentId,
|
String agentId,
|
||||||
String conversationId,
|
String conversationId,
|
||||||
String query,
|
String query){
|
||||||
List<String> knowledgeIds) {
|
|
||||||
|
|
||||||
ResultDomain<TbAiMessage> resultDomain = new ResultDomain<>();
|
ResultDomain<TbAiMessage> resultDomain = new ResultDomain<>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -426,21 +548,12 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
if (StringUtils.hasText(conversation.getDifyConversationId())) {
|
if (StringUtils.hasText(conversation.getDifyConversationId())) {
|
||||||
chatRequest.setConversationId(conversation.getDifyConversationId());
|
chatRequest.setConversationId(conversation.getDifyConversationId());
|
||||||
}
|
}
|
||||||
if (knowledgeIds != null && !knowledgeIds.isEmpty()) {
|
|
||||||
chatRequest.setDatasetIds(knowledgeIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agent.getTemperature() != null) {
|
chatRequest.setResponseMode("blocking");
|
||||||
chatRequest.setTemperature(agent.getTemperature().doubleValue());
|
Map<String, Object> inputs = new HashMap<>();
|
||||||
} else {
|
inputs.put("connectInternet", agent.getConnectInternet());
|
||||||
chatRequest.setTemperature(difyConfig.getChat().getDefaultTemperature());
|
chatRequest.setInputs(inputs);
|
||||||
}
|
chatRequest.setFiles(null);
|
||||||
|
|
||||||
if (agent.getMaxTokens() != null) {
|
|
||||||
chatRequest.setMaxTokens(agent.getMaxTokens());
|
|
||||||
} else {
|
|
||||||
chatRequest.setMaxTokens(difyConfig.getChat().getDefaultMaxTokens());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用Dify阻塞式对话
|
// 调用Dify阻塞式对话
|
||||||
ChatResponse chatResponse = difyApiClient.blockingChat(chatRequest, agent.getDifyApiKey());
|
ChatResponse chatResponse = difyApiClient.blockingChat(chatRequest, agent.getDifyApiKey());
|
||||||
@@ -809,7 +922,7 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
TbAiMessage userQuestion = null;
|
TbAiMessage userQuestion = null;
|
||||||
for (int i = messages.size() - 1; i >= 0; i--) {
|
for (int i = messages.size() - 1; i >= 0; i--) {
|
||||||
if ("user".equals(messages.get(i).getRole()) &&
|
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);
|
userQuestion = messages.get(i);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -821,13 +934,23 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接返回streamChatWithSse的结果
|
// 重新生成:创建临时session并调用新的streamChatWithSse
|
||||||
return streamChatWithSse(
|
String sessionId = UUID.randomUUID().toString();
|
||||||
originalMessage.getAgentID(),
|
|
||||||
originalMessage.getConversationID(),
|
// 构建会话数据
|
||||||
userQuestion.getContent(),
|
Map<String, Object> sessionData = new HashMap<>();
|
||||||
null
|
sessionData.put("agentId", originalMessage.getAgentID());
|
||||||
);
|
sessionData.put("conversationId", originalMessage.getConversationID());
|
||||||
|
sessionData.put("query", userQuestion.getContent());
|
||||||
|
sessionData.put("filesData", null); // 重新生成不需要传文件
|
||||||
|
sessionData.put("createTime", System.currentTimeMillis());
|
||||||
|
|
||||||
|
// 存入Redis,5分钟过期
|
||||||
|
String redisKey = CHAT_SESSION_PREFIX + sessionId;
|
||||||
|
redisTemplate.opsForValue().set(redisKey, sessionData, 5, TimeUnit.MINUTES);
|
||||||
|
|
||||||
|
log.info("重新生成回答: messageId={}, sessionId={}", messageId, sessionId);
|
||||||
|
return streamChatWithSse(sessionId);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("重新生成回答异常", e);
|
log.error("重新生成回答异常", e);
|
||||||
@@ -971,5 +1094,91 @@ public class AiChatServiceImpl implements AiChatService {
|
|||||||
return resultDomain;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,19 +8,24 @@ import org.springframework.util.StringUtils;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.xyzh.ai.client.DifyApiClient;
|
import org.xyzh.ai.client.DifyApiClient;
|
||||||
import org.xyzh.ai.client.dto.DocumentStatusResponse;
|
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.DocumentUploadRequest;
|
||||||
import org.xyzh.ai.client.dto.DocumentUploadResponse;
|
import org.xyzh.ai.client.dto.DocumentUploadResponse;
|
||||||
import org.xyzh.ai.config.DifyConfig;
|
import org.xyzh.ai.config.DifyConfig;
|
||||||
import org.xyzh.ai.exception.DifyException;
|
import org.xyzh.ai.exception.DifyException;
|
||||||
import org.xyzh.ai.exception.FileProcessException;
|
import org.xyzh.ai.exception.FileProcessException;
|
||||||
|
import org.xyzh.ai.mapper.AiAgentConfigMapper;
|
||||||
import org.xyzh.ai.mapper.AiKnowledgeMapper;
|
import org.xyzh.ai.mapper.AiKnowledgeMapper;
|
||||||
import org.xyzh.ai.mapper.AiUploadFileMapper;
|
import org.xyzh.ai.mapper.AiUploadFileMapper;
|
||||||
import org.xyzh.api.ai.file.AiUploadFileService;
|
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.domain.ResultDomain;
|
||||||
import org.xyzh.common.core.page.PageDomain;
|
import org.xyzh.common.core.page.PageDomain;
|
||||||
import org.xyzh.common.core.page.PageParam;
|
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.TbAiKnowledge;
|
||||||
import org.xyzh.common.dto.ai.TbAiUploadFile;
|
import org.xyzh.common.dto.ai.TbAiUploadFile;
|
||||||
|
import org.xyzh.common.dto.system.TbSysFile;
|
||||||
import org.xyzh.common.dto.user.TbSysUser;
|
import org.xyzh.common.dto.user.TbSysUser;
|
||||||
import org.xyzh.system.utils.LoginUtil;
|
import org.xyzh.system.utils.LoginUtil;
|
||||||
|
|
||||||
@@ -58,9 +63,119 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private DifyConfig difyConfig;
|
private DifyConfig difyConfig;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AiAgentConfigMapper agentConfigMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FileService fileService;
|
||||||
|
|
||||||
// 异步处理线程池
|
// 异步处理线程池
|
||||||
private final ExecutorService executorService = Executors.newFixedThreadPool(5);
|
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
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public ResultDomain<TbAiUploadFile> uploadToKnowledge(
|
public ResultDomain<TbAiUploadFile> uploadToKnowledge(
|
||||||
@@ -113,15 +228,26 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
|
|||||||
return resultDomain;
|
return resultDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 保存临时文件
|
// 5. 先保存到系统文件表(永久存储)
|
||||||
File tempFile = saveTempFile(file);
|
ResultDomain<TbSysFile> uploadResult = fileService.uploadFile(
|
||||||
if (tempFile == null) {
|
file,
|
||||||
resultDomain.fail("保存临时文件失败");
|
"ai-knowledge", // 模块名
|
||||||
|
knowledgeId, // 业务ID(知识库ID)
|
||||||
|
currentUser.getID() // 上传者
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uploadResult.isSuccess() || uploadResult.getData() == null) {
|
||||||
|
resultDomain.fail("保存文件失败: " + uploadResult.getMessage());
|
||||||
return resultDomain;
|
return resultDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
TbSysFile sysFile = (TbSysFile) uploadResult.getData();
|
||||||
// 6. 上传到Dify
|
log.info("文件已保存到系统文件表: sysFileId={}, fileName={}", sysFile.getID(), sysFile.getOriginalName());
|
||||||
|
|
||||||
|
// 6. 获取已保存文件的File对象,直接用于上传到Dify
|
||||||
|
File fileToUpload = fileService.getFileByRelativePath(sysFile.getFilePath());
|
||||||
|
|
||||||
|
// 7. 上传到Dify
|
||||||
DocumentUploadRequest uploadRequest = new DocumentUploadRequest();
|
DocumentUploadRequest uploadRequest = new DocumentUploadRequest();
|
||||||
uploadRequest.setName(originalFilename);
|
uploadRequest.setName(originalFilename);
|
||||||
|
|
||||||
@@ -132,18 +258,19 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
|
|||||||
|
|
||||||
DocumentUploadResponse difyResponse = difyApiClient.uploadDocumentByFile(
|
DocumentUploadResponse difyResponse = difyApiClient.uploadDocumentByFile(
|
||||||
knowledge.getDifyDatasetId(),
|
knowledge.getDifyDatasetId(),
|
||||||
tempFile,
|
fileToUpload,
|
||||||
originalFilename,
|
originalFilename,
|
||||||
uploadRequest,
|
uploadRequest,
|
||||||
difyConfig.getApiKey()
|
difyConfig.getApiKey());
|
||||||
);
|
|
||||||
|
|
||||||
// 7. 保存到本地数据库
|
// 8. 保存到本地数据库
|
||||||
TbAiUploadFile uploadFile = new TbAiUploadFile();
|
TbAiUploadFile uploadFile = new TbAiUploadFile();
|
||||||
uploadFile.setID(UUID.randomUUID().toString());
|
uploadFile.setID(UUID.randomUUID().toString());
|
||||||
|
uploadFile.setUserID(currentUser.getID());
|
||||||
uploadFile.setKnowledgeId(knowledgeId);
|
uploadFile.setKnowledgeId(knowledgeId);
|
||||||
|
uploadFile.setSysFileId(sysFile.getID()); // 关联系统文件ID
|
||||||
uploadFile.setFileName(originalFilename);
|
uploadFile.setFileName(originalFilename);
|
||||||
uploadFile.setFilePath(tempFile.getAbsolutePath());
|
uploadFile.setFilePath(sysFile.getFilePath()); // 保存系统文件的相对路径
|
||||||
uploadFile.setFileSize(file.getSize());
|
uploadFile.setFileSize(file.getSize());
|
||||||
uploadFile.setFileType(getFileExtension(originalFilename));
|
uploadFile.setFileType(getFileExtension(originalFilename));
|
||||||
uploadFile.setDifyDocumentId(difyResponse.getId());
|
uploadFile.setDifyDocumentId(difyResponse.getId());
|
||||||
@@ -156,9 +283,10 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
|
|||||||
|
|
||||||
int rows = uploadFileMapper.insertUploadFile(uploadFile);
|
int rows = uploadFileMapper.insertUploadFile(uploadFile);
|
||||||
if (rows > 0) {
|
if (rows > 0) {
|
||||||
log.info("文件上传成功: {} - {}", uploadFile.getID(), originalFilename);
|
log.info("知识库文件上传成功: uploadFileId={}, sysFileId={}, fileName={}",
|
||||||
|
uploadFile.getID(), sysFile.getID(), originalFilename);
|
||||||
|
|
||||||
// 8. 异步更新向量化状态
|
// 9. 异步更新向量化状态
|
||||||
asyncUpdateVectorStatus(uploadFile.getID());
|
asyncUpdateVectorStatus(uploadFile.getID());
|
||||||
|
|
||||||
resultDomain.success("文件上传成功", uploadFile);
|
resultDomain.success("文件上传成功", uploadFile);
|
||||||
@@ -168,11 +296,6 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
|
|||||||
return resultDomain;
|
return resultDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
} finally {
|
|
||||||
// 清理临时文件
|
|
||||||
deleteTempFile(tempFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (DifyException e) {
|
} catch (DifyException e) {
|
||||||
log.error("上传文件到Dify失败", e);
|
log.error("上传文件到Dify失败", e);
|
||||||
resultDomain.fail("上传文件失败: " + e.getMessage());
|
resultDomain.fail("上传文件失败: " + e.getMessage());
|
||||||
@@ -203,9 +326,7 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
|
|||||||
List<String> failedFiles = new ArrayList<>();
|
List<String> failedFiles = new ArrayList<>();
|
||||||
|
|
||||||
for (MultipartFile file : files) {
|
for (MultipartFile file : files) {
|
||||||
ResultDomain<TbAiUploadFile> uploadResult = uploadToKnowledge(
|
ResultDomain<TbAiUploadFile> uploadResult = uploadToKnowledge(knowledgeId, file, indexingTechnique);
|
||||||
knowledgeId, file, indexingTechnique
|
|
||||||
);
|
|
||||||
|
|
||||||
if (uploadResult.isSuccess()) {
|
if (uploadResult.isSuccess()) {
|
||||||
uploadedFiles.add(uploadResult.getData());
|
uploadedFiles.add(uploadResult.getData());
|
||||||
@@ -258,8 +379,7 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
|
|||||||
difyApiClient.deleteDocument(
|
difyApiClient.deleteDocument(
|
||||||
knowledge.getDifyDatasetId(),
|
knowledge.getDifyDatasetId(),
|
||||||
file.getDifyDocumentId(),
|
file.getDifyDocumentId(),
|
||||||
difyConfig.getApiKey()
|
difyConfig.getApiKey());
|
||||||
);
|
|
||||||
log.info("Dify文档删除成功: {}", file.getDifyDocumentId());
|
log.info("Dify文档删除成功: {}", file.getDifyDocumentId());
|
||||||
} catch (DifyException e) {
|
} catch (DifyException e) {
|
||||||
log.error("删除Dify文档失败,继续删除本地记录", e);
|
log.error("删除Dify文档失败,继续删除本地记录", e);
|
||||||
@@ -393,8 +513,7 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
|
|||||||
DocumentStatusResponse statusResponse = difyApiClient.getDocumentStatus(
|
DocumentStatusResponse statusResponse = difyApiClient.getDocumentStatus(
|
||||||
knowledge.getDifyDatasetId(),
|
knowledge.getDifyDatasetId(),
|
||||||
file.getDifyBatchId(),
|
file.getDifyBatchId(),
|
||||||
difyConfig.getApiKey()
|
difyConfig.getApiKey());
|
||||||
);
|
|
||||||
|
|
||||||
// 4. 更新本地状态
|
// 4. 更新本地状态
|
||||||
TbAiUploadFile update = new TbAiUploadFile();
|
TbAiUploadFile update = new TbAiUploadFile();
|
||||||
@@ -516,35 +635,45 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存临时文件
|
* 获取文件类型(使用具体的文件扩展名)
|
||||||
|
* 图片类型返回 "image",其他类型返回具体扩展名(pdf, docx, txt等)
|
||||||
*/
|
*/
|
||||||
private File saveTempFile(MultipartFile file) {
|
private String getFileType(String filename) {
|
||||||
try {
|
if (!StringUtils.hasText(filename)) {
|
||||||
String tempDir = System.getProperty("java.io.tmpdir");
|
return "file";
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 转换为大写以匹配数组中的类型
|
||||||
* 删除临时文件
|
String extension = getFileExtension(filename).toUpperCase();
|
||||||
*/
|
|
||||||
private void deleteTempFile(File file) {
|
// 图片类型统一返回 "image"
|
||||||
if (file != null && file.exists()) {
|
String[] imageTypes = { "JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG" };
|
||||||
try {
|
String[] documentTypes = { "TXT", "MD", "MARKDOWN", "MDX", "PDF", "HTML", "XLSX", "XLS", "VTT", "PROPERTIES",
|
||||||
Files.delete(file.toPath());
|
"DOC", "DOCX", "CSV", "EML", "MSG", "PPTX", "PPT", "XML", "EPUB" };
|
||||||
log.debug("临时文件已删除: {}", file.getAbsolutePath());
|
String[] audioTypes = { "MP3", "M4A", "WAV", "WEBM", "MPGA" };
|
||||||
} catch (IOException e) {
|
String[] videoTypes = { "MP4", "MOV", "MPEG", "WEBM" };
|
||||||
log.warn("删除临时文件失败: {}", file.getAbsolutePath(), e);
|
|
||||||
|
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -574,5 +703,26 @@ public class AiUploadFileServiceImpl implements AiUploadFileService {
|
|||||||
}
|
}
|
||||||
}, executorService);
|
}, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,12 +8,7 @@
|
|||||||
<result column="name" property="name" jdbcType="VARCHAR"/>
|
<result column="name" property="name" jdbcType="VARCHAR"/>
|
||||||
<result column="avatar" property="avatar" jdbcType="VARCHAR"/>
|
<result column="avatar" property="avatar" jdbcType="VARCHAR"/>
|
||||||
<result column="description" property="description" jdbcType="VARCHAR"/>
|
<result column="description" property="description" jdbcType="VARCHAR"/>
|
||||||
<result column="system_prompt" property="systemPrompt" jdbcType="LONGVARCHAR"/>
|
<result column="connect_internet" property="connectInternet" jdbcType="INTEGER"/>
|
||||||
<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="dify_app_id" property="difyAppId" jdbcType="VARCHAR"/>
|
<result column="dify_app_id" property="difyAppId" jdbcType="VARCHAR"/>
|
||||||
<result column="dify_api_key" property="difyApiKey" jdbcType="VARCHAR"/>
|
<result column="dify_api_key" property="difyApiKey" jdbcType="VARCHAR"/>
|
||||||
<result column="status" property="status" jdbcType="INTEGER"/>
|
<result column="status" property="status" jdbcType="INTEGER"/>
|
||||||
@@ -27,8 +22,7 @@
|
|||||||
|
|
||||||
<!-- 基础字段 -->
|
<!-- 基础字段 -->
|
||||||
<sql id="Base_Column_List">
|
<sql id="Base_Column_List">
|
||||||
id, name, avatar, description, system_prompt, model_name, model_provider,
|
id, name, avatar, description, connect_internet, dify_app_id, dify_api_key, status,
|
||||||
temperature, max_tokens, top_p, dify_app_id, dify_api_key, status,
|
|
||||||
creator, updater, create_time, update_time, delete_time, deleted
|
creator, updater, create_time, update_time, delete_time, deleted
|
||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
@@ -39,12 +33,6 @@
|
|||||||
<if test="name != null and name != ''">
|
<if test="name != null and name != ''">
|
||||||
AND name LIKE CONCAT('%', #{name}, '%')
|
AND name LIKE CONCAT('%', #{name}, '%')
|
||||||
</if>
|
</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">
|
<if test="status != null">
|
||||||
AND status = #{status}
|
AND status = #{status}
|
||||||
</if>
|
</if>
|
||||||
@@ -54,12 +42,10 @@
|
|||||||
<!-- 插入智能体配置 -->
|
<!-- 插入智能体配置 -->
|
||||||
<insert id="insertAgentConfig" parameterType="org.xyzh.common.dto.ai.TbAiAgentConfig">
|
<insert id="insertAgentConfig" parameterType="org.xyzh.common.dto.ai.TbAiAgentConfig">
|
||||||
INSERT INTO tb_ai_agent_config (
|
INSERT INTO tb_ai_agent_config (
|
||||||
id, name, avatar, description, system_prompt, model_name, model_provider,
|
id, name, avatar, description, connect_internet, dify_app_id, dify_api_key, status,
|
||||||
temperature, max_tokens, top_p, dify_app_id, dify_api_key, status,
|
|
||||||
creator, updater, create_time, update_time, deleted
|
creator, updater, create_time, update_time, deleted
|
||||||
) VALUES (
|
) VALUES (
|
||||||
#{id}, #{name}, #{avatar}, #{description}, #{systemPrompt}, #{modelName}, #{modelProvider},
|
#{id}, #{name}, #{avatar}, #{description}, #{connectInternet}, #{difyAppId}, #{difyApiKey}, #{status},
|
||||||
#{temperature}, #{maxTokens}, #{topP}, #{difyAppId}, #{difyApiKey}, #{status},
|
|
||||||
#{creator}, #{updater}, #{createTime}, #{updateTime}, #{deleted}
|
#{creator}, #{updater}, #{createTime}, #{updateTime}, #{deleted}
|
||||||
)
|
)
|
||||||
</insert>
|
</insert>
|
||||||
@@ -71,12 +57,7 @@
|
|||||||
<if test="name != null and name != ''">name = #{name},</if>
|
<if test="name != null and name != ''">name = #{name},</if>
|
||||||
<if test="avatar != null">avatar = #{avatar},</if>
|
<if test="avatar != null">avatar = #{avatar},</if>
|
||||||
<if test="description != null">description = #{description},</if>
|
<if test="description != null">description = #{description},</if>
|
||||||
<if test="systemPrompt != null">system_prompt = #{systemPrompt},</if>
|
<if test="connectInternet != null">connect_internet = #{connectInternet},</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="difyAppId != null">dify_app_id = #{difyAppId},</if>
|
<if test="difyAppId != null">dify_app_id = #{difyAppId},</if>
|
||||||
<if test="difyApiKey != null">dify_api_key = #{difyApiKey},</if>
|
<if test="difyApiKey != null">dify_api_key = #{difyApiKey},</if>
|
||||||
<if test="status != null">status = #{status},</if>
|
<if test="status != null">status = #{status},</if>
|
||||||
@@ -116,9 +97,6 @@
|
|||||||
<if test="filter.status != null">
|
<if test="filter.status != null">
|
||||||
AND status = #{filter.status}
|
AND status = #{filter.status}
|
||||||
</if>
|
</if>
|
||||||
<if test="filter.modelProvider != null and filter.modelProvider != ''">
|
|
||||||
AND model_provider = #{filter.modelProvider}
|
|
||||||
</if>
|
|
||||||
</if>
|
</if>
|
||||||
ORDER BY create_time DESC
|
ORDER BY create_time DESC
|
||||||
</select>
|
</select>
|
||||||
@@ -136,9 +114,6 @@
|
|||||||
<if test="filter.status != null">
|
<if test="filter.status != null">
|
||||||
AND status = #{filter.status}
|
AND status = #{filter.status}
|
||||||
</if>
|
</if>
|
||||||
<if test="filter.modelProvider != null and filter.modelProvider != ''">
|
|
||||||
AND model_provider = #{filter.modelProvider}
|
|
||||||
</if>
|
|
||||||
</if>
|
</if>
|
||||||
ORDER BY create_time DESC
|
ORDER BY create_time DESC
|
||||||
LIMIT #{pageParam.offset}, #{pageParam.pageSize}
|
LIMIT #{pageParam.offset}, #{pageParam.pageSize}
|
||||||
@@ -156,9 +131,6 @@
|
|||||||
<if test="filter.status != null">
|
<if test="filter.status != null">
|
||||||
AND status = #{filter.status}
|
AND status = #{filter.status}
|
||||||
</if>
|
</if>
|
||||||
<if test="filter.modelProvider != null and filter.modelProvider != ''">
|
|
||||||
AND model_provider = #{filter.modelProvider}
|
|
||||||
</if>
|
|
||||||
</if>
|
</if>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<result column="user_id" property="userID" jdbcType="VARCHAR"/>
|
<result column="user_id" property="userID" jdbcType="VARCHAR"/>
|
||||||
<result column="knowledge_id" property="knowledgeId" jdbcType="VARCHAR"/>
|
<result column="knowledge_id" property="knowledgeId" jdbcType="VARCHAR"/>
|
||||||
<result column="conversation_id" property="conversationID" 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_name" property="fileName" jdbcType="VARCHAR"/>
|
||||||
<result column="file_path" property="filePath" jdbcType="VARCHAR"/>
|
<result column="file_path" property="filePath" jdbcType="VARCHAR"/>
|
||||||
<result column="file_size" property="fileSize" jdbcType="BIGINT"/>
|
<result column="file_size" property="fileSize" jdbcType="BIGINT"/>
|
||||||
@@ -16,12 +18,10 @@
|
|||||||
<result column="extracted_text" property="extractedText" jdbcType="LONGVARCHAR"/>
|
<result column="extracted_text" property="extractedText" jdbcType="LONGVARCHAR"/>
|
||||||
<result column="dify_document_id" property="difyDocumentId" jdbcType="VARCHAR"/>
|
<result column="dify_document_id" property="difyDocumentId" jdbcType="VARCHAR"/>
|
||||||
<result column="dify_batch_id" property="difyBatchId" 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="chunk_count" property="chunkCount" jdbcType="INTEGER"/>
|
||||||
<result column="status" property="status" jdbcType="INTEGER"/>
|
<result column="status" property="status" jdbcType="INTEGER"/>
|
||||||
<result column="error_message" property="errorMessage" jdbcType="VARCHAR"/>
|
<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="create_time" property="createTime" jdbcType="TIMESTAMP"/>
|
||||||
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
|
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
|
||||||
<result column="delete_time" property="deleteTime" jdbcType="TIMESTAMP"/>
|
<result column="delete_time" property="deleteTime" jdbcType="TIMESTAMP"/>
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
|
|
||||||
<!-- 基础字段 -->
|
<!-- 基础字段 -->
|
||||||
<sql id="Base_Column_List">
|
<sql id="Base_Column_List">
|
||||||
id, user_id, knowledge_id, conversation_id, file_name, file_path, file_size,
|
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,
|
file_type, mime_type, extracted_text, dify_document_id, dify_batch_id, dify_upload_file_id,
|
||||||
vector_status, chunk_count, status, error_message, creator, updater,
|
chunk_count, status, error_message,
|
||||||
create_time, update_time, delete_time, deleted
|
create_time, update_time, delete_time, deleted
|
||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
@@ -56,9 +56,6 @@
|
|||||||
<if test="filter.fileType != null and filter.fileType != ''">
|
<if test="filter.fileType != null and filter.fileType != ''">
|
||||||
AND file_type = #{filter.fileType}
|
AND file_type = #{filter.fileType}
|
||||||
</if>
|
</if>
|
||||||
<if test="filter.vectorStatus != null">
|
|
||||||
AND vector_status = #{filter.vectorStatus}
|
|
||||||
</if>
|
|
||||||
<if test="filter.status != null">
|
<if test="filter.status != null">
|
||||||
AND status = #{filter.status}
|
AND status = #{filter.status}
|
||||||
</if>
|
</if>
|
||||||
@@ -69,14 +66,14 @@
|
|||||||
<!-- insertUploadFile(插入文件记录) -->
|
<!-- insertUploadFile(插入文件记录) -->
|
||||||
<insert id="insertUploadFile" parameterType="org.xyzh.common.dto.ai.TbAiUploadFile">
|
<insert id="insertUploadFile" parameterType="org.xyzh.common.dto.ai.TbAiUploadFile">
|
||||||
INSERT INTO tb_ai_upload_file (
|
INSERT INTO tb_ai_upload_file (
|
||||||
id, user_id, knowledge_id, conversation_id, file_name, file_path, file_size,
|
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,
|
file_type, mime_type, extracted_text, dify_document_id, dify_batch_id, dify_upload_file_id,
|
||||||
vector_status, chunk_count, status, error_message, creator, updater,
|
chunk_count, status, error_message,
|
||||||
create_time, update_time, deleted
|
create_time, update_time, deleted
|
||||||
) VALUES (
|
) VALUES (
|
||||||
#{ID}, #{userID}, #{knowledgeId}, #{conversationID}, #{fileName}, #{filePath}, #{fileSize},
|
#{ID}, #{userID}, #{knowledgeId}, #{conversationID}, #{messageID}, #{sysFileId}, #{fileName}, #{filePath}, #{fileSize},
|
||||||
#{fileType}, #{mimeType}, #{extractedText}, #{difyDocumentId}, #{difyBatchId},
|
#{fileType}, #{mimeType}, #{extractedText}, #{difyDocumentId}, #{difyBatchId}, #{difyUploadFileId},
|
||||||
#{vectorStatus}, #{chunkCount}, #{status}, #{errorMessage}, #{creator}, #{updater},
|
#{chunkCount}, #{status}, #{errorMessage},
|
||||||
#{createTime}, #{updateTime}, #{deleted}
|
#{createTime}, #{updateTime}, #{deleted}
|
||||||
)
|
)
|
||||||
</insert>
|
</insert>
|
||||||
@@ -88,6 +85,8 @@
|
|||||||
<if test="userID != null">user_id = #{userID},</if>
|
<if test="userID != null">user_id = #{userID},</if>
|
||||||
<if test="knowledgeId != null">knowledge_id = #{knowledgeId},</if>
|
<if test="knowledgeId != null">knowledge_id = #{knowledgeId},</if>
|
||||||
<if test="conversationID != null">conversation_id = #{conversationID},</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="fileName != null">file_name = #{fileName},</if>
|
||||||
<if test="filePath != null">file_path = #{filePath},</if>
|
<if test="filePath != null">file_path = #{filePath},</if>
|
||||||
<if test="fileSize != null">file_size = #{fileSize},</if>
|
<if test="fileSize != null">file_size = #{fileSize},</if>
|
||||||
@@ -96,11 +95,10 @@
|
|||||||
<if test="extractedText != null">extracted_text = #{extractedText},</if>
|
<if test="extractedText != null">extracted_text = #{extractedText},</if>
|
||||||
<if test="difyDocumentId != null">dify_document_id = #{difyDocumentId},</if>
|
<if test="difyDocumentId != null">dify_document_id = #{difyDocumentId},</if>
|
||||||
<if test="difyBatchId != null">dify_batch_id = #{difyBatchId},</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="chunkCount != null">chunk_count = #{chunkCount},</if>
|
||||||
<if test="status != null">status = #{status},</if>
|
<if test="status != null">status = #{status},</if>
|
||||||
<if test="errorMessage != null">error_message = #{errorMessage},</if>
|
<if test="errorMessage != null">error_message = #{errorMessage},</if>
|
||||||
<if test="updater != null">updater = #{updater},</if>
|
|
||||||
<if test="updateTime != null">update_time = #{updateTime},</if>
|
<if test="updateTime != null">update_time = #{updateTime},</if>
|
||||||
</set>
|
</set>
|
||||||
WHERE id = #{ID} AND deleted = 0
|
WHERE id = #{ID} AND deleted = 0
|
||||||
@@ -110,8 +108,7 @@
|
|||||||
<update id="deleteUploadFile" parameterType="org.xyzh.common.dto.ai.TbAiUploadFile">
|
<update id="deleteUploadFile" parameterType="org.xyzh.common.dto.ai.TbAiUploadFile">
|
||||||
UPDATE tb_ai_upload_file
|
UPDATE tb_ai_upload_file
|
||||||
SET deleted = 1,
|
SET deleted = 1,
|
||||||
delete_time = NOW(),
|
delete_time = NOW()
|
||||||
updater = #{updater}
|
|
||||||
WHERE id = #{ID} AND deleted = 0
|
WHERE id = #{ID} AND deleted = 0
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
@@ -186,4 +183,31 @@
|
|||||||
ORDER BY create_time DESC
|
ORDER BY create_time DESC
|
||||||
</select>
|
</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>
|
</mapper>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.xyzh.api.ai.chat;
|
|||||||
|
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
import org.xyzh.common.core.domain.ResultDomain;
|
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.TbAiConversation;
|
||||||
import org.xyzh.common.dto.ai.TbAiMessage;
|
import org.xyzh.common.dto.ai.TbAiMessage;
|
||||||
|
|
||||||
@@ -17,20 +18,27 @@ import java.util.List;
|
|||||||
public interface AiChatService {
|
public interface AiChatService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 流式对话(SSE)- 使用SseEmitter实现真正的流式推送
|
* 准备对话会话(POST传递复杂参数)
|
||||||
* @param agentId 智能体ID
|
* @param agentId 智能体ID
|
||||||
* @param conversationId 会话ID(可选,为空则创建新会话)
|
* @param conversationId 会话ID(可选,为空则创建新会话)
|
||||||
* @param query 用户问题
|
* @param query 用户问题
|
||||||
* @param knowledgeIds 使用的知识库ID列表(可选,用于知识库隔离)
|
* @param filesData 上传的文件列表(Dify文件信息)
|
||||||
* @return SseEmitter 流式推送对象
|
* @return ResultDomain<String> 返回sessionId
|
||||||
*/
|
*/
|
||||||
SseEmitter streamChatWithSse(
|
ResultDomain<String> prepareChatSession(
|
||||||
String agentId,
|
String agentId,
|
||||||
String conversationId,
|
String conversationId,
|
||||||
String query,
|
String query,
|
||||||
List<String> knowledgeIds
|
List<DifyFileInfo> filesData
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式对话(SSE)- 使用sessionId建立SSE连接
|
||||||
|
* @param sessionId 会话标识
|
||||||
|
* @return SseEmitter 流式推送对象
|
||||||
|
*/
|
||||||
|
SseEmitter streamChatWithSse(String sessionId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 阻塞式对话(非流式)
|
* 阻塞式对话(非流式)
|
||||||
* @param agentId 智能体ID
|
* @param agentId 智能体ID
|
||||||
@@ -42,8 +50,7 @@ public interface AiChatService {
|
|||||||
ResultDomain<TbAiMessage> blockingChat(
|
ResultDomain<TbAiMessage> blockingChat(
|
||||||
String agentId,
|
String agentId,
|
||||||
String conversationId,
|
String conversationId,
|
||||||
String query,
|
String query
|
||||||
List<String> knowledgeIds
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package org.xyzh.api.ai.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Dify文件信息(用于对话文件上传和请求)
|
||||||
|
* @filename DifyFileInfo.java
|
||||||
|
* @author AI Assistant
|
||||||
|
* @copyright xyzh
|
||||||
|
* @since 2025-11-06
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class DifyFileInfo {
|
||||||
|
/**
|
||||||
|
* 文件ID(Dify返回)
|
||||||
|
*/
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件名
|
||||||
|
*/
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件大小(字节)
|
||||||
|
*/
|
||||||
|
private Integer size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件扩展名
|
||||||
|
*/
|
||||||
|
private String extension;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件MIME类型
|
||||||
|
*/
|
||||||
|
@JsonProperty("mime_type")
|
||||||
|
private String mimeType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传人ID
|
||||||
|
*/
|
||||||
|
@JsonProperty("created_by")
|
||||||
|
private String createdBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传时间(时间戳)
|
||||||
|
*/
|
||||||
|
@JsonProperty("created_at")
|
||||||
|
private Long createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览URL
|
||||||
|
*/
|
||||||
|
@JsonProperty("preview_url")
|
||||||
|
private String previewUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 源文件URL
|
||||||
|
*/
|
||||||
|
@JsonProperty("source_url")
|
||||||
|
private String sourceUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件类型:image、document、audio、video、file
|
||||||
|
*/
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 传输方式:remote_url、local_file
|
||||||
|
*/
|
||||||
|
@JsonProperty("transfer_method")
|
||||||
|
private String transferMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件URL或ID
|
||||||
|
*/
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地文件上传ID
|
||||||
|
*/
|
||||||
|
@JsonProperty("upload_file_id")
|
||||||
|
private String uploadFileId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统文件ID
|
||||||
|
*/
|
||||||
|
@JsonProperty("sys_file_id")
|
||||||
|
private String sysFileId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件路径(从系统文件表获取)
|
||||||
|
*/
|
||||||
|
@JsonProperty("file_path")
|
||||||
|
private String filePath;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import org.xyzh.common.core.page.PageParam;
|
|||||||
import org.xyzh.common.dto.ai.TbAiUploadFile;
|
import org.xyzh.common.dto.ai.TbAiUploadFile;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description AI文件上传服务接口
|
* @description AI文件上传服务接口
|
||||||
@@ -17,6 +18,17 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public interface AiUploadFileService {
|
public interface AiUploadFileService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件用于对话(图文多模态)
|
||||||
|
* @param file 上传的文件
|
||||||
|
* @param agentId 智能体ID
|
||||||
|
* @return Dify文件信息(包含id、name、size等)
|
||||||
|
*/
|
||||||
|
ResultDomain<Map<String, Object>> uploadFileForChat(
|
||||||
|
MultipartFile file,
|
||||||
|
String agentId
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件到知识库(同步到Dify)
|
* 上传文件到知识库(同步到Dify)
|
||||||
* @param knowledgeId 知识库ID
|
* @param knowledgeId 知识库ID
|
||||||
@@ -92,4 +104,11 @@ public interface AiUploadFileService {
|
|||||||
* @return 同步结果
|
* @return 同步结果
|
||||||
*/
|
*/
|
||||||
ResultDomain<TbAiUploadFile> syncKnowledgeFiles(String knowledgeId);
|
ResultDomain<TbAiUploadFile> syncKnowledgeFiles(String knowledgeId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询消息关联的文件列表
|
||||||
|
* @param messageId 消息ID
|
||||||
|
* @return 文件列表
|
||||||
|
*/
|
||||||
|
ResultDomain<TbAiUploadFile> listFilesByMessageId(String messageId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.xyzh.api.file;
|
package org.xyzh.api.file;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.xyzh.common.core.domain.ResultDomain;
|
import org.xyzh.common.core.domain.ResultDomain;
|
||||||
import org.xyzh.common.dto.system.TbSysFile;
|
import org.xyzh.common.dto.system.TbSysFile;
|
||||||
@@ -120,5 +122,33 @@ public interface FileService {
|
|||||||
* @since 2025-10-16
|
* @since 2025-10-16
|
||||||
*/
|
*/
|
||||||
ResultDomain<TbSysFile> batchDeleteFiles(String[] fileIds);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,10 @@
|
|||||||
<artifactId>common-dto</artifactId>
|
<artifactId>common-dto</artifactId>
|
||||||
<version>${school-news.version}</version>
|
<version>${school-news.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,34 +30,11 @@ public class TbAiAgentConfig extends BaseDTO {
|
|||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 系统提示词
|
* @description 是否连接互联网 0不连接 1连接
|
||||||
*/
|
*/
|
||||||
private String systemPrompt;
|
private Integer connectInternet;
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 模型名称
|
|
||||||
*/
|
|
||||||
private String modelName;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 模型提供商
|
|
||||||
*/
|
|
||||||
private String modelProvider;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 温度值
|
|
||||||
*/
|
|
||||||
private BigDecimal temperature;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 最大tokens
|
|
||||||
*/
|
|
||||||
private Integer maxTokens;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Top P值
|
|
||||||
*/
|
|
||||||
private BigDecimal topP;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Dify应用ID
|
* @description Dify应用ID
|
||||||
@@ -108,52 +85,12 @@ public class TbAiAgentConfig extends BaseDTO {
|
|||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getSystemPrompt() {
|
public Integer getConnectInternet() {
|
||||||
return systemPrompt;
|
return connectInternet;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setSystemPrompt(String systemPrompt) {
|
public void setConnectInternet(Integer connectInternet) {
|
||||||
this.systemPrompt = systemPrompt;
|
this.connectInternet = connectInternet;
|
||||||
}
|
|
||||||
|
|
||||||
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 String getDifyAppId() {
|
public String getDifyAppId() {
|
||||||
@@ -201,9 +138,12 @@ public class TbAiAgentConfig extends BaseDTO {
|
|||||||
return "TbAiAgentConfig{" +
|
return "TbAiAgentConfig{" +
|
||||||
"id=" + getID() +
|
"id=" + getID() +
|
||||||
", name='" + name + '\'' +
|
", name='" + name + '\'' +
|
||||||
", modelName='" + modelName + '\'' +
|
", connectInternet=" + connectInternet +
|
||||||
", modelProvider='" + modelProvider + '\'' +
|
", difyAppId='" + difyAppId + '\'' +
|
||||||
|
", difyApiKey='" + difyApiKey + '\'' +
|
||||||
", status=" + status +
|
", status=" + status +
|
||||||
|
", creator='" + creator + '\'' +
|
||||||
|
", updater='" + updater + '\'' +
|
||||||
", createTime=" + getCreateTime() +
|
", createTime=" + getCreateTime() +
|
||||||
'}';
|
'}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ public class TbAiUploadFile extends BaseDTO {
|
|||||||
*/
|
*/
|
||||||
private String conversationID;
|
private String conversationID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 关联消息ID(绑定到具体的用户消息)
|
||||||
|
*/
|
||||||
|
private String messageID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 系统文件ID(关联tb_sys_file)
|
||||||
|
*/
|
||||||
|
private String sysFileId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 文件名
|
* @description 文件名
|
||||||
*/
|
*/
|
||||||
@@ -68,6 +78,11 @@ public class TbAiUploadFile extends BaseDTO {
|
|||||||
*/
|
*/
|
||||||
private String difyBatchId;
|
private String difyBatchId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Dify上传文件ID(对话中上传的文件)
|
||||||
|
*/
|
||||||
|
private String difyUploadFileId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 分段数量
|
* @description 分段数量
|
||||||
*/
|
*/
|
||||||
@@ -107,6 +122,22 @@ public class TbAiUploadFile extends BaseDTO {
|
|||||||
this.conversationID = conversationID;
|
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() {
|
public String getFileName() {
|
||||||
return fileName;
|
return fileName;
|
||||||
}
|
}
|
||||||
@@ -171,6 +202,14 @@ public class TbAiUploadFile extends BaseDTO {
|
|||||||
this.difyBatchId = difyBatchId;
|
this.difyBatchId = difyBatchId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDifyUploadFileId() {
|
||||||
|
return difyUploadFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDifyUploadFileId(String difyUploadFileId) {
|
||||||
|
this.difyUploadFileId = difyUploadFileId;
|
||||||
|
}
|
||||||
|
|
||||||
public Integer getChunkCount() {
|
public Integer getChunkCount() {
|
||||||
return chunkCount;
|
return chunkCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.io.File;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.xyzh.api.file.FileService;
|
import org.xyzh.api.file.FileService;
|
||||||
import org.xyzh.common.core.domain.ResultDomain;
|
import org.xyzh.common.core.domain.ResultDomain;
|
||||||
@@ -371,5 +372,120 @@ public class FileServiceImpl implements FileService {
|
|||||||
return resultDomain;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,5 +61,14 @@ public interface FileStorageStrategy {
|
|||||||
* @since 2025-10-16
|
* @since 2025-10-16
|
||||||
*/
|
*/
|
||||||
String getStorageType();
|
String getStorageType();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取文件的绝对路径(用于临时文件)
|
||||||
|
* @param relativePath 相对路径
|
||||||
|
* @return String 绝对路径
|
||||||
|
* @author AI Assistant
|
||||||
|
* @since 2025-11-06
|
||||||
|
*/
|
||||||
|
String getAbsolutePath(String relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,5 +76,10 @@ public class LocalFileStorageStrategy implements FileStorageStrategy {
|
|||||||
public String getStorageType() {
|
public String getStorageType() {
|
||||||
return "local";
|
return "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getAbsolutePath(String relativePath) {
|
||||||
|
return basePath + File.separator + relativePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,5 +137,10 @@ public class MinIOFileStorageStrategy implements FileStorageStrategy {
|
|||||||
public String getStorageType() {
|
public String getStorageType() {
|
||||||
return "minio";
|
return "minio";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getAbsolutePath(String relativePath) {
|
||||||
|
return relativePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export const aiAgentConfigApi = {
|
|||||||
* @returns Promise<ResultDomain<AiAgentConfig>>
|
* @returns Promise<ResultDomain<AiAgentConfig>>
|
||||||
*/
|
*/
|
||||||
async getAgentById(agentId: string): 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -56,7 +58,9 @@ export const aiAgentConfigApi = {
|
|||||||
* @returns Promise<ResultDomain<AiAgentConfig[]>>
|
* @returns Promise<ResultDomain<AiAgentConfig[]>>
|
||||||
*/
|
*/
|
||||||
async listEnabledAgents(): 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const chatHistoryApi = {
|
|||||||
*/
|
*/
|
||||||
async getRecentConversations(limit = 10): Promise<ResultDomain<AiConversation>> {
|
async getRecentConversations(limit = 10): Promise<ResultDomain<AiConversation>> {
|
||||||
const response = await api.get<AiConversation>('/ai/chat/history/recent', {
|
const response = await api.get<AiConversation>('/ai/chat/history/recent', {
|
||||||
params: { limit }
|
limit
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,25 +18,38 @@ import type {
|
|||||||
*/
|
*/
|
||||||
export const chatApi = {
|
export const chatApi = {
|
||||||
/**
|
/**
|
||||||
* 流式对话(SSE)- 使用fetch支持Authorization
|
* 流式对话(SSE)- 两步法:POST准备 + GET建立SSE
|
||||||
* @param request 对话请求
|
* @param request 对话请求
|
||||||
* @param callback 流式回调
|
* @param callback 流式回调
|
||||||
* @returns Promise<ResultDomain<AiMessage>>
|
* @returns Promise<ResultDomain<AiMessage>>
|
||||||
*/
|
*/
|
||||||
async streamChat(request: ChatRequest, callback?: StreamCallback): 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 使用相对路径走Vite代理,避免跨域
|
// 使用IIFE包装async逻辑,避免Promise executor是async的警告
|
||||||
const eventSource = new EventSource(
|
(async () => {
|
||||||
`/api/ai/chat/stream?` +
|
try {
|
||||||
new URLSearchParams({
|
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,
|
agentId: request.agentId,
|
||||||
conversationId: request.conversationId || '',
|
conversationId: request.conversationId || '',
|
||||||
query: request.query,
|
query: request.query,
|
||||||
knowledgeIds: request.knowledgeIds?.join(',') || '',
|
files: request.files || []
|
||||||
token: tokenData?.value || ''
|
}, {
|
||||||
})
|
showLoading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!prepareResponse.data.success || !prepareResponse.data.data) {
|
||||||
|
throw new Error(prepareResponse.data.message || '准备会话失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = prepareResponse.data.data;
|
||||||
|
console.log('[会话创建成功] sessionId:', sessionId);
|
||||||
|
|
||||||
|
// 第2步:GET建立SSE连接
|
||||||
|
const eventSource = new EventSource(
|
||||||
|
`/api/ai/chat/stream?sessionId=${sessionId}&token=${tokenData}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 通知外部EventSource已创建
|
// 通知外部EventSource已创建
|
||||||
@@ -100,7 +113,6 @@ export const chatApi = {
|
|||||||
eventSource.addEventListener(eventType, (event: any) => {
|
eventSource.addEventListener(eventType, (event: any) => {
|
||||||
try {
|
try {
|
||||||
const eventData = JSON.parse(event.data);
|
const eventData = JSON.parse(event.data);
|
||||||
console.log(`[Dify事件] ${eventType}:`, eventData);
|
|
||||||
|
|
||||||
// 调用自定义的Dify事件回调
|
// 调用自定义的Dify事件回调
|
||||||
if (callback?.onDifyEvent) {
|
if (callback?.onDifyEvent) {
|
||||||
@@ -126,6 +138,12 @@ export const chatApi = {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
reject(error);
|
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', {
|
const response = await api.post<AiConversation>('/ai/chat/conversation', {
|
||||||
agentId,
|
agentId,
|
||||||
title
|
title
|
||||||
|
}, {
|
||||||
|
showLoading: false
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -193,7 +213,9 @@ export const chatApi = {
|
|||||||
* @returns Promise<ResultDomain<AiConversation>>
|
* @returns Promise<ResultDomain<AiConversation>>
|
||||||
*/
|
*/
|
||||||
async updateConversation(conversation: AiConversation): 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -203,7 +225,9 @@ export const chatApi = {
|
|||||||
* @returns Promise<ResultDomain<boolean>>
|
* @returns Promise<ResultDomain<boolean>>
|
||||||
*/
|
*/
|
||||||
async deleteConversation(conversationId: string): 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -225,7 +249,9 @@ export const chatApi = {
|
|||||||
* @returns Promise<ResultDomain<AiMessage[]>>
|
* @returns Promise<ResultDomain<AiMessage[]>>
|
||||||
*/
|
*/
|
||||||
async listMessages(conversationId: string): 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -319,8 +345,6 @@ export const chatApi = {
|
|||||||
eventSource.addEventListener(eventType, (event: any) => {
|
eventSource.addEventListener(eventType, (event: any) => {
|
||||||
try {
|
try {
|
||||||
const eventData = JSON.parse(event.data);
|
const eventData = JSON.parse(event.data);
|
||||||
console.log(`[Dify事件] ${eventType}:`, eventData);
|
|
||||||
|
|
||||||
// 调用自定义的Dify事件回调
|
// 调用自定义的Dify事件回调
|
||||||
if (callback?.onDifyEvent) {
|
if (callback?.onDifyEvent) {
|
||||||
const cleanEventType = eventType.replace('dify_', '');
|
const cleanEventType = eventType.replace('dify_', '');
|
||||||
@@ -369,6 +393,8 @@ export const chatApi = {
|
|||||||
const response = await api.post<boolean>(`/ai/chat/message/${messageId}/rate`, {
|
const response = await api.post<boolean>(`/ai/chat/message/${messageId}/rate`, {
|
||||||
rating,
|
rating,
|
||||||
feedback
|
feedback
|
||||||
|
}, {
|
||||||
|
showLoading: false
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,24 @@ import type { AiUploadFile, ResultDomain, FileUploadResponse, PageParam } from '
|
|||||||
* 文件上传API服务
|
* 文件上传API服务
|
||||||
*/
|
*/
|
||||||
export const fileUploadApi = {
|
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
|
* @param knowledgeId 知识库ID
|
||||||
@@ -21,7 +39,9 @@ export const fileUploadApi = {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('knowledgeId', knowledgeId);
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -38,7 +58,9 @@ export const fileUploadApi = {
|
|||||||
});
|
});
|
||||||
formData.append('knowledgeId', knowledgeId);
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -104,5 +126,17 @@ export const fileUploadApi = {
|
|||||||
async batchSyncFileStatus(fileIds: string[]): Promise<ResultDomain<number>> {
|
async batchSyncFileStatus(fileIds: string[]): Promise<ResultDomain<number>> {
|
||||||
const response = await api.post<number>('/ai/file/batch-sync', { fileIds });
|
const response = await api.post<number>('/ai/file/batch-sync', { fileIds });
|
||||||
return response.data;
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface AiAgentConfig extends BaseDTO {
|
|||||||
description?: string;
|
description?: string;
|
||||||
/** 系统提示词 */
|
/** 系统提示词 */
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
/** 是否连接互联网(0否 1是) */
|
||||||
|
connectInternet?: number;
|
||||||
/** 模型名称 */
|
/** 模型名称 */
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
/** 模型提供商 */
|
/** 模型提供商 */
|
||||||
@@ -73,6 +75,8 @@ export interface AiKnowledge extends BaseDTO {
|
|||||||
export interface AiUploadFile extends BaseDTO {
|
export interface AiUploadFile extends BaseDTO {
|
||||||
/** 知识库ID */
|
/** 知识库ID */
|
||||||
knowledgeId?: string;
|
knowledgeId?: string;
|
||||||
|
/** 系统文件ID(关联tb_sys_file) */
|
||||||
|
sysFileId?: string;
|
||||||
/** 文件名 */
|
/** 文件名 */
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
/** 文件路径 */
|
/** 文件路径 */
|
||||||
@@ -139,6 +143,8 @@ export interface AiMessage extends BaseDTO {
|
|||||||
content?: string;
|
content?: string;
|
||||||
/** 关联文件ID(JSON数组) */
|
/** 关联文件ID(JSON数组) */
|
||||||
fileIDs?: string;
|
fileIDs?: string;
|
||||||
|
/** 关联文件列表(前端附加,用于显示文件详情) */
|
||||||
|
files?: AiUploadFile[];
|
||||||
/** 引用知识ID(JSON数组) */
|
/** 引用知识ID(JSON数组) */
|
||||||
knowledgeIDs?: string;
|
knowledgeIDs?: string;
|
||||||
/** 知识库引用详情(JSON数组) */
|
/** 知识库引用详情(JSON数组) */
|
||||||
@@ -185,10 +191,15 @@ export interface ChatRequest {
|
|||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
/** 用户问题 */
|
/** 用户问题 */
|
||||||
query: string;
|
query: string;
|
||||||
/** 指定的知识库ID列表(可选) */
|
|
||||||
knowledgeIds?: string[];
|
|
||||||
/** 是否流式返回 */
|
/** 是否流式返回 */
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
|
fileIDs?: string;
|
||||||
|
/** 上传的文件列表(Dify文件信息) */
|
||||||
|
files?: Array<{
|
||||||
|
type: string;
|
||||||
|
transfer_method: string;
|
||||||
|
upload_file_id: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -55,6 +55,21 @@
|
|||||||
show-word-limit
|
show-word-limit
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
@@ -90,11 +105,20 @@ const configForm = ref<AiAgentConfig>({
|
|||||||
name: '',
|
name: '',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
|
connectInternet: 0,
|
||||||
modelName: '',
|
modelName: '',
|
||||||
modelProvider: 'dify',
|
modelProvider: 'dify',
|
||||||
status: 1
|
status: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 联网开关(用于双向绑定)
|
||||||
|
const internetEnabled = computed({
|
||||||
|
get: () => configForm.value.connectInternet || 0,
|
||||||
|
set: (val) => {
|
||||||
|
configForm.value.connectInternet = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -119,9 +143,9 @@ async function loadConfig() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
// 获取启用的智能体列表
|
// 获取启用的智能体列表
|
||||||
const result = await aiAgentConfigApi.listEnabledAgents();
|
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) {
|
} catch (error) {
|
||||||
console.error('加载配置失败:', 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) {
|
:deep(.el-switch) {
|
||||||
--el-switch-on-color: #E7000B;
|
--el-switch-on-color: #E7000B;
|
||||||
|
|
||||||
.el-switch__label {
|
.el-switch__label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #0A0A0A;
|
color: #0A0A0A;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-checked .el-switch__label--left {
|
||||||
|
color: #6B7240;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.is-checked) .el-switch__label--right {
|
||||||
|
color: #6B7240;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<div v-if="!historyCollapsed" class="history-list">
|
<div v-if="!historyCollapsed" class="history-list">
|
||||||
<!-- 新建对话按钮 -->
|
<!-- 新建对话按钮 -->
|
||||||
<button class="new-chat-btn" @click="() => createNewConversation()">
|
<button class="new-chat-btn" @click="prepareNewConversation">
|
||||||
+ 新建对话
|
+ 新建对话
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -83,6 +83,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<div class="message-text">{{ message.content }}</div>
|
<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 class="message-time">{{ formatMessageTime(message.createTime) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,16 +109,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-content">
|
<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>
|
<span></span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 否则显示实际内容 -->
|
|
||||||
<template v-else>
|
<!-- 消息底部:时间和操作按钮 -->
|
||||||
<div class="message-text" v-html="formatMarkdown(message.content || '')"></div>
|
<div v-if="message.content" class="message-footer">
|
||||||
<div class="message-footer">
|
|
||||||
<div class="message-time">{{ formatMessageTime(message.createTime) }}</div>
|
<div class="message-time">{{ formatMessageTime(message.createTime) }}</div>
|
||||||
<div class="message-actions">
|
<div class="message-actions">
|
||||||
<button @click="copyMessage(message.content || '')" class="msg-action-btn" title="复制">
|
<button @click="copyMessage(message.content || '')" class="msg-action-btn" title="复制">
|
||||||
@@ -124,25 +139,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,8 +187,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { chatApi, chatHistoryApi, aiAgentConfigApi } from '@/apis/ai';
|
import { chatApi, chatHistoryApi, aiAgentConfigApi, fileUploadApi } from '@/apis/ai';
|
||||||
import type { AiConversation, AiMessage, AiAgentConfig } from '@/types/ai';
|
import type { AiConversation, AiMessage, AiAgentConfig, AiUploadFile } from '@/types/ai';
|
||||||
|
|
||||||
interface AIAgentProps {
|
interface AIAgentProps {
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
@@ -480,21 +476,27 @@ async function loadMoreConversations() {
|
|||||||
// TODO: 实现分页加载
|
// TODO: 实现分页加载
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新建对话
|
// 准备新对话(只清空状态,不创建conversation)
|
||||||
|
function prepareNewConversation() {
|
||||||
|
currentConversation.value = null;
|
||||||
|
messages.value = [];
|
||||||
|
ElMessage.success('已准备新对话,发送消息后将自动创建');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新对话(内部使用,在发送第一条消息时调用)
|
||||||
async function createNewConversation(title?: string) {
|
async function createNewConversation(title?: string) {
|
||||||
try {
|
try {
|
||||||
const result = await chatApi.createConversation(agentConfig.value!.id!, title);
|
const result = await chatApi.createConversation(agentConfig.value!.id!, title);
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
currentConversation.value = result.data;
|
currentConversation.value = result.data;
|
||||||
|
// 将新创建的对话添加到列表开头
|
||||||
conversations.value.unshift(result.data);
|
conversations.value.unshift(result.data);
|
||||||
messages.value = [];
|
return result.data;
|
||||||
if (!title) {
|
|
||||||
ElMessage.success('已创建新对话');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('创建对话失败:', error);
|
console.error('创建对话失败:', error);
|
||||||
ElMessage.error('创建对话失败');
|
ElMessage.error('创建对话失败');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +596,9 @@ const difyEventData = ref<Record<string, any>>({}); // 存储Dify事件数据
|
|||||||
const chatContentRef = ref<HTMLElement | null>(null);
|
const chatContentRef = ref<HTMLElement | null>(null);
|
||||||
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
// 消息文件列表缓存
|
||||||
|
const messageFilesCache = ref<Record<string, any[]>>({});
|
||||||
|
|
||||||
// 加载消息
|
// 加载消息
|
||||||
async function loadMessages(conversationId: string) {
|
async function loadMessages(conversationId: string) {
|
||||||
try {
|
try {
|
||||||
@@ -602,6 +607,14 @@ async function loadMessages(conversationId: string) {
|
|||||||
// 后端返回List,所以数据在dataList字段
|
// 后端返回List,所以数据在dataList字段
|
||||||
const messageList = result.dataList || result.data || [];
|
const messageList = result.dataList || result.data || [];
|
||||||
messages.value = Array.isArray(messageList) ? messageList : [];
|
messages.value = Array.isArray(messageList) ? messageList : [];
|
||||||
|
|
||||||
|
// 加载每条用户消息的关联文件
|
||||||
|
for (const message of messages.value) {
|
||||||
|
if (message.role === 'user' && message.id) {
|
||||||
|
await loadMessageFiles(message.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
scrollToBottom();
|
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() {
|
async function sendMessage() {
|
||||||
if (!canSend.value) return;
|
if (!canSend.value) return;
|
||||||
@@ -620,10 +651,13 @@ async function sendMessage() {
|
|||||||
// 如果没有当前对话,创建新对话,使用第一个问题作为标题
|
// 如果没有当前对话,创建新对话,使用第一个问题作为标题
|
||||||
const isFirstMessage = !currentConversation.value;
|
const isFirstMessage = !currentConversation.value;
|
||||||
if (isFirstMessage) {
|
if (isFirstMessage) {
|
||||||
// 限制标题长度为50字符
|
// 限制标题长度为20字符
|
||||||
const title = message.length > 50 ? message.substring(0, 50) + '...' : message;
|
const title = message.length > 20 ? message.substring(0, 20) + '...' : message;
|
||||||
await createNewConversation(title);
|
const newConv = await createNewConversation(title);
|
||||||
if (!currentConversation.value) return;
|
if (!newConv) {
|
||||||
|
ElMessage.error('创建对话失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加用户消息到界面
|
// 添加用户消息到界面
|
||||||
@@ -648,11 +682,20 @@ async function sendMessage() {
|
|||||||
try {
|
try {
|
||||||
let aiMessageContent = '';
|
let aiMessageContent = '';
|
||||||
|
|
||||||
await chatApi.streamChat({
|
chatApi.streamChat({
|
||||||
agentId: agentConfig.value!.id!,
|
agentId: agentConfig.value!.id!,
|
||||||
conversationId: currentConversation.value?.id || '',
|
conversationId: currentConversation.value?.id || '',
|
||||||
query: message,
|
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) => {
|
onStart: (eventSource: EventSource) => {
|
||||||
@@ -681,7 +724,6 @@ async function sendMessage() {
|
|||||||
updateTime: new Date().toISOString()
|
updateTime: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 累加内容(包括空chunk,因为后端可能分块发送)
|
// 累加内容(包括空chunk,因为后端可能分块发送)
|
||||||
if (chunk) {
|
if (chunk) {
|
||||||
aiMessageContent += chunk;
|
aiMessageContent += chunk;
|
||||||
@@ -697,7 +739,7 @@ async function sendMessage() {
|
|||||||
},
|
},
|
||||||
onDifyEvent: (eventType: string, eventData: any) => {
|
onDifyEvent: (eventType: string, eventData: any) => {
|
||||||
// 处理Dify原始事件(包含完整信息)
|
// 处理Dify原始事件(包含完整信息)
|
||||||
console.log(`[Dify事件] ${eventType}:`, eventData);
|
|
||||||
|
|
||||||
// 存储事件数据
|
// 存储事件数据
|
||||||
difyEventData.value[eventType] = eventData;
|
difyEventData.value[eventType] = eventData;
|
||||||
@@ -712,17 +754,12 @@ async function sendMessage() {
|
|||||||
// 例如:node_started, node_finished, agent_thought等
|
// 例如:node_started, node_finished, agent_thought等
|
||||||
},
|
},
|
||||||
onMessageEnd: () => {
|
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;
|
isGenerating.value = false;
|
||||||
currentEventSource.value = null;
|
currentEventSource.value = null;
|
||||||
currentTaskId.value = null;
|
currentTaskId.value = null;
|
||||||
currentMessageId.value = null;
|
currentMessageId.value = null;
|
||||||
|
// 清空已上传的文件列表(文件仅对一次消息发送生效)
|
||||||
|
uploadedFiles.value = [];
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
console.error('对话失败:', error);
|
console.error('对话失败:', error);
|
||||||
@@ -731,14 +768,27 @@ async function sendMessage() {
|
|||||||
currentEventSource.value = null;
|
currentEventSource.value = null;
|
||||||
currentTaskId.value = null;
|
currentTaskId.value = null;
|
||||||
currentMessageId.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) {
|
} catch (error) {
|
||||||
console.error('发送消息失败:', error);
|
console.error('发送消息失败:', error);
|
||||||
ElMessage.error('发送消息失败');
|
ElMessage.error('发送消息失败');
|
||||||
isGenerating.value = false;
|
isGenerating.value = false;
|
||||||
currentEventSource.value = null;
|
currentEventSource.value = null;
|
||||||
|
// 发送失败也清空文件列表
|
||||||
|
uploadedFiles.value = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,8 +916,6 @@ async function regenerateMessage(messageId: string) {
|
|||||||
nextTick(() => scrollToBottom());
|
nextTick(() => scrollToBottom());
|
||||||
},
|
},
|
||||||
onDifyEvent: (eventType: string, eventData: any) => {
|
onDifyEvent: (eventType: string, eventData: any) => {
|
||||||
// 处理Dify原始事件(包含完整信息)
|
|
||||||
console.log(`[Dify事件-重新生成] ${eventType}:`, eventData);
|
|
||||||
|
|
||||||
// 存储事件数据
|
// 存储事件数据
|
||||||
difyEventData.value[eventType] = eventData;
|
difyEventData.value[eventType] = eventData;
|
||||||
@@ -923,25 +971,65 @@ 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 fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const isUploading = ref(false);
|
||||||
|
|
||||||
function triggerFileUpload() {
|
function triggerFileUpload() {
|
||||||
fileInputRef.value?.click();
|
fileInputRef.value?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileUpload(e: Event) {
|
async function handleFileUpload(e: Event) {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
const files = target.files;
|
const files = target.files;
|
||||||
|
|
||||||
if (files && files.length > 0) {
|
if (!files || files.length === 0) return;
|
||||||
uploadedFiles.value.push(...Array.from(files));
|
if (!agentConfig.value?.id) {
|
||||||
ElMessage.success(`已添加 ${files.length} 个文件`);
|
ElMessage.error('智能体未加载');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,允许重复选择同一文件
|
// 清空input,允许重复选择同一文件
|
||||||
target.value = '';
|
target.value = '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function removeUploadedFile(index: number) {
|
function removeUploadedFile(index: number) {
|
||||||
uploadedFiles.value.splice(index, 1);
|
uploadedFiles.value.splice(index, 1);
|
||||||
@@ -974,6 +1062,15 @@ function formatMessageTime(dateStr: string | undefined) {
|
|||||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
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) {
|
function formatMarkdown(content: string) {
|
||||||
// 简单的 Markdown 转换(可以使用 marked.js 等库进行更复杂的转换)
|
// 简单的 Markdown 转换(可以使用 marked.js 等库进行更复杂的转换)
|
||||||
let html = content;
|
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 {
|
.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 {
|
.input-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user