项目初始化
This commit is contained in:
25
modules/ai/ai-cms/bin/deploy.bat
Normal file
25
modules/ai/ai-cms/bin/deploy.bat
Normal file
@@ -0,0 +1,25 @@
|
||||
@echo off
|
||||
rem /**
|
||||
rem * Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
rem * No deletion without permission, or be held responsible to law.
|
||||
rem *
|
||||
rem * Author: ThinkGem@163.com
|
||||
rem */
|
||||
echo.
|
||||
echo [<5B><>Ϣ] <20><><EFBFBD>̵<F0B9A4B3>Maven<65><6E><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
echo.
|
||||
|
||||
%~d0
|
||||
cd %~dp0
|
||||
|
||||
if defined JAVA_HOME17 (
|
||||
set "JAVA_HOME=%JAVA_HOME17%" & set "PATH=%JAVA_HOME17%\bin;%PATH%"
|
||||
)
|
||||
call mvn -v
|
||||
echo.
|
||||
|
||||
cd ..
|
||||
call mvn clean deploy -Dmaven.test.skip=true -Pdeploy
|
||||
|
||||
cd bin
|
||||
pause
|
||||
21
modules/ai/ai-cms/bin/deploy.sh
Normal file
21
modules/ai/ai-cms/bin/deploy.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# /**
|
||||
# * Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
# * No deletion without permission, or be held responsible to law.
|
||||
# *
|
||||
# * Author: ThinkGem@163.com
|
||||
# */
|
||||
echo ""
|
||||
echo "[信息] 部署工程到Maven服务器。"
|
||||
echo ""
|
||||
|
||||
if [ -n "$JAVA_HOME17" ] && [ -d "$JAVA_HOME17" ]; then
|
||||
export JAVA_HOME="$JAVA_HOME17" PATH="$JAVA_HOME17/bin:$PATH"
|
||||
fi
|
||||
mvn -v
|
||||
echo ""
|
||||
|
||||
cd ..
|
||||
mvn clean deploy -Dmaven.test.skip=true -Pdeploy
|
||||
|
||||
cd bin
|
||||
25
modules/ai/ai-cms/bin/package.bat
Normal file
25
modules/ai/ai-cms/bin/package.bat
Normal file
@@ -0,0 +1,25 @@
|
||||
@echo off
|
||||
rem /**
|
||||
rem * Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
rem * No deletion without permission, or be held responsible to law.
|
||||
rem *
|
||||
rem * Author: ThinkGem@163.com
|
||||
rem */
|
||||
echo.
|
||||
echo [<5B><>Ϣ] <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>װ<EFBFBD><D7B0><EFBFBD>̣<EFBFBD><CCA3><EFBFBD><EFBFBD><EFBFBD>jar<61><72><EFBFBD>ļ<EFBFBD><C4BC><EFBFBD>
|
||||
echo.
|
||||
|
||||
%~d0
|
||||
cd %~dp0
|
||||
|
||||
if defined JAVA_HOME17 (
|
||||
set "JAVA_HOME=%JAVA_HOME17%" & set "PATH=%JAVA_HOME17%\bin;%PATH%"
|
||||
)
|
||||
call mvn -v
|
||||
echo.
|
||||
|
||||
cd ..
|
||||
call mvn clean install -Dmaven.test.skip=true -Ppackage
|
||||
|
||||
cd bin
|
||||
pause
|
||||
21
modules/ai/ai-cms/bin/package.sh
Normal file
21
modules/ai/ai-cms/bin/package.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# /**
|
||||
# * Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
# * No deletion without permission, or be held responsible to law.
|
||||
# *
|
||||
# * Author: ThinkGem@163.com
|
||||
# */
|
||||
echo ""
|
||||
echo "[信息] 打包安装工程,生成jar包文件。"
|
||||
echo ""
|
||||
|
||||
if [ -n "$JAVA_HOME17" ] && [ -d "$JAVA_HOME17" ]; then
|
||||
export JAVA_HOME="$JAVA_HOME17" PATH="$JAVA_HOME17/bin:$PATH"
|
||||
fi
|
||||
mvn -v
|
||||
echo ""
|
||||
|
||||
cd ..
|
||||
mvn clean install -Dmaven.test.skip=true -Ppackage
|
||||
|
||||
cd bin
|
||||
3590
modules/ai/ai-cms/db/ai-cms.erm
Normal file
3590
modules/ai/ai-cms/db/ai-cms.erm
Normal file
File diff suppressed because it is too large
Load Diff
151
modules/ai/ai-cms/pom.xml
Normal file
151
modules/ai/ai-cms/pom.xml
Normal file
@@ -0,0 +1,151 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.jeesite</groupId>
|
||||
<artifactId>jeesite-module-ai-parent</artifactId>
|
||||
<version>5.15.1.springboot3-SNAPSHOT</version>
|
||||
<relativePath>../ai-parent/pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>jeesite-module-ai-cms</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>JeeSite Module AI + CMS + RAG</name>
|
||||
<url>https://jeesite.com</url>
|
||||
<inceptionYear>2013-Now</inceptionYear>
|
||||
|
||||
<properties>
|
||||
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<!-- 核心模块 --><!--suppress VulnerableLibrariesLocal -->
|
||||
<dependency>
|
||||
<groupId>com.jeesite</groupId>
|
||||
<artifactId>jeesite-module-core</artifactId>
|
||||
<version>${project.parent.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 内容管理模块 -->
|
||||
<dependency>
|
||||
<groupId>com.jeesite</groupId>
|
||||
<artifactId>jeesite-module-cms</artifactId>
|
||||
<version>${project.parent.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 云端模型 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 本地大模型 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-ollama</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 向量数据库 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-advisors-vector-store</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Chroma 向量数据库 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-vector-store-chroma</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- PG 向量数据库 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ES 向量数据库 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-vector-store-elasticsearch</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Milvus 向量数据库 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-vector-store-milvus</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<artifactId>slf4j-reload4j</artifactId>
|
||||
<groupId>org.slf4j</groupId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-resolver-dns-native-macos</artifactId>
|
||||
<classifier>osx-aarch_64</classifier>
|
||||
</dependency>
|
||||
|
||||
<!-- HTML 转 Markdown -->
|
||||
<dependency>
|
||||
<groupId>com.vladsch.flexmark</groupId>
|
||||
<artifactId>flexmark-html2md-converter</artifactId>
|
||||
<version>0.64.8</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Office、zip 等文件内容解析 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-core</artifactId>
|
||||
<version>3.2.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-parsers-standard-package</artifactId>
|
||||
<version>3.2.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
<version>1.27.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Ai Tools -->
|
||||
<dependency>
|
||||
<groupId>com.jeesite</groupId>
|
||||
<artifactId>jeesite-module-ai-tools</artifactId>
|
||||
<version>${project.parent.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MCP Client -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-mcp-client</artifactId>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<id>thinkgem</id>
|
||||
<name>WangZhen</name>
|
||||
<email>thinkgem at 163.com</email>
|
||||
<roles><role>Project lead</role></roles>
|
||||
<timezone>+8</timezone>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<organization>
|
||||
<name>JeeSite</name>
|
||||
<url>https://jeesite.com</url>
|
||||
</organization>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
*/
|
||||
package com.jeesite.modules.ai.cms.config;
|
||||
|
||||
import com.jeesite.common.datasource.DataSourceHolder;
|
||||
import com.jeesite.common.lang.StringUtils;
|
||||
import com.jeesite.common.utils.SpringUtils;
|
||||
import com.jeesite.modules.ai.cms.properties.AiCmsProperties;
|
||||
import com.jeesite.modules.ai.cms.service.CacheChatMemoryRepository;
|
||||
import com.jeesite.modules.ai.tools.annotation.AiTools;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 聊天配置类
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(AiCmsProperties.class)
|
||||
public class AiCmsChatConfig {
|
||||
|
||||
/**
|
||||
* 聊天对话客户端(使用本地 Tools)
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Bean("chatClient")
|
||||
@ConditionalOnProperty(name = "spring.ai.mcp.client.enabled", havingValue = "false", matchIfMissing = true)
|
||||
public ChatClient chatClient(ChatClient.Builder builder, AiCmsProperties properties) {
|
||||
if (StringUtils.isNotBlank(properties.getDefaultSystem())) {
|
||||
builder.defaultSystem(properties.getDefaultSystem());
|
||||
}
|
||||
if (properties.getTools().getEnabled()) {
|
||||
Map<String, Object> tools = SpringUtils.getApplicationContext().getBeansWithAnnotation(AiTools.class);
|
||||
if (!tools.isEmpty()) {
|
||||
builder.defaultTools(tools.values().toArray());
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天对话数据存储
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Bean
|
||||
public ChatMemory chatMemory(CacheChatMemoryRepository cacheChatMemoryRepository) {
|
||||
return MessageWindowChatMemory.builder()
|
||||
.chatMemoryRepository(cacheChatMemoryRepository)
|
||||
.maxMessages(1024)
|
||||
.build();
|
||||
}
|
||||
|
||||
// @Bean
|
||||
// public BatchingStrategy batchingStrategy() {
|
||||
// return new TokenCountBatchingStrategy(EncodingType.CL100K_BASE, Integer.MAX_VALUE, 0.1);
|
||||
// }
|
||||
|
||||
/**
|
||||
* PG向量库数据源
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
@ConditionalOnProperty(name = "jdbc.ds_pgvector.type")
|
||||
public JdbcTemplate pgVectorStoreJdbcTemplate() {
|
||||
return DataSourceHolder.getRoutingDataSource()
|
||||
.getJdbcTemplate("ds_pgvector");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
*/
|
||||
package com.jeesite.modules.ai.cms.config;
|
||||
|
||||
import com.jeesite.common.config.Global;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* MVC 异步任务池定义
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Configuration
|
||||
public class AiCmsWebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
|
||||
configurer.setTaskExecutor(webMvcAsyncTaskExecutor());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ThreadPoolTaskExecutor webMvcAsyncTaskExecutor() {
|
||||
ThreadPoolTaskExecutor bean = new ThreadPoolTaskExecutor();
|
||||
bean.setCorePoolSize(Global.getPropertyToInteger("web.taskPool.corePoolSize", "8"));
|
||||
bean.setMaxPoolSize(Global.getPropertyToInteger("web.taskPool.maxPoolSize", "20"));
|
||||
bean.setKeepAliveSeconds(Global.getPropertyToInteger("web.taskPool.keepAliveSeconds", "60"));
|
||||
bean.setQueueCapacity(Global.getPropertyToInteger("web.taskPool.queueCapacity", String.valueOf(Integer.MAX_VALUE)));
|
||||
bean.setThreadNamePrefix("web-async-");
|
||||
return bean;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
*/
|
||||
package com.jeesite.modules.ai.cms.config;
|
||||
|
||||
import com.jeesite.common.collect.MapUtils;
|
||||
import com.jeesite.common.config.Global;
|
||||
import com.jeesite.common.lang.StringUtils;
|
||||
import com.jeesite.modules.ai.cms.properties.AiCmsProperties;
|
||||
import com.jeesite.modules.ai.tools.utils.SubjectHolder;
|
||||
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import org.apache.shiro.subject.Subject;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
|
||||
import org.springframework.ai.mcp.customizer.McpSyncClientCustomizer;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI MCP 配置类
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(AiCmsProperties.class)
|
||||
public class AiMcpClientConfig {
|
||||
|
||||
|
||||
/**
|
||||
* 聊天对话客户端(使用 MCP Tools)
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Bean("chatClient")
|
||||
@ConditionalOnProperty(name = "spring.ai.mcp.client.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public ChatClient mcpChatClient(ChatClient.Builder builder, AiCmsProperties properties,
|
||||
SyncMcpToolCallbackProvider syncMcpToolCallbackProvider) {
|
||||
if (StringUtils.isNotBlank(properties.getDefaultSystem())) {
|
||||
builder.defaultSystem(properties.getDefaultSystem());
|
||||
}
|
||||
builder.defaultToolCallbacks(syncMcpToolCallbackProvider.getToolCallbacks());
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "spring.ai.mcp.client.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public McpSyncClientCustomizer mcpSyncClientCustomizer() {
|
||||
return (name, syncSpec) -> syncSpec.transportContextProvider(() -> {
|
||||
Map<String, Object> data = MapUtils.newHashMap();
|
||||
Subject subject = SubjectHolder.getSubject();
|
||||
if (subject != null) {
|
||||
data.put("sessionId", subject.getSession().getId());
|
||||
}
|
||||
return McpTransportContext.create(data);
|
||||
});
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "spring.ai.mcp.client.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public McpSyncHttpClientRequestCustomizer mcpSyncHttpClientRequestCustomizer() {
|
||||
return (builder, method, endpoint, body, context) -> {
|
||||
String sessionId = (String) context.get("sessionId");
|
||||
if (StringUtils.isNotBlank(sessionId)) {
|
||||
builder.header(Global.getProperty("session.sessionIdHeaderName", "x-token"), sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
*/
|
||||
package com.jeesite.modules.ai.cms.config;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import org.springaicommunity.mcp.annotation.McpElicitation;
|
||||
import org.springaicommunity.mcp.annotation.McpLogging;
|
||||
import org.springaicommunity.mcp.annotation.McpSampling;
|
||||
import org.springframework.ai.mcp.annotation.spring.ClientMcpSyncHandlersRegistry;
|
||||
import org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerProperties;
|
||||
import org.springframework.ai.mcp.client.common.autoconfigure.properties.McpClientCommonProperties;
|
||||
import org.springframework.aop.framework.autoproxy.AutoProxyUtils;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* ClientMcp*HandlersRegistry: support beans with unresolvable types #4918
|
||||
* (To fix the issues in Spring AI 1.1.0, they need to be removed in 1.1.1.)
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnClass(McpLogging.class)
|
||||
@ConditionalOnProperty(prefix = McpClientAnnotationScannerProperties.CONFIG_PREFIX, name = "enabled",
|
||||
havingValue = "true", matchIfMissing = true)
|
||||
public class AiMcpClientFixConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC",
|
||||
matchIfMissing = true)
|
||||
public ClientMcpSyncHandlersRegistry clientMcpSyncHandlersRegistry() {
|
||||
return new ClientMcpSyncHandlersRegistry() {
|
||||
@Override
|
||||
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
|
||||
this.beanFactory = beanFactory;
|
||||
Map<String, List<String>> elicitationClientToAnnotatedBeans = new HashMap<>();
|
||||
Map<String, List<String>> samplingClientToAnnotatedBeans = new HashMap<>();
|
||||
for (var beanName : beanFactory.getBeanDefinitionNames()) {
|
||||
if (!beanFactory.getBeanDefinition(beanName).isSingleton()) {
|
||||
// Only process singleton beans, not scoped beans
|
||||
continue;
|
||||
}
|
||||
|
||||
// ClientMcp*HandlersRegistry: support beans with unresolvable types #4918
|
||||
Class<?> beanClass = AutoProxyUtils.determineTargetClass(beanFactory, beanName);
|
||||
if (beanClass == null) {
|
||||
// If we cannot determine the bean class, we cannot scan it before
|
||||
// it is really resolved. This is very likely an infrastructure-level
|
||||
// bean, not a "service" type, skip it entirely.
|
||||
continue;
|
||||
}
|
||||
var foundAnnotations = this.scan(beanClass);
|
||||
// #4918 end
|
||||
|
||||
if (!foundAnnotations.isEmpty()) {
|
||||
this.allAnnotatedBeans.add(beanName);
|
||||
}
|
||||
for (var foundAnnotation : foundAnnotations) {
|
||||
if (foundAnnotation instanceof McpSampling sampling) {
|
||||
for (var client : sampling.clients()) {
|
||||
samplingClientToAnnotatedBeans.computeIfAbsent(client, c -> new ArrayList<>()).add(beanName);
|
||||
}
|
||||
}
|
||||
else if (foundAnnotation instanceof McpElicitation elicitation) {
|
||||
for (var client : elicitation.clients()) {
|
||||
elicitationClientToAnnotatedBeans.computeIfAbsent(client, c -> new ArrayList<>()).add(beanName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var elicitationEntry : elicitationClientToAnnotatedBeans.entrySet()) {
|
||||
if (elicitationEntry.getValue().size() > 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"Found 2 elicitation handlers for client [%s], found in bean with names %s. Only one @McpElicitation handler is allowed per client"
|
||||
.formatted(elicitationEntry.getKey(), new LinkedHashSet<>(elicitationEntry.getValue())));
|
||||
}
|
||||
}
|
||||
for (var samplingEntry : samplingClientToAnnotatedBeans.entrySet()) {
|
||||
if (samplingEntry.getValue().size() > 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"Found 2 sampling handlers for client [%s], found in bean with names %s. Only one @McpSampling handler is allowed per client"
|
||||
.formatted(samplingEntry.getKey(), new LinkedHashSet<>(samplingEntry.getValue())));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, McpSchema.ClientCapabilities.Builder> capsPerClient = new HashMap<>();
|
||||
for (var samplingClient : samplingClientToAnnotatedBeans.keySet()) {
|
||||
capsPerClient.computeIfAbsent(samplingClient, ignored -> McpSchema.ClientCapabilities.builder()).sampling();
|
||||
}
|
||||
for (var elicitationClient : elicitationClientToAnnotatedBeans.keySet()) {
|
||||
capsPerClient.computeIfAbsent(elicitationClient, ignored -> McpSchema.ClientCapabilities.builder())
|
||||
.elicitation();
|
||||
}
|
||||
|
||||
this.capabilitiesPerClient = capsPerClient.entrySet()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().build()));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
*/
|
||||
package com.jeesite.modules.ai.cms.config;
|
||||
|
||||
import com.jeesite.common.lang.StringUtils;
|
||||
import com.jeesite.common.mapper.JsonMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 推理模型OpenAI兼容处理
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Configuration
|
||||
public class WebClientThinkConfig {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(WebClientThinkConfig.class);
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@SuppressWarnings("unchecked")
|
||||
public WebClientCustomizer webClientCustomizerThink() {
|
||||
return webClientBuilder -> {
|
||||
ExchangeFilterFunction requestFilter = ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
|
||||
logger.trace("Request url: {}: {}", clientRequest.method(), clientRequest.url());
|
||||
return Mono.just(clientRequest);
|
||||
});
|
||||
ExchangeFilterFunction responseFilter = ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
|
||||
logger.trace("Response status: {}", clientResponse.statusCode());
|
||||
AtomicBoolean thinkingFlag = new AtomicBoolean(false);
|
||||
Flux<DataBuffer> modifiedBody = clientResponse.bodyToFlux(DataBuffer.class)
|
||||
.map(buf -> {
|
||||
byte[] bytes = new byte[buf.readableByteCount()];
|
||||
buf.read(bytes);
|
||||
DataBufferUtils.release(buf);
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
})
|
||||
.flatMap(eventString -> {
|
||||
logger.trace("Original response: ==> {}", eventString);
|
||||
List<String> lines = new ArrayList<>();
|
||||
String[] list = eventString.split("\\n", -1);
|
||||
for (String line : list) {
|
||||
String jsonPart = line;
|
||||
boolean dataPrefix = false;
|
||||
if (line.startsWith("data: ")) {
|
||||
jsonPart = line.substring("data: ".length()).trim();
|
||||
dataPrefix = true;
|
||||
}
|
||||
if (!(StringUtils.startsWith(jsonPart, "{")
|
||||
&& StringUtils.endsWith(jsonPart, "}")
|
||||
&& !"data: [DONE]".equals(line))) {
|
||||
lines.add(line);
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> map = JsonMapper.fromJson(jsonPart, Map.class);
|
||||
if (map == null) {
|
||||
lines.add(line);
|
||||
continue;
|
||||
}
|
||||
// 修改内容字段
|
||||
boolean ollamaEvent = false;
|
||||
List<Object> choices = (List<Object>) map.get("choices");
|
||||
if (choices == null) {
|
||||
Map<String, Object> message = (Map<String, Object>) map.get("message");
|
||||
if (message == null) {
|
||||
lines.add(line);
|
||||
continue;
|
||||
}
|
||||
choices = List.of(message);
|
||||
ollamaEvent = true;
|
||||
}
|
||||
for (Object o : choices) {
|
||||
Map<String, Object> choice = (Map<String, Object>) o;
|
||||
if (choice == null) {
|
||||
continue;
|
||||
}
|
||||
String content;
|
||||
String reasoningContent;
|
||||
Map<String, Object> delta = (Map<String, Object>) choice.get("delta");
|
||||
if (delta != null) {
|
||||
content = (String) delta.get("content");
|
||||
reasoningContent = (String) delta.get("reasoning_content");
|
||||
} else {
|
||||
content = (String) choice.get("content");
|
||||
reasoningContent = (String) choice.get("thinking");
|
||||
}
|
||||
if (StringUtils.isNotEmpty(reasoningContent) && StringUtils.isEmpty(content)) {
|
||||
if (!thinkingFlag.get()) {
|
||||
thinkingFlag.set(true);
|
||||
content = "<think>\n" + reasoningContent;
|
||||
} else {
|
||||
content = reasoningContent;
|
||||
}
|
||||
} else {
|
||||
if (thinkingFlag.get()) {
|
||||
thinkingFlag.set(false);
|
||||
content = "</think>\n" + (content == null ? "" : content);
|
||||
}
|
||||
}
|
||||
if (ollamaEvent) {
|
||||
choice.put("content", content);
|
||||
map.put("message", choice);
|
||||
} else if (delta != null) {
|
||||
delta.put("content", content);
|
||||
}
|
||||
}
|
||||
// 重新生成事件字符串
|
||||
lines.add((dataPrefix ? "data: " : "") + JsonMapper.toJson(map));
|
||||
}
|
||||
String finalLine = StringUtils.join(lines, "\n");
|
||||
logger.trace("Modified response: ==> {}", finalLine);
|
||||
return Mono.just(finalLine);
|
||||
})
|
||||
.map(str -> {
|
||||
byte[] bytes = str.getBytes(StandardCharsets.UTF_8);
|
||||
return new DefaultDataBufferFactory().wrap(bytes);
|
||||
});
|
||||
ClientResponse modifiedResponse = ClientResponse.from(clientResponse)
|
||||
.headers(headers -> headers.remove(HttpHeaders.CONTENT_LENGTH))
|
||||
.body(modifiedBody)
|
||||
.build();
|
||||
return Mono.just(modifiedResponse);
|
||||
});
|
||||
webClientBuilder.filter(requestFilter).filter(responseFilter);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.jeesite.modules.ai.cms.properties;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.NestedConfigurationProperty;
|
||||
|
||||
@ConfigurationProperties("spring.ai")
|
||||
public class AiCmsProperties {
|
||||
|
||||
/**
|
||||
* 向量数据库设置
|
||||
*/
|
||||
@NestedConfigurationProperty
|
||||
private final Vectorstore vectorstore = new Vectorstore();
|
||||
|
||||
/**
|
||||
* 是否启用 Tool calling 工具调用【例子详见 TestAiTools.java、UserAiTools.java 】
|
||||
*/
|
||||
@NestedConfigurationProperty
|
||||
private final Tools tools = new Tools();
|
||||
|
||||
/**
|
||||
* 默认系统提示词
|
||||
*/
|
||||
private String defaultSystem = "";
|
||||
|
||||
/**
|
||||
* 默认问题模板格式
|
||||
*/
|
||||
private String defaultPromptTemplate = "";
|
||||
|
||||
public Vectorstore getVectorstore() {
|
||||
return vectorstore;
|
||||
}
|
||||
|
||||
public Tools getTools() {
|
||||
return tools;
|
||||
}
|
||||
|
||||
public String getDefaultSystem() {
|
||||
return defaultSystem;
|
||||
}
|
||||
|
||||
public void setDefaultSystem(String defaultSystem) {
|
||||
this.defaultSystem = defaultSystem;
|
||||
}
|
||||
|
||||
public String getDefaultPromptTemplate() {
|
||||
return defaultPromptTemplate;
|
||||
}
|
||||
|
||||
public void setDefaultPromptTemplate(String defaultPromptTemplate) {
|
||||
this.defaultPromptTemplate = defaultPromptTemplate;
|
||||
}
|
||||
|
||||
public static class Vectorstore {
|
||||
|
||||
/**
|
||||
* 向量库类型选择:chroma、pgvector、elasticsearch、milvus
|
||||
*/
|
||||
private String type;
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Tools {
|
||||
|
||||
/**
|
||||
* 是否启用 Tool calling 工具调用【例子详见 TestAiTools.java、UserAiTools.java 】
|
||||
*/
|
||||
private Boolean enabled = false;
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
*/
|
||||
package com.jeesite.modules.ai.cms.service;
|
||||
|
||||
import com.jeesite.common.cache.CacheUtils;
|
||||
import com.jeesite.common.collect.ListUtils;
|
||||
import com.jeesite.common.collect.MapUtils;
|
||||
import com.jeesite.common.config.Global;
|
||||
import com.jeesite.common.idgen.IdGen;
|
||||
import com.jeesite.common.lang.DateUtils;
|
||||
import com.jeesite.common.lang.StringUtils;
|
||||
import com.jeesite.common.mapper.JsonMapper;
|
||||
import com.jeesite.common.service.BaseService;
|
||||
import com.jeesite.modules.ai.cms.properties.AiCmsProperties;
|
||||
import com.jeesite.modules.ai.tools.utils.SubjectHolder;
|
||||
import com.jeesite.modules.sys.entity.Area;
|
||||
import com.jeesite.modules.sys.utils.AreaUtils;
|
||||
import com.jeesite.modules.sys.utils.UserUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.apache.shiro.util.ThreadContext;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
|
||||
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.prompt.PromptTemplate;
|
||||
import org.springframework.ai.content.Media;
|
||||
import org.springframework.ai.converter.AbstractMessageOutputConverter;
|
||||
import org.springframework.ai.converter.BeanOutputConverter;
|
||||
import org.springframework.ai.converter.MapOutputConverter;
|
||||
import org.springframework.ai.vectorstore.SearchRequest;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.SignalType;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 聊天服务类
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Service
|
||||
public class AiCmsChatService extends BaseService {
|
||||
|
||||
private static final String CMS_CHAT_CACHE = "cmsChatCache";
|
||||
private static final String[] USER_MESSAGE_SEARCH = new String[]{"{", "}"};
|
||||
private static final String[] USER_MESSAGE_REPLACE = new String[]{"\\{", "\\}"};
|
||||
|
||||
private final ChatClient chatClient;
|
||||
private final ChatMemory chatMemory;
|
||||
private final VectorStore vectorStore;
|
||||
private final AiCmsProperties properties;
|
||||
|
||||
public AiCmsChatService(ChatClient chatClient,
|
||||
ChatMemory chatMemory,
|
||||
ObjectProvider<VectorStore> vectorStore,
|
||||
AiCmsProperties properties) {
|
||||
this.chatClient = chatClient;
|
||||
this.chatMemory = chatMemory;
|
||||
this.vectorStore = vectorStore.getIfAvailable();
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天对话消息
|
||||
* @author ThinkGem
|
||||
*/
|
||||
public List<Message> getChatMessage(String conversationId) {
|
||||
if (StringUtils.isBlank(conversationId)) {
|
||||
return List.of();
|
||||
}
|
||||
return chatMemory.get(conversationId);
|
||||
}
|
||||
|
||||
private static String getChatCacheKey() {
|
||||
String key = UserUtils.getUser().getId();
|
||||
if (StringUtils.isBlank(key)) {
|
||||
key = UserUtils.getSession().getId().toString();
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
public Map<String, Map<String, Object>> getChatCacheMap() {
|
||||
return CacheUtils.computeIfAbsent(CMS_CHAT_CACHE, getChatCacheKey(), k -> MapUtils.newHashMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建或更新聊天对话
|
||||
* @author ThinkGem
|
||||
*/
|
||||
public Map<String, Object> saveChatConversation(String conversationId, String title) {
|
||||
if (StringUtils.isBlank(conversationId)) {
|
||||
conversationId = IdGen.nextId();
|
||||
}
|
||||
if (StringUtils.isBlank(title)) {
|
||||
title = "新对话 " + DateUtils.getTime();
|
||||
}
|
||||
Map<String, Object> map = MapUtils.newHashMap();
|
||||
map.put("id", conversationId);
|
||||
map.put("title", title);
|
||||
Map<String, Map<String, Object>> cache = getChatCacheMap();
|
||||
cache.put(conversationId, map);
|
||||
CacheUtils.put(CMS_CHAT_CACHE, getChatCacheKey(), cache);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除聊天对话
|
||||
* @author ThinkGem
|
||||
*/
|
||||
public void deleteChatConversation(String conversationId) {
|
||||
Map<String, Map<String, Object>> cache = getChatCacheMap();
|
||||
cache.remove(conversationId);
|
||||
CacheUtils.put(CMS_CHAT_CACHE, getChatCacheKey(), cache);
|
||||
chatMemory.clear(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天对话,流输出
|
||||
* @author ThinkGem
|
||||
*/
|
||||
public Flux<ChatResponse> chatStream(String conversationId, String message, HttpServletRequest request) {
|
||||
String text = StringUtils.replaceEach(message, USER_MESSAGE_SEARCH, USER_MESSAGE_REPLACE);
|
||||
List<Media> media = ListUtils.newArrayList();
|
||||
// List<FileUpload> fileUploadList = FileUploadUtils.findFileUpload(conversationId, "cms-chat");
|
||||
// for (FileUpload fileUpload : fileUploadList) {
|
||||
// File file = new File(fileUpload.getFileEntity().getFileRealPath());
|
||||
// MediaType mediaType = MediaType.parseMediaType(FileUtils.getContentType(file.getName()));
|
||||
// media.add(Media.builder().mimeType(mediaType).data(file).build());
|
||||
// }
|
||||
UserMessage userMessage = UserMessage.builder().text(text).media(media).build();
|
||||
ChatClient.ChatClientRequestSpec spec = chatClient.prompt().messages(userMessage)
|
||||
.advisors(MessageChatMemoryAdvisor.builder(chatMemory)
|
||||
.conversationId(conversationId)
|
||||
.build());
|
||||
if (vectorStore != null) {
|
||||
spec.advisors(QuestionAnswerAdvisor.builder(vectorStore)
|
||||
.searchRequest(SearchRequest.builder().similarityThreshold(0.6F).topK(6).build())
|
||||
.promptTemplate(new PromptTemplate(properties.getDefaultPromptTemplate()))
|
||||
.build());
|
||||
}
|
||||
// if (Global.getPropertyToBoolean("spring.ai.mcp.client.enabled", "false")) {
|
||||
// AiMcpClientConfig.subject.set(ThreadContext.getSubject());
|
||||
// } else {
|
||||
// spec.toolContext(Map.of("subject", ThreadContext.getSubject()));
|
||||
// }
|
||||
SubjectHolder.setSubject(ThreadContext.getSubject());
|
||||
return spec.stream()
|
||||
.chatResponse()
|
||||
.doOnNext(response -> {
|
||||
if (StringUtils.isNotBlank(response.getResult().getOutput().getText())) {
|
||||
AssistantMessage assistantMessage = (AssistantMessage)request.getAttribute("assistantMessage");
|
||||
AssistantMessage currAssistantMessage = response.getResult().getOutput();
|
||||
if (assistantMessage == null) {
|
||||
request.setAttribute("assistantMessage", currAssistantMessage);
|
||||
} else {
|
||||
request.setAttribute("assistantMessage", AssistantMessage.builder()
|
||||
.content(assistantMessage.getText() + currAssistantMessage.getText())
|
||||
.properties(currAssistantMessage.getMetadata()).build());
|
||||
}
|
||||
}
|
||||
SubjectHolder.remove();
|
||||
})
|
||||
.doFinally((signalType) -> {
|
||||
if (signalType != SignalType.ON_COMPLETE) {
|
||||
AssistantMessage assistantMessage = (AssistantMessage)request.getAttribute("assistantMessage");
|
||||
if (assistantMessage != null) {
|
||||
chatMemory.add(conversationId, assistantMessage);
|
||||
} else if (signalType == SignalType.CANCEL) {
|
||||
chatMemory.add(conversationId, new AssistantMessage(text("暂无消息,你已主动停止响应。")));
|
||||
}
|
||||
}
|
||||
SubjectHolder.remove();
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
String errorMessage = error.getMessage();
|
||||
if (Global.getPropertyToBoolean("error.page.printErrorInfo", "true")){
|
||||
if (error instanceof WebClientResponseException webClientError) {
|
||||
errorMessage = webClientError.getResponseBodyAsString();
|
||||
} else if (error.getCause() instanceof WebClientResponseException webClientError) {
|
||||
errorMessage = webClientError.getResponseBodyAsString();
|
||||
}
|
||||
}
|
||||
AssistantMessage assistantMessage = new AssistantMessage(errorMessage);
|
||||
chatMemory.add(conversationId, assistantMessage);
|
||||
logger.error("Error message: {}", errorMessage);
|
||||
SubjectHolder.remove();
|
||||
return Flux.just(ChatResponse.builder()
|
||||
.generations(List.of(new Generation(assistantMessage)))
|
||||
.build());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天对话,文本输出
|
||||
* @author ThinkGem
|
||||
*/
|
||||
public String chatText(String message) {
|
||||
return chatClient.prompt()
|
||||
.messages(
|
||||
new UserMessage(StringUtils.replaceEach(message, USER_MESSAGE_SEARCH, USER_MESSAGE_REPLACE))
|
||||
)
|
||||
.call()
|
||||
.content();
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天对话,结构化输出(Map)
|
||||
* @author ThinkGem
|
||||
*/
|
||||
public Map<String, Object> chatJson(String message) {
|
||||
return chatClient.prompt()
|
||||
.messages(
|
||||
new SystemMessage("""
|
||||
[{name:'张三', sex:'男', age:'17'}, {name:'李四', sex:'女', age:'18'}],返回 json。
|
||||
"""),
|
||||
new UserMessage(StringUtils.replaceEach(message, USER_MESSAGE_SEARCH, USER_MESSAGE_REPLACE))
|
||||
)
|
||||
.call()
|
||||
.responseEntity(
|
||||
new AbstractMessageOutputConverter<Map<String, Object>>(
|
||||
new MappingJackson2MessageConverter(JsonMapper.getInstance())
|
||||
) {
|
||||
final MapOutputConverter mapOutputConverter = new MapOutputConverter();
|
||||
@Override
|
||||
public Map<String, Object> convert(String source) {
|
||||
return mapOutputConverter.convert(source);
|
||||
}
|
||||
@Override
|
||||
public String getFormat() {
|
||||
return mapOutputConverter.getFormat();
|
||||
}
|
||||
}
|
||||
)
|
||||
.getEntity();
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天对话,结构化输出(Area)
|
||||
* @author ThinkGem
|
||||
*/
|
||||
public List<Area> chatArea(String message) {
|
||||
List<Area> list = AreaUtils.getAreaAllList();
|
||||
if (list.size() > 10) list = list.subList(0, 10);
|
||||
ChatClient.ChatClientRequestSpec spec = chatClient.prompt()
|
||||
.messages(
|
||||
new SystemMessage(JsonMapper.toJson(list)),
|
||||
new UserMessage(StringUtils.replaceEach(message, USER_MESSAGE_SEARCH, USER_MESSAGE_REPLACE))
|
||||
);
|
||||
if (vectorStore != null) {
|
||||
spec.advisors(QuestionAnswerAdvisor.builder(vectorStore)
|
||||
.searchRequest(SearchRequest.builder().similarityThreshold(0.6F).topK(6).build())
|
||||
.promptTemplate(new PromptTemplate(properties.getDefaultPromptTemplate()))
|
||||
.build());
|
||||
}
|
||||
return spec.call()
|
||||
.responseEntity(new BeanOutputConverter<>(new ParameterizedTypeReference<List<Area>>() {},
|
||||
JsonMapper.getInstance()))
|
||||
.getEntity();
|
||||
}
|
||||
|
||||
// public static void main(String[] args) {
|
||||
// String s = """
|
||||
// [{"id":"110000","isNewRecord":false,"createBy":"system","createDate":"2025-01-01T19:25:11Z","updateBy":"system","updateDate":"2025-01-01 19:25","childList":[{"id":"110100","isNewRecord":false,"createBy":"system","createDate":"2025-01-01 19:25","updateBy":"system","updateDate":"2025-01-01 19:25","childList":[{"id":"110101","isNewRecord":false,"areaCode":"110101","areaName":"东城区","areaType":"3","isRoot":true,"isTreeLeaf":false},{"id":"110102","isNewRecord":false,"areaCode":"110102","areaName":"西城区","areaType":"3","isRoot":true,"isTreeLeaf":false},{"id":"110105","isNewRecord":false,"areaCode":"110105","areaName":"朝阳区","areaType":"3","isRoot":true,"isTreeLeaf":false},{"id":"110106","isNewRecord":false,"areaCode":"110106","areaName":"丰台区","areaType":"3","isRoot":true,"isTreeLeaf":false},{"id":"110107","isNewRecord":false,"areaCode":"110107","areaName":"石景山区","areaType":"3","isRoot":true,"isTreeLeaf":false},{"id":"110108","isNewRecord":false,"areaCode":"110108","areaName":"海淀区","areaType":"3","isRoot":true,"isTreeLeaf":false},{"id":"110109","isNewRecord":false,"areaCode":"110109","areaName":"门头沟区","areaType":"3","isRoot":true,"isTreeLeaf":false},{"id":"110111","isNewRecord":false,"areaCode":"110111","areaName":"房山区","areaType":"3","isRoot":true,"isTreeLeaf":false}],"areaCode":"110100","areaName":"北京城区","areaType":"2","isRoot":true,"isTreeLeaf":false}],"areaCode":"110000","areaName":"北京市","areaType":"1","isRoot":true,"isTreeLeaf":false}]
|
||||
// """;
|
||||
// JsonMapper jsonMapper = JsonMapper.getInstance();
|
||||
// ParameterizedTypeReference<List<Area>> p = new ParameterizedTypeReference<List<Area>>() {};
|
||||
// List<Area> entity = jsonMapper.fromJsonString(s, jsonMapper.constructType(p.getType()));
|
||||
// System.out.println(entity);
|
||||
// String json = jsonMapper.toJsonString(entity);
|
||||
// System.out.println(json);
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
*/
|
||||
package com.jeesite.modules.ai.cms.service;
|
||||
|
||||
import com.jeesite.common.collect.ListUtils;
|
||||
import com.jeesite.common.collect.MapUtils;
|
||||
import com.jeesite.common.config.Global;
|
||||
import com.jeesite.common.io.IOUtils;
|
||||
import com.jeesite.common.lang.StringUtils;
|
||||
import com.jeesite.common.lang.TimeUtils;
|
||||
import com.jeesite.common.utils.PageUtils;
|
||||
import com.jeesite.common.utils.SpringUtils;
|
||||
import com.jeesite.common.web.http.HttpClientUtils;
|
||||
import com.jeesite.common.web.http.ServletUtils;
|
||||
import com.jeesite.modules.cms.entity.Article;
|
||||
import com.jeesite.modules.cms.service.ArticleService;
|
||||
import com.jeesite.modules.cms.service.extend.ArticleVectorStore;
|
||||
import com.vladsch.flexmark.html.renderer.LinkType;
|
||||
import com.vladsch.flexmark.html.renderer.ResolvedLink;
|
||||
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter;
|
||||
import com.vladsch.flexmark.html2md.converter.HtmlLinkResolver;
|
||||
import com.vladsch.flexmark.html2md.converter.HtmlLinkResolverFactory;
|
||||
import com.vladsch.flexmark.html2md.converter.HtmlNodeConverterContext;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.apache.tika.Tika;
|
||||
import org.apache.tika.config.TikaConfig;
|
||||
import org.apache.tika.exception.TikaException;
|
||||
import org.apache.tika.io.TikaInputStream;
|
||||
import org.apache.tika.metadata.Metadata;
|
||||
import org.apache.tika.mime.MediaType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* CMS 文章向量库存储
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Service
|
||||
public class ArticleVectorStoreImpl implements ArticleVectorStore {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(getClass());
|
||||
private final VectorStore vectorStore;
|
||||
private ArticleService articleService;
|
||||
|
||||
public ArticleVectorStoreImpl(ObjectProvider<VectorStore> vectorStore) {
|
||||
this.vectorStore = vectorStore.getIfAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文章到向量库
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Override
|
||||
public void save(Article article) {
|
||||
if (vectorStore == null) return;
|
||||
Map<String, Object> metadata = MapUtils.newHashMap();
|
||||
metadata.put("id", article.getId());
|
||||
metadata.put("siteCode", article.getCategory().getSite().getSiteCode());
|
||||
metadata.put("categoryCode", article.getCategory().getCategoryCode());
|
||||
metadata.put("categoryName", article.getCategory().getCategoryName());
|
||||
metadata.put("title", article.getTitle());
|
||||
metadata.put("href", article.getHref());
|
||||
metadata.put("keywords", article.getKeywords());
|
||||
metadata.put("description", article.getDescription());
|
||||
metadata.put("url", article.getUrl());
|
||||
metadata.put("status", article.getStatus());
|
||||
metadata.put("createBy", article.getCreateBy());
|
||||
metadata.put("createDate", article.getCreateDate());
|
||||
metadata.put("updateBy", article.getUpdateBy());
|
||||
metadata.put("updateDate", article.getUpdateDate());
|
||||
List<String> attachmentList = ListUtils.newArrayList();
|
||||
String content = article.getTitle() + ", " + article.getKeywords() + ", "
|
||||
+ article.getDescription() + ", " + FlexmarkHtmlConverter.builder()
|
||||
.linkResolverFactory(getHtmlLinkResolverFactory(attachmentList)).build()
|
||||
.convert(article.getArticleData().getContent())
|
||||
+ ", attachment: " + attachmentList;
|
||||
List<Document> documents = List.of(new Document(article.getId(), content, metadata));
|
||||
List<Document> splitDocuments = new TokenTextSplitter().apply(documents);
|
||||
this.delete(article); // 删除原数据
|
||||
ListUtils.pageList(splitDocuments, 10, params -> {
|
||||
vectorStore.add((List<Document>)params[0]); // 增加新数据
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文章中的连接并提取内容
|
||||
* @author ThinkGem
|
||||
*/
|
||||
private @NotNull HtmlLinkResolverFactory getHtmlLinkResolverFactory(List<String> attachmentList) {
|
||||
HttpServletRequest request = ServletUtils.getRequest();
|
||||
return new HtmlLinkResolverFactory() {
|
||||
@Override
|
||||
public @NotNull Set<Class<?>> getAfterDependents() {
|
||||
return Set.of();
|
||||
}
|
||||
@Override
|
||||
public @NotNull Set<Class<?>> getBeforeDependents() {
|
||||
return Set.of();
|
||||
}
|
||||
@Override
|
||||
public boolean affectsGlobalScope() {
|
||||
return false;
|
||||
}
|
||||
@Override
|
||||
public HtmlLinkResolver apply(HtmlNodeConverterContext htmlNodeConverterContext) {
|
||||
return (node, context, resolvedLink) -> {
|
||||
if ("a".equalsIgnoreCase(node.nodeName())) {
|
||||
String href = node.attributes().get("href"); String url = href;
|
||||
if (StringUtils.contains(url, "://")) {
|
||||
// 只提取系统允许跳转的附件内容,外部网站内容不进行提取,shiro.allowRedirects 参数设置范围
|
||||
if (ServletUtils.isAllowRedirects(request, url)) {
|
||||
try (InputStream is = HttpClientUtils.getInputStream(url, null)) {
|
||||
if (is != null) {
|
||||
String text = getDocumentText(is);
|
||||
attachmentList.add(url + text);
|
||||
}
|
||||
} catch (IOException | TikaException e) {
|
||||
logger.error(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String ctxPath = Global.getCtxPath();
|
||||
if (StringUtils.isNotBlank(ctxPath) && StringUtils.startsWith(url, ctxPath)){
|
||||
url = url.substring(ctxPath.length());
|
||||
}
|
||||
try (InputStream is = IOUtils.getFileInputStream(Global.getUserfilesBaseDir(url))){
|
||||
if (is != null) {
|
||||
String text = getDocumentText(is);
|
||||
attachmentList.add(url + text);
|
||||
}
|
||||
} catch (IOException | TikaException e) {
|
||||
logger.error(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
return new ResolvedLink(LinkType.LINK, href);
|
||||
}
|
||||
return resolvedLink;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 获取文章附件中的内容
|
||||
* @author ThinkGem
|
||||
*/
|
||||
private static @NotNull String getDocumentText(InputStream is) throws IOException, TikaException {
|
||||
TikaConfig config = TikaConfig.getDefaultConfig();
|
||||
Tika tika = new Tika(config);
|
||||
Metadata metadata = new Metadata();
|
||||
TikaInputStream stream = TikaInputStream.get(is);
|
||||
MediaType mimetype = tika.getDetector().detect(stream, metadata);
|
||||
if (mimetype != null && StringUtils.equals(mimetype.getType(), "text")) {
|
||||
String text = IOUtils.toString(stream, StandardCharsets.UTF_8);
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
return FlexmarkHtmlConverter.builder().build().convert(text);
|
||||
} else {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
String content = tika.parseToString(stream, metadata);
|
||||
return content.lines()
|
||||
.map(String::strip).filter(line -> !line.isEmpty())
|
||||
.reduce((a, b) -> a + System.lineSeparator() + b)
|
||||
.orElse(StringUtils.EMPTY);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除向量库文章
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Override
|
||||
public void delete(Article article) {
|
||||
if (vectorStore == null) return;
|
||||
if (StringUtils.isNotBlank(article.getId())) {
|
||||
vectorStore.delete(new FilterExpressionBuilder().eq("id", article.getId()).build());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建向量库文章
|
||||
* @author ThinkGem
|
||||
*/
|
||||
public String rebuild(Article article) {
|
||||
if (vectorStore == null) return null;
|
||||
logger.debug("开始重建向量库。 siteCode: {}, categoryCode: {}",
|
||||
article.getCategory().getSite().getSiteCode(),
|
||||
article.getCategory().getCategoryCode());
|
||||
long start = System.currentTimeMillis();
|
||||
try{
|
||||
article.setIsQueryArticleData(true); // 查询文章内容
|
||||
if (articleService == null) {
|
||||
articleService = SpringUtils.getBean(ArticleService.class);
|
||||
}
|
||||
PageUtils.findList(article, null, e -> {
|
||||
List<Article> list = articleService.findList((Article) e);
|
||||
if (!list.isEmpty()) {
|
||||
list.forEach(this::save);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}catch(Exception ex){
|
||||
logger.error("重建向量库失败", ex);
|
||||
return "重建向量库失败:" + ex.getMessage();
|
||||
}
|
||||
String message = "重建向量库完成! 用时" + TimeUtils.formatTime(System.currentTimeMillis() - start) + "。";
|
||||
logger.debug(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
*/
|
||||
package com.jeesite.modules.ai.cms.service;
|
||||
|
||||
import com.jeesite.common.cache.CacheUtils;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.ai.chat.memory.ChatMemoryRepository;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 对话消息存储
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@Service
|
||||
public class CacheChatMemoryRepository implements ChatMemoryRepository {
|
||||
|
||||
private static final String CMS_CHAT_MSG_CACHE = "cmsChatMsgCache";
|
||||
|
||||
@Override
|
||||
public @NotNull List<String> findConversationIds() {
|
||||
return CacheUtils.getCache(CMS_CHAT_MSG_CACHE).keys().stream().map(Object::toString).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull List<Message> findByConversationId(@NotNull String conversationId) {
|
||||
return CacheUtils.computeIfAbsent(CMS_CHAT_MSG_CACHE, conversationId, k -> List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAll(@NotNull String conversationId, @NotNull List<Message> messages) {
|
||||
CacheUtils.put(CMS_CHAT_MSG_CACHE, conversationId, messages);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteByConversationId(@NotNull String conversationId) {
|
||||
CacheUtils.remove(CMS_CHAT_MSG_CACHE, conversationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
*/
|
||||
package com.jeesite.modules.ai.cms.web;
|
||||
|
||||
import com.jeesite.common.config.Global;
|
||||
import com.jeesite.common.web.BaseController;
|
||||
import com.jeesite.modules.ai.cms.service.AiCmsChatService;
|
||||
import com.jeesite.modules.sys.entity.Area;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AI 聊天控制器类
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("${adminPath}/cms/chat")
|
||||
public class CmsAiChatController extends BaseController {
|
||||
|
||||
private final AiCmsChatService aiCmsChatService;
|
||||
|
||||
public CmsAiChatController(AiCmsChatService aiCmsChatService) {
|
||||
this.aiCmsChatService = aiCmsChatService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天对话消息
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@RequestMapping("/message")
|
||||
public List<Message> message(String id) {
|
||||
return aiCmsChatService.getChatMessage(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天对话列表
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@RequestMapping("/list")
|
||||
public Collection<Map<String, Object>> list() {
|
||||
return aiCmsChatService.getChatCacheMap().values().stream()
|
||||
.sorted(Comparator.comparing(map -> (String) map.get("id"),
|
||||
Comparator.reverseOrder())).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建或更新聊天对话
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@RequestMapping("/save")
|
||||
public String save(String id, String title) {
|
||||
Map<String, Object> map = aiCmsChatService.saveChatConversation(id, title);
|
||||
return renderResult(Global.TRUE, "保存成功", map);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除聊天对话
|
||||
* @author ThinkGem
|
||||
*/
|
||||
@RequestMapping("/delete")
|
||||
public String delete(@RequestParam String id) {
|
||||
aiCmsChatService.deleteChatConversation(id);
|
||||
return renderResult(Global.TRUE, "删除成功", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天对话,流输出
|
||||
* @author ThinkGem
|
||||
* http://127.0.0.1:8980/js/a/cms/chat/stream?id=1&message=你好
|
||||
*/
|
||||
@RequestMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public Flux<ChatResponse> stream(@RequestParam String id, @RequestParam String message, HttpServletRequest request) {
|
||||
return aiCmsChatService.chatStream(id, message, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天对话,文本输出
|
||||
* @author ThinkGem
|
||||
* http://127.0.0.1:8980/js/a/cms/chat/text?message=你好
|
||||
*/
|
||||
@RequestMapping(value = "/text")
|
||||
public String text(@RequestParam String message) {
|
||||
return aiCmsChatService.chatText(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天对话,结构化输出 JSON
|
||||
* @author ThinkGem
|
||||
* http://127.0.0.1:8980/js/a/cms/chat/json?message=张三
|
||||
* http://127.0.0.1:8980/js/a/cms/chat/json?message=打开客厅的灯
|
||||
*/
|
||||
@RequestMapping(value = "/json")
|
||||
public Map<String, Object> json(@RequestParam String message) {
|
||||
return aiCmsChatService.chatJson(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天对话,结构化输出 Entity
|
||||
* @author ThinkGem
|
||||
* http://127.0.0.1:8980/js/a/cms/chat/entity?message=北京
|
||||
*/
|
||||
@RequestMapping(value = "/entity")
|
||||
public List<Area> entity(@RequestParam String message) {
|
||||
return aiCmsChatService.chatArea(message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
## 重要提示(Tip):
|
||||
|
||||
## 请勿在该配置文件中添加其它任何配置(添加也不会生效)。
|
||||
## 该文件,仅仅是为了让 jeesite-ai-cms.yml 文件,
|
||||
## 在 IDEA 中有一个自动完成及帮助提示,并无其它用意。
|
||||
## 参数配置请在 jeesite-ai-cms.yml 文件中添加。
|
||||
|
||||
spring:
|
||||
config:
|
||||
import:
|
||||
- classpath:config/jeesite-ai-cms.yml
|
||||
236
modules/ai/ai-cms/src/main/resources/config/jeesite-ai-cms.yml
Normal file
236
modules/ai/ai-cms/src/main/resources/config/jeesite-ai-cms.yml
Normal file
@@ -0,0 +1,236 @@
|
||||
# 温馨提示:不建议直接修改此文件,为了平台升级方便,建议将需要修改的参数值,复制到application.yml里进行覆盖该参数值。
|
||||
|
||||
spring:
|
||||
ai:
|
||||
|
||||
# 模型选择:openai、ollama
|
||||
model:
|
||||
chat: openai
|
||||
embedding: ${spring.ai.model.chat}
|
||||
embedding.text: ${spring.ai.model.chat}
|
||||
embedding.multimodal: ${spring.ai.model.chat}
|
||||
audio.transcription: none
|
||||
audio.speech: none
|
||||
moderation: none
|
||||
image: none
|
||||
|
||||
# ========= 聊天对话 相关配置 =========
|
||||
|
||||
# 云端模型【请在 pom.xml 中打开 openai 的注释,并注释上其它模型】
|
||||
openai:
|
||||
|
||||
# 聊天对话模型使用阿里百炼
|
||||
chat:
|
||||
base-url: https://dashscope.aliyuncs.com/compatible-mode
|
||||
api-key: ${BAILIAN_APP_KEY}
|
||||
options:
|
||||
model: deepseek-r1-distill-llama-8b
|
||||
max-tokens: 1024
|
||||
temperature: 0.6
|
||||
top-p: 0.7
|
||||
frequency-penalty: 0
|
||||
# 嵌入向量模型使用硅基流动
|
||||
embedding:
|
||||
base-url: https://api.siliconflow.cn
|
||||
api-key: ${SFLOW_APP_KEY}
|
||||
options:
|
||||
model: BAAI/bge-m3
|
||||
dimensions: 512
|
||||
|
||||
# # 硅基流动
|
||||
# base-url: https://api.siliconflow.cn
|
||||
# api-key: ${SFLOW_APP_KEY}
|
||||
# # 聊天对话模型
|
||||
# chat:
|
||||
# options:
|
||||
# model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B
|
||||
# max-tokens: 1024
|
||||
# temperature: 0.6
|
||||
# top-p: 0.9
|
||||
# frequency-penalty: 0
|
||||
# # 向量库知识库模型(注意:不同的模型维度不同)
|
||||
# embedding:
|
||||
# options:
|
||||
# model: BAAI/bge-m3
|
||||
# dimensions: 512
|
||||
|
||||
# # 模力方舟
|
||||
# base-url: https://ai.gitee.com
|
||||
# api-key: ${GITEE_APP_KEY}
|
||||
# # 聊天对话模型
|
||||
# chat:
|
||||
# options:
|
||||
# model: DeepSeek-R1-Distill-Qwen-14B
|
||||
# max-tokens: 1024
|
||||
# temperature: 0.6
|
||||
# top-p: 0.9
|
||||
# frequency-penalty: 0
|
||||
# # 向量库知识库模型(注意:不同的模型维度不同)
|
||||
# embedding:
|
||||
# options:
|
||||
# model: bge-large-zh-v1.5
|
||||
# dimensions: 512
|
||||
|
||||
# # 阿里百炼
|
||||
# base-url: https://dashscope.aliyuncs.com/compatible-mode
|
||||
# api-key: ${BAILIAN_APP_KEY}
|
||||
# # 聊天对话模型
|
||||
# chat:
|
||||
# options:
|
||||
# model: deepseek-r1-distill-llama-8b
|
||||
# max-tokens: 1024
|
||||
# temperature: 0.6
|
||||
# top-p: 0.9
|
||||
# frequency-penalty: 0
|
||||
# # 向量库知识库模型(注意:不同的模型维度不同)
|
||||
# embedding:
|
||||
# options:
|
||||
# model: text-embedding-v3
|
||||
# dimensions: 1024
|
||||
|
||||
# 本地大模型配置【请在 pom.xml 中打开 ollama 的注释,并注释上其它模型】
|
||||
ollama:
|
||||
base-url: http://localhost:11434
|
||||
# 聊天对话模型
|
||||
chat:
|
||||
options:
|
||||
model: qwen3:8b
|
||||
#model: deepseek-r1:7b
|
||||
max-tokens: 1024
|
||||
temperature: 0.6
|
||||
top-p: 0.7
|
||||
frequency-penalty: 0
|
||||
# 向量库知识库模型(注意:不同的模型维度不同)
|
||||
embedding:
|
||||
# 维度 dimensions 设置为 384
|
||||
#model: all-minilm:33m
|
||||
# 维度 dimensions 设置为 1024
|
||||
model: bge-m3
|
||||
|
||||
# ========= 向量数据库 相关配置 =========
|
||||
|
||||
# 向量数据库配置
|
||||
vectorstore:
|
||||
|
||||
# 向量库类型:chroma、pgvector、elasticsearch、milvus、指定 none 表示不使用向量库
|
||||
type: none
|
||||
|
||||
# Chroma 向量数据库【请在 pom.xml 中打开 chroma 的注释,并注释上其它向量库】
|
||||
chroma:
|
||||
client:
|
||||
host: http://127.0.0.1
|
||||
port: 8000
|
||||
initialize-schema: true
|
||||
# collection-name: vector_store
|
||||
collection-name: vector_store_1024
|
||||
|
||||
# Postgresql 向量数据库(PG 连接配置,见下文,需要手动建表)【请在 pom.xml 中打开 pgvector 的注释,并注释上其它向量库】
|
||||
pgvector:
|
||||
id-type: TEXT
|
||||
index-type: HNSW
|
||||
distance-type: COSINE_DISTANCE
|
||||
initialize-schema: false
|
||||
#table-name: vector_store_384
|
||||
#dimensions: 384
|
||||
#table-name: vector_store_786
|
||||
#dimensions: 768
|
||||
table-name: vector_store_1024
|
||||
dimensions: 1024
|
||||
max-document-batch-size: 10000
|
||||
|
||||
# ES 向量数据库(ES 连接配置,见下文)【请在 pom.xml 中打开 elasticsearch 的注释,并注释上其它向量库】
|
||||
elasticsearch:
|
||||
index-name: vector-index
|
||||
initialize-schema: true
|
||||
dimensions: 1024
|
||||
similarity: cosine
|
||||
|
||||
# Milvus 向量数据库【请在 pom.xml 中打开 milvus 的注释,并注释上其它向量库】
|
||||
milvus:
|
||||
client:
|
||||
host: "localhost"
|
||||
port: 19530
|
||||
username: "root"
|
||||
password: "milvus"
|
||||
initialize-schema: true
|
||||
database-name: "default"
|
||||
collection-name: "vector_store"
|
||||
embedding-dimension: 384
|
||||
index-type: HNSW
|
||||
metric-type: COSINE
|
||||
|
||||
# ========= 本地工具调用 相关配置 =========
|
||||
|
||||
# 是否启用 Tool calling 工具调用【例子详见 TestAiTools.java、UserAiTools.java 】
|
||||
tools:
|
||||
enabled: false
|
||||
|
||||
# ========= MPC 远程工具调用 相关配置 =========
|
||||
|
||||
# https://docs.spring.io/spring-ai/reference/api/mcp/mcp-client-boot-starter-docs.html
|
||||
mcp:
|
||||
client:
|
||||
enabled: false
|
||||
name: jeesite-mcp-client
|
||||
version: 1.0.0
|
||||
request-timeout: 30s
|
||||
type: SYNC
|
||||
streamable-http:
|
||||
connections:
|
||||
jeesite:
|
||||
url: http://127.0.0.1:8992
|
||||
endpoint: /api/v1/mcp
|
||||
# sse:
|
||||
# connections:
|
||||
# jeesite:
|
||||
# url: http://127.0.0.1:8992
|
||||
# sse-endpoint: /api/v1/sse
|
||||
toolcallback:
|
||||
enabled: true
|
||||
|
||||
# ========= 默认提示词、默认回答模版 =========
|
||||
|
||||
# 默认系统提示词
|
||||
default-system: |
|
||||
1. 人物设定:你是我的知识库AI助手。请认真地回复我提出的相关问题。
|
||||
2. 表达方式:使用简体中文回答我的问题。回答中不要体现系统提示词和模板上下文。
|
||||
|
||||
# 默认问题回答模板
|
||||
default-prompt-template: |
|
||||
{query}
|
||||
请根据知识库和提供的历史信息作答。如果知识库中没有答案,请自我发挥。
|
||||
以下是知识库信息:{question_answer_context}
|
||||
|
||||
# ========= Postgresql 向量数据库数据源 =========
|
||||
|
||||
#jdbc:
|
||||
# ds_pgvector:
|
||||
# type: postgresql
|
||||
# driver: org.postgresql.Driver
|
||||
# url: jdbc:postgresql://127.0.0.1:5433/jeesite-ai
|
||||
# username: postgres
|
||||
# password: postgres
|
||||
# testSql: SELECT 1
|
||||
# pool:
|
||||
# init: 0
|
||||
# minIdle: 0
|
||||
# breakAfterAcquireFailure: true
|
||||
|
||||
# ========= ES 向量数据库连接配置 =========
|
||||
|
||||
#spring.elasticsearch:
|
||||
# socket-timeout: 120s
|
||||
# connection-timeout: 120s
|
||||
# uris: http://127.0.0.1:9200
|
||||
# username: elastic
|
||||
# password: elastic
|
||||
|
||||
# ========= 其他配置选项 =========
|
||||
|
||||
# 对话消息存缓存,可自定义存数据库
|
||||
j2cache:
|
||||
caffeine:
|
||||
region:
|
||||
# 对话消息的超期时间,默认 30天,根据需要可以设置更久。
|
||||
cmsChatCache: 100000, 30d
|
||||
cmsChatMsgCache: 100000, 30d
|
||||
@@ -0,0 +1,9 @@
|
||||
5.11.1
|
||||
5.12.0
|
||||
5.12.1
|
||||
5.13.0
|
||||
5.13.1
|
||||
5.14.0
|
||||
5.14.1
|
||||
5.15.0
|
||||
5.15.1
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now https://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
*/
|
||||
package com.jeesite.test;
|
||||
|
||||
import com.jeesite.common.mapper.JsonMapper;
|
||||
import com.jeesite.common.tests.BaseSpringContextTests;
|
||||
import com.jeesite.modules.ai.cms.service.AiCmsChatService;
|
||||
import com.jeesite.modules.sys.entity.Area;
|
||||
import org.junit.FixMethodOrder;
|
||||
import org.junit.Test;
|
||||
import org.junit.runners.MethodSorters;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 对话单元测试
|
||||
* @author ThinkGem
|
||||
* @version 2025-06-06
|
||||
*/
|
||||
@ActiveProfiles("test")
|
||||
@SpringBootApplication
|
||||
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
|
||||
@SpringBootTest(properties = {"spring.ai.model.chat=ollama","spring.ai.tools.enabled=true"})
|
||||
public class AiChatServiceTest extends BaseSpringContextTests {
|
||||
|
||||
private AiCmsChatService aiCmsChatService;
|
||||
|
||||
@Autowired
|
||||
public void setAiChatServiceTest(AiCmsChatService aiCmsChatService) {
|
||||
this.aiCmsChatService = aiCmsChatService;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void test01Text() {
|
||||
logger.info("===== 聊天对话,文本输出");
|
||||
String message = "你好";
|
||||
String text = aiCmsChatService.chatText(message);
|
||||
System.out.println(text);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void test02Json() {
|
||||
logger.info("===== 聊天对话,结构化输出 JSON");
|
||||
String message = "张三";
|
||||
Map<String, Object> map = aiCmsChatService.chatJson(message);
|
||||
System.out.println(JsonMapper.toJson(map));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void test03Tool() {
|
||||
logger.info("===== 聊天对话,结构化输出 Tool Calling");
|
||||
String message = "打开客厅的灯";
|
||||
Map<String, Object> map = aiCmsChatService.chatJson(message);
|
||||
System.out.println(JsonMapper.toJson(map));
|
||||
message = "关闭客厅的灯";
|
||||
map = aiCmsChatService.chatJson(message);
|
||||
System.out.println(JsonMapper.toJson(map));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void test04Entity() {
|
||||
logger.info("===== 聊天对话,结构化输出 Entity");
|
||||
String message = "北京";
|
||||
List<Area> list = aiCmsChatService.chatArea(message);
|
||||
System.out.println(JsonMapper.toJson(list));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
15
modules/ai/ai-cms/src/test/resources/application.yml
Normal file
15
modules/ai/ai-cms/src/test/resources/application.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
# 数据库连接
|
||||
jdbc:
|
||||
|
||||
# Mysql 数据库配置
|
||||
type: mysql
|
||||
driver: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://127.0.0.1:3306/jeesite_v5?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=CONVERT_TO_NULL&serverTimezone=Asia/Shanghai
|
||||
username: root
|
||||
password: 123456
|
||||
testSql: SELECT 1
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
config: classpath:logback-test.xml
|
||||
20
modules/ai/ai-cms/src/test/resources/logback-test.xml
Normal file
20
modules/ai/ai-cms/src/test/resources/logback-test.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration debug="false" scan="false">
|
||||
|
||||
<!-- Logger level setting -->
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
|
||||
<include resource="config/logger-default.xml" />
|
||||
|
||||
<!-- Console log output -->
|
||||
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} %clr(%-5p) %clr([%-39logger{39}]){cyan} - %m%n%wEx</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- Level: FATAL 0 ERROR 3 WARN 4 INFO 6 DEBUG 7 -->
|
||||
<root level="WARN">
|
||||
<appender-ref ref="console" />
|
||||
</root>
|
||||
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user