拆分 jeesite-ai-tools 模块,工具调用保持会话控制权限,如当前用户只能查询有权限的数据

This commit is contained in:
thinkgem
2025-10-19 13:21:21 +08:00
parent 6dadf4c774
commit fd8d036cb1
10 changed files with 332 additions and 52 deletions

View File

@@ -0,0 +1,277 @@
/**
* Copyright (c) 2013-Now http://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.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());
}
spec.toolContext(Map.of("subject", 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", 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 (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);
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,22 @@
@echo off
rem /**
rem * Copyright (c) 2013-Now http://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
call mvn -v
echo.
cd ..
call mvn clean deploy -Dmaven.test.skip=true -Pdeploy
cd bin
pause

View File

@@ -0,0 +1,18 @@
#!/bin/sh
# /**
# * Copyright (c) 2013-Now http://jeesite.com All rights reserved.
# * No deletion without permission, or be held responsible to law.
# *
# * Author: ThinkGem@163.com
# */
echo ""
echo "[信息] 部署工程到Maven服务器。"
echo ""
mvn -v
echo ""
cd ..
mvn clean deploy -Dmaven.test.skip=true -Pdeploy
cd bin

View File

@@ -0,0 +1,22 @@
@echo off
rem /**
rem * Copyright (c) 2013-Now http://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
call mvn -v
echo.
cd ..
call mvn clean install -Dmaven.test.skip=true -Ppackage
cd bin
pause

View File

@@ -0,0 +1,18 @@
#!/bin/sh
# /**
# * Copyright (c) 2013-Now http://jeesite.com All rights reserved.
# * No deletion without permission, or be held responsible to law.
# *
# * Author: ThinkGem@163.com
# */
echo ""
echo "[信息] 打包安装工程生成jar包文件。"
echo ""
mvn -v
echo ""
cd ..
mvn clean install -Dmaven.test.skip=true -Ppackage
cd bin

View File

@@ -0,0 +1,69 @@
<?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-parent-ai</artifactId>
<version>5.14.0.springboot3-SNAPSHOT</version>
<relativePath>../../../parent/ai/pom.xml</relativePath>
</parent>
<artifactId>jeesite-module-ai-tools</artifactId>
<packaging>jar</packaging>
<name>JeeSite Module AI Tools</name>
<url>http://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>
<!-- AI Model -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
</dependency>
<!-- AI MCP -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp</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>http://jeesite.com</url>
</organization>
</project>

View File

@@ -0,0 +1,54 @@
/**
* Copyright (c) 2013-Now http://jeesite.com All rights reserved.
* No deletion without permission, or be held responsible to law.
*/
package com.jeesite.modules.ai.tools;
import com.jeesite.common.lang.DateUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
/**
* AI MCP 工具调用
* @author ThinkGem
*/
@Component
public class TestAiTools {
private final Logger logger = LoggerFactory.getLogger(TestAiTools.class);
/**
* 获取服务器时间
*/
@Tool(name="获取服务器时间", description = "获取当前的日期和时间,格式为 yyyy-MM-dd HH:mm:ss。")
public String getCurrentDateTime() {
String dateTime = "当前日期时间:" + DateUtils.getDateTime();
logger.info("当前日期时间 ============== {}", dateTime);
return dateTime;
}
/**
* 开关房间的灯
*/
@Tool(
name = "房间灯光开关",
description = "控制指定房间的灯光开关。需要提供房间名称(如 '客厅'、'卧室'和目标状态true 表示开灯false 表示关灯)。"
)
public String roomLightSwitch(
@ToolParam(description = "要控制的房间名称,例如:'客厅'、'卧室'、'餐厅'、'厨房'") String roomName,
@ToolParam(description = "灯光目标状态true 表示打开灯false 表示关闭灯") boolean on) {
String message = roomName + " 房间里的灯被 " + (on ? "打开" : "关闭");
logger.info("房间灯光开关 ============== {}", message);
return String.format("""
{
"message": "%s",
"roomName": "%s",
"on": %s
}
""", message, roomName, on);
}
}

View File

@@ -0,0 +1,78 @@
/**
* Copyright (c) 2013-Now http://jeesite.com All rights reserved.
* No deletion without permission, or be held responsible to law.
*/
package com.jeesite.modules.ai.tools;
import com.jeesite.common.lang.StringUtils;
import com.jeesite.common.mapper.JsonMapper;
import com.jeesite.common.mybatis.mapper.query.QueryType;
import com.jeesite.modules.sys.entity.EmpUser;
import com.jeesite.modules.sys.entity.User;
import com.jeesite.modules.sys.service.EmpUserService;
import com.jeesite.modules.sys.utils.UserUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* AI MCP 工具调用
* @author ThinkGem
*/
@Component
public class UserAITools {
private final Logger logger = LoggerFactory.getLogger(UserAITools.class);
private final EmpUserService empUserService;
public UserAITools(EmpUserService empUserService) {
this.empUserService = empUserService;
}
/**
* 获取当前会话的用户信息
*/
@Tool(name="当前用户信息", description = "无条件获取当前用户信息")
public String getCurrentUser(ToolContext toolContext) {
User currentUser = UserUtils.getUser();
if (StringUtils.isBlank(currentUser.getUserCode())) {
logger.info("当前用户信息 ============== 当前用户未登录。");
return "当前用户未登录。";
}
String result = JsonMapper.toJson(currentUser);
logger.info("当前用户信息 ============== 查询结果:{}", result);
return result;
}
/**
* 查询用户信息
*/
@Tool(name="查询用户信息", description = "根据用户名(登录账号)或员工姓名模糊查询用户信息。" +
"结果以表格形式展示包含用户名userName、姓名empUser、部门officeName等基本信息。")
public String findEmpUserInfo(ToolContext toolContext,
@ToolParam(description = "用户的登录名或员工的真实姓名,支持模糊匹配") String userName
) {
EmpUser where = new EmpUser();
where.sqlMap().getWhere().and(w -> w
.or("a.user_name", QueryType.LIKE, userName)
.or("e.emp_name", QueryType.LIKE, userName));
// 权限控制,只能查询当前用户能查询的用户信息
logger.info("获取用户信息 ============== 当前用户: {}", where.currentUser().getUserCode());
empUserService.addDataScopeFilter(where);
List<EmpUser> list = empUserService.findList(where);
String result = JsonMapper.toJson(list);
logger.info("获取用户信息 ============== 查询结果: {}", result);
if (list.isEmpty()) {
return "未找到符合条件的用户信息。";
}
return result;
}
}

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2013-Now http://jeesite.com All rights reserved.
* No deletion without permission, or be held responsible to law.
*/
package com.jeesite.modules.ai.tools.aspect;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* Tool 上下文用户信息注入切面
* @author ThinkGem
*/
@Aspect
@Component
public class ToolContextAspect {
@Around("@annotation(org.springframework.ai.tool.annotation.Tool)")
public Object handleThreadContext(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
ToolContext toolContext = null;
for (Object arg : args) {
if (arg instanceof ToolContext) {
toolContext = (ToolContext) arg;
break;
}
}
if (toolContext != null) {
Map<String, Object> context = toolContext.getContext();
if (context.containsKey("subject")) {
ThreadContext.bind((Subject) context.get("subject"));
}
}
return joinPoint.proceed();
}
}