1. 主动停止的响应的消息仍然存储对话数据;2. 对话消息框当手动向上滚动的时候,停止滚动到底部,方便阅读已生成的消息;3. 消息内容,对 JSON 格式的数据,进行格式化显示;4. 支持“深度思考”按钮,可展开和折叠深度思考的消息;对 AI 的用户消息进行转义,避免不会支持的消息报错;5. 当 AI 接口调用异常的时候,给于用户提示实际的接口返回内容;6. 新增自动更新对话标题;6. 加载消息过程中,避免再次发送新消息和切换对话。

This commit is contained in:
thinkgem
2025-04-21 13:24:23 +08:00
parent 695762b34c
commit 1b8b6162f4
6 changed files with 272 additions and 79 deletions

View File

@@ -5,9 +5,11 @@
package com.jeesite.modules.cms.ai.config; package com.jeesite.modules.cms.ai.config;
import com.jeesite.common.datasource.DataSourceHolder; import com.jeesite.common.datasource.DataSourceHolder;
import com.jeesite.modules.cms.ai.properties.CmsAiProperties;
import com.jeesite.modules.cms.ai.tools.CmsAiTools; import com.jeesite.modules.cms.ai.tools.CmsAiTools;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Primary;
@@ -18,6 +20,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
* @author ThinkGem * @author ThinkGem
*/ */
@Configuration @Configuration
@EnableConfigurationProperties(CmsAiProperties.class)
public class CmsAiChatConfig { public class CmsAiChatConfig {
/** /**
@@ -25,19 +28,21 @@ public class CmsAiChatConfig {
* @author ThinkGem * @author ThinkGem
*/ */
@Bean @Bean
public ChatClient chatClient(ChatClient.Builder builder) { public ChatClient chatClient(ChatClient.Builder builder, CmsAiProperties properties) {
return builder builder.defaultSystem("""
.defaultSystem(""" ## 人物设定
## 人物设定 你是我的知识库AI助手你把我当作朋友耐心真诚地回复我提出的相关问题。
你是我的知识库AI助手你把我当作朋友耐心真诚地回复我提出的相关问题 你需要遵循以下原则,与关注者进行友善而有价值的沟通
你需要遵循以下原则,与关注者进行友善而有价值的沟通。 ## 表达方式:
## 表达方式: 1. 使用简体中文回答我的问题。
1. 使用简体中文回答我的问题 2. 使用幽默有趣的方式与我沟通
2. 使用幽默有趣的方式与我沟通。 3. 增加互动,如 “您的看法如何?”
3. 增加互动,如 “您的看法如何?” 4. 可以用表情,避免过多表情。
""") """);
.defaultTools(new CmsAiTools()) if (properties.getToolCalls()) {
.build(); builder.defaultTools(new CmsAiTools());
}
return builder.build();
} }
// @Bean // @Bean

View File

@@ -0,0 +1,129 @@
/**
* Copyright (c) 2013-Now http://jeesite.com All rights reserved.
* No deletion without permission, or be held responsible to law.
*/
package com.jeesite.modules.cms.ai.config;
import com.jeesite.common.mapper.JsonMapper;
import org.apache.commons.lang3.StringUtils;
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
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) {
if (!line.startsWith("data: ")) {
lines.add(line);
continue;
}
String jsonPart = line.substring("data: ".length()).trim();
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;
}
// 修改内容字段
List<Object> choices = (List<Object>)map.get("choices");
if (choices == null) {
lines.add(line);
continue;
}
for (Object o : choices) {
Map<String, Object> choice = (Map<String, Object>) o;
if (choice == null) {
continue;
}
Map<String, Object> delta = (Map<String, Object>) choice.get("delta");
if (delta == null) {
continue;
}
String reasoningContent = (String) delta.get("reasoning_content");
String content = (String) delta.get("content");
if (reasoningContent != null) {
if (!thinkingFlag.get()) {
thinkingFlag.set(true);
delta.put("content", "<think>\n" + reasoningContent);
} else {
delta.put("content", reasoningContent);
}
} else {
if (thinkingFlag.get()) {
thinkingFlag.set(false);
delta.put("content", "</think>" + (content == null ? "" : content));
}
}
}
// 重新生成事件字符串
lines.add("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,17 @@
package com.jeesite.modules.cms.ai.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("spring.ai")
public class CmsAiProperties {
private Boolean toolCalls = false;
public Boolean getToolCalls() {
return toolCalls;
}
public void setToolCalls(Boolean toolCalls) {
this.toolCalls = toolCalls;
}
}

View File

@@ -11,18 +11,23 @@ import com.jeesite.common.lang.DateUtils;
import com.jeesite.common.lang.StringUtils; import com.jeesite.common.lang.StringUtils;
import com.jeesite.common.service.BaseService; import com.jeesite.common.service.BaseService;
import com.jeesite.modules.sys.utils.UserUtils; import com.jeesite.modules.sys.utils.UserUtils;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor; import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.chat.memory.ChatMemory; 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.Message;
import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.SignalType;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -35,6 +40,8 @@ import java.util.Map;
public class CmsAiChatService extends BaseService { public class CmsAiChatService extends BaseService {
private static final String CMS_CHAT_CACHE = "cmsChatCache"; 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[]{"\\{", "\\}", "\\$", "\\%"};
@Autowired @Autowired
private ChatClient chatClient; private ChatClient chatClient;
@@ -102,14 +109,52 @@ public class CmsAiChatService extends BaseService {
* 聊天对话,流输出 * 聊天对话,流输出
* @author ThinkGem * @author ThinkGem
*/ */
public Flux<ChatResponse> chatStream(String conversationId, String message) { public Flux<ChatResponse> chatStream(String conversationId, String message, HttpServletRequest request) {
return chatClient.prompt() return chatClient.prompt()
.messages(new UserMessage(message)) .messages(
new UserMessage(StringUtils.replaceEach(message, USER_MESSAGE_SEARCH, USER_MESSAGE_REPLACE))
)
.advisors( .advisors(
new MessageChatMemoryAdvisor(chatMemory, conversationId, 1024), new MessageChatMemoryAdvisor(chatMemory, conversationId, 1024),
new QuestionAnswerAdvisor(vectorStore, SearchRequest.builder().similarityThreshold(0.6F).topK(6).build())) new QuestionAnswerAdvisor(vectorStore, SearchRequest.builder().similarityThreshold(0.6F).topK(6).build())
)
.stream() .stream()
.chatResponse(); .chatResponse()
.doOnNext(response -> {
if (response.getResult() != null && 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", new AssistantMessage(
assistantMessage.getText() + currAssistantMessage.getText(),
currAssistantMessage.getMetadata()));
}
}
})
.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("暂无消息,你已主动停止响应。")));
}
}
})
.onErrorResume(error -> {
String errorMessage = error.getMessage();
if (error instanceof WebClientResponseException webClientError) {
errorMessage = webClientError.getResponseBodyAsString();
}
AssistantMessage assistantMessage = new AssistantMessage(errorMessage);
chatMemory.add(conversationId, assistantMessage);
logger.error("Error message: {}", errorMessage);
return Flux.just(ChatResponse.builder()
.generations(List.of(new Generation(assistantMessage)))
.build());
});
} }
} }

