feat: 添加项目配置和依赖更新
配置更新: 1. 前端配置 - 添加 hook-fetch 依赖用于 HTTP 请求 - 更新 vite.config.mts 配置 - 添加 .npmrc 配置文件 2. 后端配置 - 更新 application.yml 和 application-dev.yml 配置 - 更新 docker-compose.yml 配置 3. 代码优化 - OSS 客户端优化 - SSE 管理器优化 - 聊天服务和向量存储策略优化 4. 项目文档 - 添加 CLAUDE.md 项目指南 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -58,7 +58,7 @@ spring:
|
||||
driverClassName: com.mysql.cj.jdbc.Driver
|
||||
# jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562
|
||||
# rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题)
|
||||
url: jdbc:mysql://127.0.0.1:3306/hzhub_ai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
|
||||
url: jdbc:mysql://127.0.0.1:3306/ruoyi_ai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
|
||||
username: root
|
||||
password: hzhub123
|
||||
# agent:
|
||||
|
||||
@@ -277,11 +277,11 @@ warm-flow:
|
||||
vector-store:
|
||||
# 向量存储类型 可选(weaviate/milvus)
|
||||
# 如需修改向量库类型,请修改此配置值!
|
||||
type: milvus
|
||||
type: weaviate
|
||||
# Weaviate配置
|
||||
weaviate:
|
||||
protocol: http
|
||||
host: 127.0.0.1:6038
|
||||
host: 127.0.0.1:28080
|
||||
classname: LocalKnowledge
|
||||
# Milvus配置
|
||||
milvus:
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.hzhub.common.oss.properties.OssProperties;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.core.async.*;
|
||||
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
|
||||
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
|
||||
@@ -94,7 +95,14 @@ public class OssClient {
|
||||
.region(of())
|
||||
.forcePathStyle(isStyle)
|
||||
.httpClient(NettyNioAsyncHttpClient.builder()
|
||||
.connectionTimeout(Duration.ofSeconds(60)).build())
|
||||
.connectionTimeout(Duration.ofMinutes(5))
|
||||
.readTimeout(Duration.ofMinutes(5))
|
||||
.writeTimeout(Duration.ofMinutes(5))
|
||||
.build())
|
||||
.overrideConfiguration(ClientOverrideConfiguration.builder()
|
||||
.apiCallTimeout(Duration.ofMinutes(10))
|
||||
.apiCallAttemptTimeout(Duration.ofMinutes(5))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
//AWS基于 CRT 的 S3 AsyncClient 实例用作 S3 传输管理器的底层客户端
|
||||
@@ -172,36 +180,22 @@ public class OssClient {
|
||||
* @throws OssException 如果上传失败,抛出自定义异常
|
||||
*/
|
||||
public UploadResult upload(InputStream inputStream, String key, Long length, String contentType) {
|
||||
// 如果输入流不是 ByteArrayInputStream,则将其读取为字节数组再创建 ByteArrayInputStream
|
||||
if (!(inputStream instanceof ByteArrayInputStream)) {
|
||||
inputStream = new ByteArrayInputStream(IoUtil.readBytes(inputStream));
|
||||
}
|
||||
// 将输入流转换为字节数组,使用异步字节数组上传方式
|
||||
try {
|
||||
// 创建异步请求体(length如果为空会报错)
|
||||
BlockingInputStreamAsyncRequestBody body = BlockingInputStreamAsyncRequestBody.builder()
|
||||
.contentLength(length)
|
||||
.subscribeTimeout(Duration.ofSeconds(120))
|
||||
.build();
|
||||
byte[] bytes = IoUtil.readBytes(inputStream);
|
||||
|
||||
// 使用 transferManager 进行上传
|
||||
Upload upload = transferManager.upload(
|
||||
x -> x.requestBody(body).addTransferListener(LoggingTransferListener.create())
|
||||
.putObjectRequest(
|
||||
y -> y.bucket(properties.getBucketName())
|
||||
.key(key)
|
||||
.contentType(contentType)
|
||||
// 用于设置对象的访问控制列表(ACL)。不同云厂商对ACL的支持和实现方式有所不同,
|
||||
// 因此根据具体的云服务提供商,你可能需要进行不同的配置(自行开启,阿里云有acl权限配置,腾讯云没有acl权限配置)
|
||||
//.acl(getAccessPolicy().getObjectCannedACL())
|
||||
.build())
|
||||
.build());
|
||||
// 使用异步方式上传字节数组
|
||||
software.amazon.awssdk.services.s3.model.PutObjectResponse response = client.putObject(
|
||||
software.amazon.awssdk.services.s3.model.PutObjectRequest.builder()
|
||||
.bucket(properties.getBucketName())
|
||||
.key(key)
|
||||
.contentType(contentType)
|
||||
.contentLength((long) bytes.length)
|
||||
.build(),
|
||||
software.amazon.awssdk.core.async.AsyncRequestBody.fromBytes(bytes)
|
||||
).join();
|
||||
|
||||
// 将输入流写入请求体
|
||||
body.writeInputStream(inputStream);
|
||||
|
||||
// 等待文件上传操作完成
|
||||
CompletedUpload uploadResult = upload.completionFuture().join();
|
||||
String eTag = uploadResult.response().eTag();
|
||||
String eTag = response.eTag();
|
||||
|
||||
// 提取上传结果中的 ETag,并构建一个自定义的 UploadResult 对象
|
||||
return UploadResult.builder().url(getUrl() + StringUtils.SLASH + key).filename(key).eTag(eTag).build();
|
||||
|
||||
@@ -93,6 +93,24 @@ public class SseEmitterManager {
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已存在的 SSE 连接(不创建新连接)
|
||||
*
|
||||
* @param userId 用户的唯一标识符
|
||||
* @param token 用户的唯一令牌
|
||||
* @return 返回已存在的 SseEmitter 实例,如果不存在则返回 null
|
||||
*/
|
||||
public SseEmitter getEmitter(Long userId, String token) {
|
||||
if (userId == null || token == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, SseEmitter> emitters = USER_TOKEN_EMITTERS.get(userId);
|
||||
if (MapUtil.isNotEmpty(emitters)) {
|
||||
return emitters.get(token);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开指定用户的 SSE 连接
|
||||
*
|
||||
|
||||
@@ -105,10 +105,17 @@ public class ChatServiceFacade implements IChatService {
|
||||
*/
|
||||
public SseEmitter sseChat(ChatRequest chatRequest) {
|
||||
|
||||
// 4. 具体的服务实现
|
||||
// 获取用户信息和已存在的 SSE 连接
|
||||
Long userId = LoginHelper.getUserId();
|
||||
String tokenValue = StpUtil.getTokenValue();
|
||||
SseEmitter emitter = sseEmitterManager.connect(userId, tokenValue);
|
||||
|
||||
// 获取已存在的 SSE 连接(前端已通过 GET /resource/sse 建立)
|
||||
// 不再调用 connect() 以避免关闭前端的连接
|
||||
SseEmitter emitter = sseEmitterManager.getEmitter(userId, tokenValue);
|
||||
if (emitter == null) {
|
||||
// 如果没有已存在的连接,则建立新连接(兼容未预先建立连接的情况)
|
||||
emitter = sseEmitterManager.connect(userId, tokenValue);
|
||||
}
|
||||
|
||||
// 1. 根据模型名称查询完整配置
|
||||
ChatModelVo chatModelVo = chatModelService.selectModelByName(chatRequest.getModel());
|
||||
@@ -205,66 +212,81 @@ public class ChatServiceFacade implements IChatService {
|
||||
* @param chatModelVo 聊天模型配置
|
||||
*/
|
||||
private void handleThinkingMode(ChatRequest chatRequest, List<ChatMessage> contextMessages, ChatModelVo chatModelVo) {
|
||||
// 步骤1: 配置MCP传输层 - 连接到bing-cn-mcp服务器
|
||||
McpTransport transport = new StdioMcpTransport.Builder()
|
||||
.command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "bing-cn-mcp"))
|
||||
.logEvents(true)
|
||||
.build();
|
||||
try {
|
||||
// 步骤1: 配置MCP传输层 - 连接到bing-cn-mcp服务器
|
||||
// 根据操作系统选择正确的命令路径
|
||||
String osName = System.getProperty("os.name").toLowerCase();
|
||||
List<String> mcpCommand = osName.contains("win")
|
||||
? List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "bing-cn-mcp")
|
||||
: List.of("npx", "-y", "bing-cn-mcp");
|
||||
|
||||
McpClient mcpClient = new DefaultMcpClient.Builder()
|
||||
.transport(transport)
|
||||
.build();
|
||||
McpTransport transport = new StdioMcpTransport.Builder()
|
||||
.command(mcpCommand)
|
||||
.logEvents(true)
|
||||
.build();
|
||||
|
||||
ToolProvider toolProvider = McpToolProvider.builder()
|
||||
.mcpClients(List.of(mcpClient))
|
||||
.build();
|
||||
McpClient mcpClient = new DefaultMcpClient.Builder()
|
||||
.transport(transport)
|
||||
.build();
|
||||
|
||||
// 配置echarts MCP
|
||||
McpTransport transport1 = new StdioMcpTransport.Builder()
|
||||
.command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "mcp-echarts"))
|
||||
.logEvents(true)
|
||||
.build();
|
||||
ToolProvider toolProvider = McpToolProvider.builder()
|
||||
.mcpClients(List.of(mcpClient))
|
||||
.build();
|
||||
|
||||
McpClient mcpClient1 = new DefaultMcpClient.Builder()
|
||||
.transport(transport1)
|
||||
.build();
|
||||
// 配置echarts MCP
|
||||
List<String> echartsCommand = osName.contains("win")
|
||||
? List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y", "mcp-echarts")
|
||||
: List.of("npx", "-y", "mcp-echarts");
|
||||
|
||||
ToolProvider toolProvider1 = McpToolProvider.builder()
|
||||
.mcpClients(List.of(mcpClient1))
|
||||
.build();
|
||||
McpTransport transport1 = new StdioMcpTransport.Builder()
|
||||
.command(echartsCommand)
|
||||
.logEvents(true)
|
||||
.build();
|
||||
|
||||
// 配置模型
|
||||
OpenAiChatModel plannerModel = OpenAiChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.build();
|
||||
McpClient mcpClient1 = new DefaultMcpClient.Builder()
|
||||
.transport(transport1)
|
||||
.build();
|
||||
|
||||
// 构建各Agent
|
||||
SqlAgent sqlAgent = AgenticServices.agentBuilder(SqlAgent.class)
|
||||
.chatModel(plannerModel)
|
||||
.tools(new QueryAllTablesTool(), new QueryTableSchemaTool(), new ExecuteSqlQueryTool())
|
||||
.build();
|
||||
ToolProvider toolProvider1 = McpToolProvider.builder()
|
||||
.mcpClients(List.of(mcpClient1))
|
||||
.build();
|
||||
|
||||
WebSearchAgent searchAgent = AgenticServices.agentBuilder(WebSearchAgent.class)
|
||||
.chatModel(plannerModel)
|
||||
.toolProvider(toolProvider)
|
||||
.build();
|
||||
// 配置模型
|
||||
OpenAiChatModel plannerModel = OpenAiChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.build();
|
||||
|
||||
ChartGenerationAgent chartGenerationAgent = AgenticServices.agentBuilder(ChartGenerationAgent.class)
|
||||
.chatModel(plannerModel)
|
||||
.toolProvider(toolProvider1)
|
||||
.build();
|
||||
// 构建各Agent
|
||||
SqlAgent sqlAgent = AgenticServices.agentBuilder(SqlAgent.class)
|
||||
.chatModel(plannerModel)
|
||||
.tools(new QueryAllTablesTool(), new QueryTableSchemaTool(), new ExecuteSqlQueryTool())
|
||||
.build();
|
||||
|
||||
// 构建监督者Agent
|
||||
SupervisorAgent supervisor = AgenticServices.supervisorBuilder()
|
||||
.chatModel(plannerModel)
|
||||
.subAgents(sqlAgent, chartGenerationAgent)
|
||||
.responseStrategy(SupervisorResponseStrategy.LAST)
|
||||
.build();
|
||||
WebSearchAgent searchAgent = AgenticServices.agentBuilder(WebSearchAgent.class)
|
||||
.chatModel(plannerModel)
|
||||
.toolProvider(toolProvider)
|
||||
.build();
|
||||
|
||||
String invoke = supervisor.invoke(chatRequest.getContent());
|
||||
contextMessages.add(AiMessage.from(invoke));
|
||||
ChartGenerationAgent chartGenerationAgent = AgenticServices.agentBuilder(ChartGenerationAgent.class)
|
||||
.chatModel(plannerModel)
|
||||
.toolProvider(toolProvider1)
|
||||
.build();
|
||||
|
||||
// 构建监督者Agent
|
||||
SupervisorAgent supervisor = AgenticServices.supervisorBuilder()
|
||||
.chatModel(plannerModel)
|
||||
.subAgents(sqlAgent, chartGenerationAgent)
|
||||
.responseStrategy(SupervisorResponseStrategy.LAST)
|
||||
.build();
|
||||
|
||||
String invoke = supervisor.invoke(chatRequest.getContent());
|
||||
contextMessages.add(AiMessage.from(invoke));
|
||||
} catch (Exception e) {
|
||||
log.error("深度思考模式执行失败: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("深度思考模式执行失败,请检查 MCP 服务是否正确配置: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.hzhub.common.chat.domain.dto.request.ChatRequest;
|
||||
import org.hzhub.common.chat.domain.vo.chat.ChatModelVo;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* OllamaAI服务调用
|
||||
*
|
||||
@@ -24,6 +26,7 @@ public class OllamaServiceImpl implements AbstractChatService {
|
||||
return OllamaStreamingChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(Duration.ofMinutes(3)) // 设置 3 分钟超时,适应本地模型较长的响应时间
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,11 @@ public class MilvusVectorStoreStrategy extends AbstractVectorStoreStrategy {
|
||||
*/
|
||||
private int getModelDimension(String modelName) {
|
||||
ChatModelVo modelConfig = chatModelService.selectModelByName(modelName);
|
||||
return modelConfig.getModelDimension();
|
||||
Integer dimension = modelConfig.getModelDimension();
|
||||
if (dimension == null) {
|
||||
throw new IllegalArgumentException("模型 " + modelName + " 的维度配置为空,请在数据库中配置正确的 model_dimension 值");
|
||||
}
|
||||
return dimension;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -100,25 +100,73 @@ public class WeaviateVectorStoreStrategy extends AbstractVectorStoreStrategy {
|
||||
String docId = storeEmbeddingBo.getDocId();
|
||||
log.info("向量存储条数记录: {}", chunkList.size());
|
||||
long startTime = System.currentTimeMillis();
|
||||
int successCount = 0;
|
||||
int skipCount = 0;
|
||||
|
||||
for (int i = 0; i < chunkList.size(); i++) {
|
||||
String text = chunkList.get(i);
|
||||
String fid = fidList.get(i);
|
||||
Embedding embedding = embeddingModel.embed(text).content();
|
||||
Map<String, Object> properties = Map.of(
|
||||
"text", text,
|
||||
"fid", fid,
|
||||
"kid", kid,
|
||||
"docId", docId
|
||||
);
|
||||
Float[] vector = toObjectArray(embedding.vector());
|
||||
client.data().creator()
|
||||
.withClassName("LocalKnowledge" + kid)
|
||||
.withProperties(properties)
|
||||
.withVector(vector)
|
||||
.run();
|
||||
|
||||
// 跳过空文本或仅包含空白字符的文本
|
||||
if (text == null || text.trim().isEmpty()) {
|
||||
log.warn("跳过空文本块,索引: {}, fid: {}", i, fid);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 文本预处理:移除控制字符和特殊字符
|
||||
text = text.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]", "")
|
||||
.replaceAll("\\s+", " ")
|
||||
.trim();
|
||||
|
||||
if (text.isEmpty()) {
|
||||
log.warn("文本预处理后为空,跳过,索引: {}, fid: {}", i, fid);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Embedding embedding = embeddingModel.embed(text).content();
|
||||
|
||||
// 检查 embedding 是否包含 NaN
|
||||
float[] vectorData = embedding.vector();
|
||||
boolean hasNaN = false;
|
||||
for (float v : vectorData) {
|
||||
if (Float.isNaN(v) || Float.isInfinite(v)) {
|
||||
hasNaN = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNaN) {
|
||||
log.error("Embedding 向量包含 NaN 或 Infinite 值,跳过该文本块,索引: {}, 文本长度: {}", i, text.length());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Object> properties = Map.of(
|
||||
"text", text,
|
||||
"fid", fid,
|
||||
"kid", kid,
|
||||
"docId", docId
|
||||
);
|
||||
Float[] vector = toObjectArray(embedding.vector());
|
||||
client.data().creator()
|
||||
.withClassName("LocalKnowledge" + kid)
|
||||
.withProperties(properties)
|
||||
.withVector(vector)
|
||||
.run();
|
||||
successCount++;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理文本块失败,索引: {}, fid: {}, 错误: {}", i, fid, e.getMessage());
|
||||
skipCount++;
|
||||
// 继续处理下一个文本块,不中断整个流程
|
||||
}
|
||||
}
|
||||
|
||||
long endTime = System.currentTimeMillis();
|
||||
log.info("向量存储完成消耗时间:" + (endTime - startTime) / 1000 + "秒");
|
||||
log.info("向量存储完成,成功: {}, 跳过: {}, 消耗时间: {}秒", successCount, skipCount, (endTime - startTime) / 1000);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user