项目初始化

This commit is contained in:
2026-03-19 10:57:24 +08:00
commit ee94d420ad
3822 changed files with 582614 additions and 0 deletions

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

151
modules/ai/ai-cms/pom.xml Normal file
View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
## 重要提示Tip
## 请勿在该配置文件中添加其它任何配置(添加也不会生效)。
## 该文件,仅仅是为了让 jeesite-ai-cms.yml 文件,
## 在 IDEA 中有一个自动完成及帮助提示,并无其它用意。
## 参数配置请在 jeesite-ai-cms.yml 文件中添加。
spring:
config:
import:
- classpath:config/jeesite-ai-cms.yml

View 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

View File

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

View File

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

View 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

View 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>