View File

@@ -7,6 +7,7 @@ package com.jeesite.modules.cms.ai.web;
import com.jeesite.common.config.Global; import com.jeesite.common.config.Global;
import com.jeesite.common.web.BaseController; import com.jeesite.common.web.BaseController;
import com.jeesite.modules.cms.ai.service.CmsAiChatService; import com.jeesite.modules.cms.ai.service.CmsAiChatService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -77,8 +78,8 @@ public class CmsAiChatController extends BaseController {
* @author ThinkGem * @author ThinkGem
*/ */
@RequestMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @RequestMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ChatResponse> stream(String id, String message) { public Flux<ChatResponse> stream(String id, String message, HttpServletRequest request) {
return cmsAiChatService.chatStream(id, message); return cmsAiChatService.chatStream(id, message, request);
} }
} }

View File

@@ -13,19 +13,17 @@ spring:
#api-key: ${BAILIAN_APP_KEY} #api-key: ${BAILIAN_APP_KEY}
# 聊天对话模型 # 聊天对话模型
chat: chat:
enabled: false
options: options:
model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B
#model: DeepSeek-R1-Distill-Qwen-14B #model: DeepSeek-R1-Distill-Qwen-14B
#model: deepseek-r1-distill-llama-8b #model: deepseek-r1-distill-llama-8b
max-tokens: 1024 max-tokens: 1024
temperature: 0.6 temperature: 0.6
top-p: 0.7 top-p: 0.9
frequency-penalty: 0 frequency-penalty: 0
logprobs: true #logprobs: true
# 向量库知识库模型(注意:不同的模型维度不同) # 向量库知识库模型(注意:不同的模型维度不同)
embedding: embedding:
enabled: false
options: options:
model: BAAI/bge-m3 model: BAAI/bge-m3
#model: bge-large-zh-v1.5 #model: bge-large-zh-v1.5
@@ -33,12 +31,14 @@ spring:
#model: text-embedding-v3 #model: text-embedding-v3
#dimensions: 1024 #dimensions: 1024
# 是否启用工具调用
tool-calls: false
# 本地大模型配置(使用该模型,请开启 enabled 参数) # 本地大模型配置(使用该模型,请开启 enabled 参数)
ollama: ollama:
base-url: http://localhost:11434 base-url: http://localhost:11434
# 聊天对话模型 # 聊天对话模型
chat: chat:
enabled: true
options: options:
model: qwen2.5 model: qwen2.5
#model: deepseek-r1:7b #model: deepseek-r1:7b
@@ -48,7 +48,6 @@ spring:
frequency-penalty: 0 frequency-penalty: 0
# 向量库知识库模型(注意:不同的模型维度不同) # 向量库知识库模型(注意:不同的模型维度不同)
embedding: embedding:
enabled: true
# 维度 dimensions 设置为 384 # 维度 dimensions 设置为 384
#model: all-minilm:33m #model: all-minilm:33m
# 维度 dimensions 设置为 768 # 维度 dimensions 设置为 768
@@ -68,67 +67,64 @@ spring:
#collection-name: vector_store #collection-name: vector_store
collection-name: vector_store_1024 collection-name: vector_store_1024
# # Postgresql 向量数据库PG 连接配置,见下文,需要手动建表) # Postgresql 向量数据库PG 连接配置,见下文,需要手动建表)
# pgvector: pgvector:
# id-type: TEXT id-type: TEXT
# index-type: HNSW index-type: HNSW
# distance-type: COSINE_DISTANCE distance-type: COSINE_DISTANCE
# initialize-schema: false initialize-schema: false
# #table-name: vector_store_384 #table-name: vector_store_384
# #dimensions: 384 #dimensions: 384
# #table-name: vector_store_786 #table-name: vector_store_786
# #dimensions: 768 #dimensions: 768
# table-name: vector_store_1024 table-name: vector_store_1024
# dimensions: 1024 dimensions: 1024
# batching-strategy: TOKEN_COUNT max-document-batch-size: 10000
# max-document-batch-size: 10000
# # ES 向量数据库ES 连接配置,见下文) # ES 向量数据库ES 连接配置,见下文)
# elasticsearch: elasticsearch:
# index-name: vector-index index-name: vector-index
# initialize-schema: true initialize-schema: true
# dimensions: 1024 dimensions: 1024
# similarity: cosine similarity: cosine
# batching-strategy: TOKEN_COUNT
# # Milvus 向量数据库字符串长度不超过65535 # Milvus 向量数据库
# milvus: milvus:
# client: client:
# host: "localhost" host: "localhost"
# port: 19530 port: 19530
# username: "root" username: "root"
# password: "milvus" password: "milvus"
# initialize-schema: true initialize-schema: true
# database-name: "default2" database-name: "default"
# collection-name: "vector_store2" collection-name: "vector_store"
# embedding-dimension: 384 embedding-dimension: 384
# index-type: HNSW index-type: HNSW
# metric-type: COSINE metric-type: COSINE
# ========= Postgresql 向量数据库数据源 ========= # ========= Postgresql 向量数据库数据源 =========
#jdbc: jdbc:
# ds_pgvector: ds_pgvector:
# type: postgresql type: postgresql
# driver: org.postgresql.Driver driver: org.postgresql.Driver
# url: jdbc:postgresql://127.0.0.1:5433/jeesite-ai url: jdbc:postgresql://127.0.0.1:5433/jeesite-ai
# username: postgres username: postgres
# password: postgres password: postgres
# testSql: SELECT 1 testSql: SELECT 1
# pool: pool:
# init: 0 init: 0
# minIdle: 0 minIdle: 0
# breakAfterAcquireFailure: true breakAfterAcquireFailure: true
# ========= ES 向量数据库连接配置 ========= # ========= ES 向量数据库连接配置 =========
#spring.elasticsearch: spring.elasticsearch:
# enabled: true socket-timeout: 120s
# socket-timeout: 120s connection-timeout: 120s
# connection-timeout: 120s uris: http://127.0.0.1:9200
# uris: http://127.0.0.1:9200 username: elastic
# username: elastic password: elastic
# password: elastic
# 对话消息存缓存,可自定义存数据库 # 对话消息存缓存,可自定义存数据库
j2cache: j2cache: