mirror of
https://github.com/jeecgboot/JeecgBoot.git
synced 2026-01-03 03:45:28 +08:00
AI大模型管理功能后台代码
This commit is contained in:
@ -0,0 +1,12 @@
|
||||
//package org.jeecg;
|
||||
//
|
||||
//import org.springframework.boot.SpringApplication;
|
||||
//import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
//
|
||||
//@SpringBootApplication
|
||||
//public class JeecgAiRagApplication {
|
||||
//
|
||||
// public static void main(String[] args) {
|
||||
// SpringApplication.run(JeecgAiRagApplication.class, args);
|
||||
// }
|
||||
//}
|
||||
@ -0,0 +1,37 @@
|
||||
package org.jeecg.modules.airag.app.consts;
|
||||
|
||||
/**
|
||||
* AI应用常量类
|
||||
*
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 14:52
|
||||
*/
|
||||
public class AiAppConsts {
|
||||
|
||||
/**
|
||||
* 状态:启用
|
||||
*/
|
||||
public static final String STATUS_ENABLE = "enable";
|
||||
/**
|
||||
* 状态:禁用
|
||||
*/
|
||||
public static final String STATUS_DISABLE = "disable";
|
||||
|
||||
|
||||
/**
|
||||
* 默认应用id
|
||||
*/
|
||||
public static final String DEFAULT_APP_ID = "default";
|
||||
|
||||
|
||||
/**
|
||||
* 应用类型:简单聊天
|
||||
*/
|
||||
public static final String APP_TYPE_CHAT_SIMPLE = "chatSimple";
|
||||
|
||||
/**
|
||||
* 应用类型:聊天流(高级编排)
|
||||
*/
|
||||
public static final String APP_TYPE_CHAT_FLOW = "chatFLow";
|
||||
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
package org.jeecg.modules.airag.app.consts;
|
||||
|
||||
/**
|
||||
* @Description: 提示词常量
|
||||
* @Author: chenrui
|
||||
* @Date: 2025/3/12 15:03
|
||||
*/
|
||||
public class Prompts {
|
||||
|
||||
/**
|
||||
* 根据提示生成智能体提示词
|
||||
*/
|
||||
public static final String GENERATE_LLM_PROMPT = "# 角色\n" +
|
||||
"你是一位专业且高效的AI提示词工程师,擅长根据用户多样化需求自动生成高质量的结构化提示词模板,具备全面而敏锐的分析能力和出色的创造力。\n" +
|
||||
"## 要求:\n" +
|
||||
"1. \"\"\"只输出提示词,不要输出多余解释\"\"\"\n" +
|
||||
"2. \"\"\"不要在前后增加代码块的md语法.\"\"\"\n" +
|
||||
"2. 贴合用户需求,描述智能助手的定位、能力、知识储备\n" +
|
||||
"3. 提示词应清晰、精确、易于理解,在保持质量的同时,尽可能简洁\n" +
|
||||
"4. 严格按照给定的流程和格式执行任务,确保输出规范准确。\n" +
|
||||
"\n" +
|
||||
"## 流程\n" +
|
||||
"### 1: 需求分析\n" +
|
||||
"1. 当用户描述需求时,严格运用SCQA框架确认核心要素,精准分析和联想:\"当前场景(Situation)是什么?主要矛盾(Complication)有哪些?需要解决的关键问题(Question)是?预期达成什么效果(Answer)?\"\n" +
|
||||
"2. 通过5W1H细致分析和联想细节:\"目标受众(Who)?使用场景(Where/When)?具体要实现什么(What)?为什么需要这些特征(Why)?如何量化效果(How)?\"\n" +
|
||||
"\n" +
|
||||
"### 2: 框架选择\n" +
|
||||
"根据需求从给定模板库中匹配最佳提示词类型:\n" +
|
||||
"* 角色扮演型:\n" +
|
||||
"```\n" +
|
||||
"你将扮演一个人物角色<角色名称>,以下是关于这个角色的详细设定,请根据这些信息来构建你的回答。 \n" +
|
||||
"\n" +
|
||||
"**人物基本信息:**\n" +
|
||||
"- 你是:<角色的名称、身份等基本介绍>\n" +
|
||||
"- 人称:第一人称\n" +
|
||||
"- 出身背景与上下文:<交代角色背景信息和上下文>\n" +
|
||||
"**性格特点:**\n" +
|
||||
"- <性格特点描述>\n" +
|
||||
"**语言风格:**\n" +
|
||||
"- <语言风格描述> \n" +
|
||||
"**人际关系:**\n" +
|
||||
"- <人际关系描述>\n" +
|
||||
"**过往经历:**\n" +
|
||||
"- <过往经历描述>\n" +
|
||||
"**经典台词或口头禅:**\n" +
|
||||
"补充信息: 即你可以将动作、神情语气、心理活动、故事背景放在()中来表示,为对话提供补充信息。\n" +
|
||||
"- 台词1:<角色台词示例1> \n" +
|
||||
"- 台词2:<角色台词示例2>\n" +
|
||||
"- ...\n" +
|
||||
"\n" +
|
||||
"要求: \n" +
|
||||
"- 要求1\n" +
|
||||
"- 要求2\n" +
|
||||
"- ... \n" +
|
||||
"```\n" +
|
||||
"* 多步骤型:\n" +
|
||||
"```\n" +
|
||||
"# 角色 \n" +
|
||||
"你是<角色设定(比如:xx领域的专家)>\n" +
|
||||
"你的目标是<希望模型执行什么任务,达成什么目标>\n" +
|
||||
"\n" +
|
||||
"{#以下可以采用先总括,再展开详细说明的方式,描述你希望智能体在每一个步骤如何进行工作,具体的工作步骤数量可以根据实际需求增删#}\n" +
|
||||
"## 工作步骤 \n" +
|
||||
"1. <工作流程1的一句话概括> \n" +
|
||||
"2. <工作流程2的一句话概括> \n" +
|
||||
"3. <工作流程3的一句话概括>\n" +
|
||||
"\n" +
|
||||
"### 第一步 <工作流程1标题> \n" +
|
||||
"<工作流程步骤1的具体工作要求和举例说明,可以分点列出希望在本步骤做哪些事情,需要完成什么阶段性的工作目标>\n" +
|
||||
"### 第二步 <工作流程2标题> \n" +
|
||||
"<工作流程步骤2的具体工作要求和举例说明,可以分点列出希望在本步骤做哪些事情,需要完成什么阶段性的工作目标>\n" +
|
||||
"### 第三步 <工作流程3标题>\n" +
|
||||
"<工作流程步骤3的具体工作要求和举例说明,可以分点列出希望在本步骤做哪些事情,需要完成什么阶段性的工作目标>\n" +
|
||||
"```\n" +
|
||||
"* 限制性模板:\n" +
|
||||
"```\n" +
|
||||
"# 角色:<角色名称>\n" +
|
||||
"<角色概述和主要职责的一句话描述>\n" +
|
||||
"\n" +
|
||||
"## 目标:\n" +
|
||||
"<角色的工作目标,如果有多目标可以分点列出,但建议更聚焦1-2个目标>\n" +
|
||||
"\n" +
|
||||
"## 技能:\n" +
|
||||
"1. <为了实现目标,角色需要具备的技能1>\n" +
|
||||
"2. <为了实现目标,角色需要具备的技能2>\n" +
|
||||
"3. <为了实现目标,角色需要具备的技能3>\n" +
|
||||
"\n" +
|
||||
"## 工作流:\n" +
|
||||
"1. <描述角色工作流程的第一步>\n" +
|
||||
"2. <描述角色工作流程的第二步>\n" +
|
||||
"3. <描述角色工作流程的第三步>\n" +
|
||||
"\n" +
|
||||
"## 输出格式:\n" +
|
||||
"<如果对角色的输出格式有特定要求,可以在这里强调并举例说明想要的输出格式>\n" +
|
||||
"\n" +
|
||||
"## 限制:\n" +
|
||||
"- <描述角色在互动过程中需要遵循的限制条件1>\n" +
|
||||
"- <描述角色在互动过程中需要遵循的限制条件2>\n" +
|
||||
"- <描述角色在互动过程中需要遵循的限制条件3>\n" +
|
||||
"```\n" +
|
||||
"\n" +
|
||||
"### 3: 生成优化\n" +
|
||||
"1. 输出时自动添加三重保障机制:\n" +
|
||||
" - 反幻觉校验:\"所有数据需标注来源,不确定信息用[需核实]标记\"\n" +
|
||||
" - 风格校准器:\"对比[目标风格]与生成内容的余弦相似度,低于0.7时启动重写\"\n" +
|
||||
" - 伦理审查模块:\"自动过滤涉及隐私/偏见/违法内容,替换为[合规表达]\"";
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
package org.jeecg.modules.airag.app.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.base.controller.JeecgController;
|
||||
import org.jeecg.common.system.query.QueryGenerator;
|
||||
import org.jeecg.common.util.AssertUtils;
|
||||
import org.jeecg.config.shiro.IgnoreAuth;
|
||||
import org.jeecg.modules.airag.app.consts.AiAppConsts;
|
||||
import org.jeecg.modules.airag.app.entity.AiragApp;
|
||||
import org.jeecg.modules.airag.app.service.IAiragAppService;
|
||||
import org.jeecg.modules.airag.app.service.IAiragChatService;
|
||||
import org.jeecg.modules.airag.app.vo.AppDebugParams;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* @Description: AI应用
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-26
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/airag/app")
|
||||
@Slf4j
|
||||
public class AiragAppController extends JeecgController<AiragApp, IAiragAppService> {
|
||||
@Autowired
|
||||
private IAiragAppService airagAppService;
|
||||
|
||||
@Autowired
|
||||
private IAiragChatService airagChatService;
|
||||
|
||||
/**
|
||||
* 分页列表查询
|
||||
*
|
||||
* @param airagApp
|
||||
* @param pageNo
|
||||
* @param pageSize
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@GetMapping(value = "/list")
|
||||
public Result<IPage<AiragApp>> queryPageList(AiragApp airagApp,
|
||||
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
|
||||
HttpServletRequest req) {
|
||||
QueryWrapper<AiragApp> queryWrapper = QueryGenerator.initQueryWrapper(airagApp, req.getParameterMap());
|
||||
Page<AiragApp> page = new Page<AiragApp>(pageNo, pageSize);
|
||||
IPage<AiragApp> pageList = airagAppService.page(page, queryWrapper);
|
||||
return Result.OK(pageList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增或编辑
|
||||
*
|
||||
* @param airagApp
|
||||
* @return
|
||||
*/
|
||||
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
|
||||
public Result<String> edit(@RequestBody AiragApp airagApp) {
|
||||
AssertUtils.assertNotEmpty("参数异常", airagApp);
|
||||
AssertUtils.assertNotEmpty("请输入应用名称", airagApp.getName());
|
||||
AssertUtils.assertNotEmpty("请选择应用类型", airagApp.getType());
|
||||
airagApp.setStatus(AiAppConsts.STATUS_ENABLE);
|
||||
airagAppService.saveOrUpdate(airagApp);
|
||||
return Result.OK("保存完成!", airagApp.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过id删除
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@DeleteMapping(value = "/delete")
|
||||
public Result<String> delete(@RequestParam(name = "id", required = true) String id) {
|
||||
airagAppService.removeById(id);
|
||||
return Result.OK("删除成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除
|
||||
*
|
||||
* @param ids
|
||||
* @return
|
||||
*/
|
||||
@DeleteMapping(value = "/deleteBatch")
|
||||
public Result<String> deleteBatch(@RequestParam(name = "ids", required = true) String ids) {
|
||||
this.airagAppService.removeByIds(Arrays.asList(ids.split(",")));
|
||||
return Result.OK("批量删除成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过id查询
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@IgnoreAuth
|
||||
@GetMapping(value = "/queryById")
|
||||
public Result<AiragApp> queryById(@RequestParam(name = "id", required = true) String id) {
|
||||
AiragApp airagApp = airagAppService.getById(id);
|
||||
if (airagApp == null) {
|
||||
return Result.error("未找到对应数据");
|
||||
}
|
||||
return Result.OK(airagApp);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 调试应用
|
||||
*
|
||||
* @param appDebugParams
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/28 10:49
|
||||
*/
|
||||
@PostMapping(value = "/debug")
|
||||
public SseEmitter debugApp(@RequestBody AppDebugParams appDebugParams) {
|
||||
return airagChatService.debugApp(appDebugParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据需求生成提示词
|
||||
*
|
||||
* @param prompt
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/12 15:30
|
||||
*/
|
||||
@GetMapping(value = "/prompt/generate")
|
||||
public Result<?> generatePrompt(@RequestParam(name = "prompt", required = true) String prompt) {
|
||||
return (Result<?>) airagAppService.generatePrompt(prompt,true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据需求生成提示词
|
||||
*
|
||||
* @param prompt
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/12 15:30
|
||||
*/
|
||||
@PostMapping(value = "/prompt/generate")
|
||||
public SseEmitter generatePromptSse(@RequestParam(name = "prompt", required = true) String prompt) {
|
||||
return (SseEmitter) airagAppService.generatePrompt(prompt,false);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
package org.jeecg.modules.airag.app.controller;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.config.shiro.IgnoreAuth;
|
||||
import org.jeecg.modules.airag.app.service.IAiragChatService;
|
||||
import org.jeecg.modules.airag.app.vo.ChatConversation;
|
||||
import org.jeecg.modules.airag.app.vo.ChatSendParams;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
|
||||
/**
|
||||
* airag应用-chat
|
||||
*
|
||||
* @Author: chenrui
|
||||
* @Date: 2025-02-25 11:40
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/airag/chat")
|
||||
public class AiragChatController {
|
||||
|
||||
@Autowired
|
||||
IAiragChatService chatService;
|
||||
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*
|
||||
* @return 返回一个Result对象,表示发送消息的结果
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 11:42
|
||||
*/
|
||||
@IgnoreAuth
|
||||
@PostMapping(value = "/send")
|
||||
public SseEmitter send(@RequestBody ChatSendParams chatSendParams) {
|
||||
return chatService.send(chatSendParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息 <br/>
|
||||
* 兼容旧版浏览器
|
||||
* @param content
|
||||
* @param conversationId
|
||||
* @param topicId
|
||||
* @param appId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 18:13
|
||||
*/
|
||||
@GetMapping(value = "/send")
|
||||
public SseEmitter sendByGet(@RequestParam("content") String content,
|
||||
@RequestParam(value = "conversationId", required = false) String conversationId,
|
||||
@RequestParam(value = "topicId", required = false) String topicId,
|
||||
@RequestParam(value = "appId", required = false) String appId) {
|
||||
ChatSendParams chatSendParams = new ChatSendParams(content, conversationId, topicId, appId);
|
||||
return chatService.send(chatSendParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有对话
|
||||
*
|
||||
* @return 返回一个Result对象,包含所有对话的信息
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 11:42
|
||||
*/
|
||||
@IgnoreAuth
|
||||
@GetMapping(value = "/conversations")
|
||||
public Result<?> getConversations(@RequestParam(value = "appId", required = false) String appId) {
|
||||
return chatService.getConversations(appId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/3 16:55
|
||||
*/
|
||||
@IgnoreAuth
|
||||
@DeleteMapping(value = "/conversation/{id}")
|
||||
public Result<?> deleteConversation(@PathVariable("id") String id) {
|
||||
return chatService.deleteConversation(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话标题
|
||||
*
|
||||
* @param updateTitleParams
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/3 16:55
|
||||
*/
|
||||
@IgnoreAuth
|
||||
@PutMapping(value = "/conversation/update/title")
|
||||
public Result<?> updateConversationTitle(@RequestBody ChatConversation updateTitleParams) {
|
||||
return chatService.updateConversationTitle(updateTitleParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息
|
||||
*
|
||||
* @return 返回一个Result对象,包含消息的信息
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 11:42
|
||||
*/
|
||||
@IgnoreAuth
|
||||
@GetMapping(value = "/messages")
|
||||
public Result<?> getMessages(@RequestParam(value = "conversationId", required = true) String conversationId) {
|
||||
return chatService.getMessages(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空消息
|
||||
*
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 11:42
|
||||
*/
|
||||
@IgnoreAuth
|
||||
@GetMapping(value = "/messages/clear/{conversationId}")
|
||||
public Result<?> clearMessage(@PathVariable(value = "conversationId") String conversationId) {
|
||||
return chatService.clearMessage(conversationId);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 根据请求ID停止某个请求的处理
|
||||
*
|
||||
* @param requestId 请求的唯一标识符,用于识别和停止特定的请求
|
||||
* @return 返回一个Result对象,表示停止请求的结果
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 11:42
|
||||
*/
|
||||
@IgnoreAuth
|
||||
@GetMapping(value = "/stop/{requestId}")
|
||||
public Result<?> stop(@PathVariable(name = "requestId", required = true) String requestId) {
|
||||
return chatService.stop(requestId);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,190 @@
|
||||
package org.jeecg.modules.airag.app.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.jeecg.common.aspect.annotation.Dict;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecgframework.poi.excel.annotation.Excel;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @Description: AI应用
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-26
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Data
|
||||
@TableName("airag_app")
|
||||
@Accessors(chain = true)
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Schema(description="AI应用")
|
||||
public class AiragApp implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
@Schema(description = "主键")
|
||||
private String id;
|
||||
/**
|
||||
* 创建人
|
||||
*/
|
||||
@Schema(description = "创建人")
|
||||
private String createBy;
|
||||
/**
|
||||
* 创建日期
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "创建日期")
|
||||
private java.util.Date createTime;
|
||||
/**
|
||||
* 更新人
|
||||
*/
|
||||
@Schema(description = "更新人")
|
||||
private String updateBy;
|
||||
/**
|
||||
* 更新日期
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "更新日期")
|
||||
private java.util.Date updateTime;
|
||||
/**
|
||||
* 所属部门
|
||||
*/
|
||||
@Schema(description = "所属部门")
|
||||
private String sysOrgCode;
|
||||
/**
|
||||
* 租户id
|
||||
*/
|
||||
@Excel(name = "租户id", width = 15)
|
||||
@Schema(description = "租户id")
|
||||
private String tenantId;
|
||||
/**
|
||||
* 应用名称
|
||||
*/
|
||||
@Excel(name = "应用名称", width = 15)
|
||||
@Schema(description = "应用名称")
|
||||
private String name;
|
||||
/**
|
||||
* 应用描述
|
||||
*/
|
||||
@Excel(name = "应用描述", width = 15)
|
||||
@Schema(description = "应用描述")
|
||||
private String descr;
|
||||
/**
|
||||
* 应用图标
|
||||
*/
|
||||
@Excel(name = "应用图标", width = 15)
|
||||
@Schema(description = "应用图标")
|
||||
private String icon;
|
||||
/**
|
||||
* 应用类型
|
||||
*/
|
||||
@Excel(name = "应用类型", width = 15, dicCode = "ai_app_type")
|
||||
@Dict(dicCode = "ai_app_type")
|
||||
@Schema(description = "应用类型")
|
||||
private String type;
|
||||
/**
|
||||
* 开场白
|
||||
*/
|
||||
@Excel(name = "开场白", width = 15)
|
||||
@Schema(description = "开场白")
|
||||
private String prologue;
|
||||
/**
|
||||
* 预设问题
|
||||
*/
|
||||
@Excel(name = "预设问题", width = 15)
|
||||
@Schema(description = "预设问题")
|
||||
private String presetQuestion;
|
||||
/**
|
||||
* 提示词
|
||||
*/
|
||||
@Excel(name = "提示词", width = 15)
|
||||
@Schema(description = "提示词")
|
||||
private String prompt;
|
||||
/**
|
||||
* 模型配置
|
||||
*/
|
||||
@Excel(name = "模型配置", width = 15, dictTable = "airag_model where model_type = 'LLM' ", dicText = "name", dicCode = "id")
|
||||
@Dict(dictTable = "airag_model where model_type = 'LLM' ", dicText = "name", dicCode = "id")
|
||||
@Schema(description = "模型配置")
|
||||
private String modelId;
|
||||
/**
|
||||
* 历史消息数
|
||||
*/
|
||||
@Excel(name = "历史消息数", width = 15)
|
||||
@Schema(description = "历史消息数")
|
||||
private Integer msgNum;
|
||||
/**
|
||||
* 知识库
|
||||
*/
|
||||
@Excel(name = "知识库", width = 15, dictTable = "airag_knowledge where status = 'enable'", dicText = "name", dicCode = "id")
|
||||
@Dict(dictTable = "airag_knowledge where status = 'enable'", dicText = "name", dicCode = "id")
|
||||
@Schema(description = "知识库")
|
||||
private String knowledgeIds;
|
||||
/**
|
||||
* 流程
|
||||
*/
|
||||
@Excel(name = "流程", width = 15, dictTable = "airag_flow where status = 'enable' ", dicText = "name", dicCode = "id")
|
||||
@Dict(dictTable = "airag_flow where status = 'enable' ", dicText = "name", dicCode = "id")
|
||||
@Schema(description = "流程")
|
||||
private String flowId;
|
||||
/**
|
||||
* 快捷指令
|
||||
*/
|
||||
@Excel(name = "快捷指令", width = 15)
|
||||
@Schema(description = "快捷指令")
|
||||
private String quickCommand;
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
@Excel(name = "状态", width = 15)
|
||||
@Schema(description = "状态")
|
||||
private String status;
|
||||
|
||||
|
||||
/**
|
||||
* 元数据
|
||||
*/
|
||||
@Excel(name = "元数据", width = 15)
|
||||
@Schema(description = "元数据")
|
||||
private String metadata;
|
||||
|
||||
/**
|
||||
* 知识库ids
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private List<String> knowIds;
|
||||
|
||||
/**
|
||||
* 获取知识库id
|
||||
*
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/28 11:45
|
||||
*/
|
||||
public List<String> getKnowIds() {
|
||||
if (oConvertUtils.isNotEmpty(knowledgeIds)) {
|
||||
String[] knowIds = knowledgeIds.split(",");
|
||||
return Arrays.asList(knowIds);
|
||||
} else {
|
||||
return new ArrayList<>(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package org.jeecg.modules.airag.app.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.jeecg.modules.airag.app.entity.AiragApp;
|
||||
|
||||
/**
|
||||
* @Description: AI应用
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-26
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface AiragAppMapper extends BaseMapper<AiragApp> {
|
||||
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.jeecg.modules.airag.app.mapper.AiragAppMapper">
|
||||
|
||||
</mapper>
|
||||
@ -0,0 +1,24 @@
|
||||
package org.jeecg.modules.airag.app.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.modules.airag.app.entity.AiragApp;
|
||||
|
||||
/**
|
||||
* @Description: AI应用
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-26
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface IAiragAppService extends IService<AiragApp> {
|
||||
|
||||
/**
|
||||
* 生成提示词
|
||||
* @param prompt
|
||||
* @return blocking 是否阻塞
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/12 14:45
|
||||
*/
|
||||
Object generatePrompt(String prompt,boolean blocking);
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package org.jeecg.modules.airag.app.service;
|
||||
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.modules.airag.app.vo.AppDebugParams;
|
||||
import org.jeecg.modules.airag.app.vo.ChatConversation;
|
||||
import org.jeecg.modules.airag.app.vo.ChatSendParams;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
/**
|
||||
* ai聊天
|
||||
*
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 13:36
|
||||
*/
|
||||
public interface IAiragChatService {
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*
|
||||
* @param chatSendParams
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 13:39
|
||||
*/
|
||||
SseEmitter send(ChatSendParams chatSendParams);
|
||||
|
||||
|
||||
/**
|
||||
* 调试应用
|
||||
*
|
||||
* @param appDebugParams
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/28 10:49
|
||||
*/
|
||||
SseEmitter debugApp(AppDebugParams appDebugParams);
|
||||
|
||||
/**
|
||||
* 停止响应
|
||||
*
|
||||
* @param requestId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 17:17
|
||||
*/
|
||||
Result<?> stop(String requestId);
|
||||
|
||||
/**
|
||||
* 获取所有对话
|
||||
*
|
||||
* @param appId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/26 14:48
|
||||
*/
|
||||
Result<?> getConversations(String appId);
|
||||
|
||||
/**
|
||||
* 获取对话聊天记录
|
||||
*
|
||||
* @param conversationId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/26 15:16
|
||||
*/
|
||||
Result<?> getMessages(String conversationId);
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*
|
||||
* @param conversationId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/3 16:55
|
||||
*/
|
||||
Result<?> deleteConversation(String conversationId);
|
||||
|
||||
/**
|
||||
* 更新会话标题
|
||||
* @param updateTitleParams
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/3 17:02
|
||||
*/
|
||||
Result<?> updateConversationTitle(ChatConversation updateTitleParams);
|
||||
|
||||
/**
|
||||
* 清空消息
|
||||
* @param conversationId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/3 19:49
|
||||
*/
|
||||
Result<?> clearMessage(String conversationId);
|
||||
}
|
||||
@ -0,0 +1,157 @@
|
||||
package org.jeecg.modules.airag.app.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import dev.langchain4j.data.message.AiMessage;
|
||||
import dev.langchain4j.data.message.ChatMessage;
|
||||
import dev.langchain4j.data.message.SystemMessage;
|
||||
import dev.langchain4j.data.message.UserMessage;
|
||||
import dev.langchain4j.model.output.FinishReason;
|
||||
import dev.langchain4j.service.TokenStream;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.util.AssertUtils;
|
||||
import org.jeecg.common.util.UUIDGenerator;
|
||||
import org.jeecg.modules.airag.app.consts.Prompts;
|
||||
import org.jeecg.modules.airag.app.entity.AiragApp;
|
||||
import org.jeecg.modules.airag.app.mapper.AiragAppMapper;
|
||||
import org.jeecg.modules.airag.app.service.IAiragAppService;
|
||||
import org.jeecg.modules.airag.common.consts.AiragConsts;
|
||||
import org.jeecg.modules.airag.common.handler.AIChatParams;
|
||||
import org.jeecg.modules.airag.common.handler.IAIChatHandler;
|
||||
import org.jeecg.modules.airag.common.utils.AiragLocalCache;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventData;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventFlowData;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventMessageData;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* @Description: AI应用
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-26
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AiragAppServiceImpl extends ServiceImpl<AiragAppMapper, AiragApp> implements IAiragAppService {
|
||||
|
||||
@Autowired
|
||||
IAIChatHandler aiChatHandler;
|
||||
|
||||
@Override
|
||||
public Object generatePrompt(String prompt, boolean blocking) {
|
||||
AssertUtils.assertNotEmpty("请输入提示词", prompt);
|
||||
List<ChatMessage> messages = Arrays.asList(new SystemMessage(Prompts.GENERATE_LLM_PROMPT), new UserMessage(prompt));
|
||||
|
||||
AIChatParams params = new AIChatParams();
|
||||
params.setTemperature(0.8);
|
||||
params.setTopP(0.9);
|
||||
params.setPresencePenalty(0.1);
|
||||
params.setFrequencyPenalty(0.1);
|
||||
if(blocking){
|
||||
String promptValue = aiChatHandler.completionsByDefaultModel(messages, params);
|
||||
if (promptValue == null || promptValue.isEmpty()) {
|
||||
return Result.error("生成失败");
|
||||
}
|
||||
return Result.OK("success", promptValue);
|
||||
}else{
|
||||
SseEmitter emitter = new SseEmitter(-0L);
|
||||
// 异步运行(流式)
|
||||
TokenStream tokenStream = aiChatHandler.chatByDefaultModel(messages, params);
|
||||
/**
|
||||
* 是否正在思考
|
||||
*/
|
||||
AtomicBoolean isThinking = new AtomicBoolean(false);
|
||||
String requestId = UUIDGenerator.generate();
|
||||
// ai聊天响应逻辑
|
||||
tokenStream.onNext((String resMessage) -> {
|
||||
// 兼容推理模型
|
||||
if ("<think>".equals(resMessage)) {
|
||||
isThinking.set(true);
|
||||
resMessage = "> ";
|
||||
}
|
||||
if ("</think>".equals(resMessage)) {
|
||||
isThinking.set(false);
|
||||
resMessage = "\n\n";
|
||||
}
|
||||
if (isThinking.get()) {
|
||||
if (null != resMessage && resMessage.contains("\n")) {
|
||||
resMessage = "\n> ";
|
||||
}
|
||||
}
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_MESSAGE);
|
||||
EventMessageData messageEventData = EventMessageData.builder()
|
||||
.message(resMessage)
|
||||
.build();
|
||||
eventData.setData(messageEventData);
|
||||
try {
|
||||
String eventStr = JSONObject.toJSONString(eventData);
|
||||
log.debug("[AI应用]接收LLM返回消息:{}", eventStr);
|
||||
emitter.send(SseEmitter.event().data(eventStr));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
})
|
||||
.onComplete((responseMessage) -> {
|
||||
// 记录ai的回复
|
||||
AiMessage aiMessage = responseMessage.content();
|
||||
FinishReason finishReason = responseMessage.finishReason();
|
||||
String respText = aiMessage.text();
|
||||
if (FinishReason.STOP.equals(finishReason) || null == finishReason) {
|
||||
// 正常结束
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_MESSAGE_END);
|
||||
try {
|
||||
log.debug("[AI应用]接收LLM返回消息完成:{}", respText);
|
||||
emitter.send(SseEmitter.event().data(eventData));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
closeSSE(emitter, eventData);
|
||||
} else if (FinishReason.TOOL_EXECUTION.equals(finishReason)) {
|
||||
// 需要执行工具
|
||||
// TODO author: chenrui for: date:2025/3/7
|
||||
} else {
|
||||
// 异常结束
|
||||
log.error("调用模型异常:" + respText);
|
||||
if (respText.contains("insufficient Balance")) {
|
||||
respText = "大预言模型账号余额不足!";
|
||||
}
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_FLOW_ERROR);
|
||||
eventData.setData(EventFlowData.builder().success(false).message(respText).build());
|
||||
closeSSE(emitter, eventData);
|
||||
}
|
||||
})
|
||||
.onError((Throwable error) -> {
|
||||
// sse
|
||||
String errMsg = "调用大模型接口失败:" + error.getMessage();
|
||||
log.error(errMsg, error);
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_FLOW_ERROR);
|
||||
eventData.setData(EventFlowData.builder().success(false).message(errMsg).build());
|
||||
closeSSE(emitter, eventData);
|
||||
})
|
||||
.start();
|
||||
return emitter;
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeSSE(SseEmitter emitter, EventData eventData) {
|
||||
try {
|
||||
// 发送完成事件
|
||||
emitter.send(SseEmitter.event().data(eventData));
|
||||
} catch (IOException e) {
|
||||
log.error("终止会话时发生错误", e);
|
||||
} finally {
|
||||
// 从缓存中移除emitter
|
||||
AiragLocalCache.remove(AiragConsts.CACHE_TYPE_SSE, eventData.getRequestId());
|
||||
// 关闭emitter
|
||||
emitter.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,890 @@
|
||||
package org.jeecg.modules.airag.app.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import dev.langchain4j.data.image.Image;
|
||||
import dev.langchain4j.data.message.*;
|
||||
import dev.langchain4j.model.output.FinishReason;
|
||||
import dev.langchain4j.service.TokenStream;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.api.ISysBaseAPI;
|
||||
import org.jeecg.common.system.util.JwtUtil;
|
||||
import org.jeecg.common.util.*;
|
||||
import org.jeecg.modules.airag.app.consts.AiAppConsts;
|
||||
import org.jeecg.modules.airag.app.entity.AiragApp;
|
||||
import org.jeecg.modules.airag.app.service.IAiragAppService;
|
||||
import org.jeecg.modules.airag.app.service.IAiragChatService;
|
||||
import org.jeecg.modules.airag.app.vo.AppDebugParams;
|
||||
import org.jeecg.modules.airag.app.vo.ChatConversation;
|
||||
import org.jeecg.modules.airag.app.vo.ChatSendParams;
|
||||
import org.jeecg.modules.airag.common.consts.AiragConsts;
|
||||
import org.jeecg.modules.airag.common.handler.AIChatParams;
|
||||
import org.jeecg.modules.airag.common.handler.IAIChatHandler;
|
||||
import org.jeecg.modules.airag.common.utils.AiragLocalCache;
|
||||
import org.jeecg.modules.airag.common.vo.MessageHistory;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventData;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventFlowData;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventMessageData;
|
||||
import org.jeecg.modules.airag.flow.consts.FlowConsts;
|
||||
import org.jeecg.modules.airag.flow.service.IAiragFlowService;
|
||||
import org.jeecg.modules.airag.flow.vo.api.FlowRunParams;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.BoundValueOperations;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
||||
/**
|
||||
* AI助手聊天Service
|
||||
*
|
||||
* @author chenrui
|
||||
* @date 2024/1/26 20:07
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class AiragChatServiceImpl implements IAiragChatService {
|
||||
|
||||
@Autowired
|
||||
IAIChatHandler aiChatHandler;
|
||||
|
||||
@Autowired
|
||||
RedisTemplate redisTemplate;
|
||||
|
||||
@Autowired
|
||||
IAiragAppService airagAppService;
|
||||
|
||||
@Autowired
|
||||
IAiragFlowService airagFlowService;
|
||||
|
||||
@Autowired
|
||||
private ISysBaseAPI sysBaseApi;
|
||||
@Autowired
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
@Override
|
||||
public SseEmitter send(ChatSendParams chatSendParams) {
|
||||
AssertUtils.assertNotEmpty("参数异常", chatSendParams);
|
||||
String userMessage = chatSendParams.getContent();
|
||||
AssertUtils.assertNotEmpty("至少发送一条消息", userMessage);
|
||||
|
||||
// 获取会话信息
|
||||
String conversationId = chatSendParams.getConversationId();
|
||||
String topicId = oConvertUtils.getString(chatSendParams.getTopicId(), UUIDGenerator.generate());
|
||||
// 获取app信息
|
||||
AiragApp app = null;
|
||||
if (oConvertUtils.isNotEmpty(chatSendParams.getAppId())) {
|
||||
app = airagAppService.getById(chatSendParams.getAppId());
|
||||
}
|
||||
ChatConversation chatConversation = getOrCreateChatConversation(app, conversationId);
|
||||
// 更新标题
|
||||
if (oConvertUtils.isEmpty(chatConversation.getTitle())) {
|
||||
chatConversation.setTitle(userMessage.length() > 5 ? userMessage.substring(0, 5) : userMessage);
|
||||
}
|
||||
// 发送消息
|
||||
return doChat(chatConversation, topicId, chatSendParams);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SseEmitter debugApp(AppDebugParams appDebugParams) {
|
||||
AssertUtils.assertNotEmpty("参数异常", appDebugParams);
|
||||
String userMessage = appDebugParams.getContent();
|
||||
AssertUtils.assertNotEmpty("至少发送一条消息", userMessage);
|
||||
AssertUtils.assertNotEmpty("应用信息不能为空", appDebugParams.getApp());
|
||||
// 获取会话信息
|
||||
String topicId = oConvertUtils.getString(appDebugParams.getTopicId(), UUIDGenerator.generate());
|
||||
AiragApp app = appDebugParams.getApp();
|
||||
app.setId("__DEBUG_APP");
|
||||
ChatConversation chatConversation = getOrCreateChatConversation(app, topicId);
|
||||
// 发送消息
|
||||
SseEmitter emitter = doChat(chatConversation, topicId, appDebugParams);
|
||||
//保存会话
|
||||
saveChatConversation(chatConversation, true, null);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Result<?> stop(String requestId) {
|
||||
AssertUtils.assertNotEmpty("requestId不能为空", requestId);
|
||||
// 从缓存中获取对应的SseEmitter
|
||||
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
|
||||
if (emitter != null) {
|
||||
closeSSE(emitter, new EventData(requestId, null, EventData.EVENT_MESSAGE_END));
|
||||
return Result.ok("会话已成功终止");
|
||||
} else {
|
||||
return Result.error("未找到对应的会话");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭sse
|
||||
*
|
||||
* @param emitter
|
||||
* @param eventData
|
||||
* @throws IOException
|
||||
* @author chenrui
|
||||
* @date 2025/2/27 15:56
|
||||
*/
|
||||
private static void closeSSE(SseEmitter emitter, EventData eventData) {
|
||||
AssertUtils.assertNotEmpty("请求id不能为空", eventData);
|
||||
if (null == emitter) {
|
||||
log.warn("会话已关闭");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 发送完成事件
|
||||
emitter.send(SseEmitter.event().data(eventData));
|
||||
} catch (IOException e) {
|
||||
log.error("终止会话时发生错误", e);
|
||||
} finally {
|
||||
// 从缓存中移除emitter
|
||||
AiragLocalCache.remove(AiragConsts.CACHE_TYPE_SSE, eventData.getRequestId());
|
||||
// 关闭emitter
|
||||
emitter.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> getConversations(String appId) {
|
||||
if (oConvertUtils.isEmpty(appId)) {
|
||||
appId = AiAppConsts.DEFAULT_APP_ID;
|
||||
}
|
||||
String key = getConversationDirCacheKey(null);
|
||||
key = key + ":*";
|
||||
List<String> keys = redisUtil.scan(key);
|
||||
// 如果键集合为空,返回空列表
|
||||
if (keys.isEmpty()) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
}
|
||||
|
||||
// 遍历键集合,获取对应的 ChatConversation 对象
|
||||
List<ChatConversation> conversations = new ArrayList<>();
|
||||
for (Object k : keys) {
|
||||
ChatConversation conversation = (ChatConversation) redisTemplate.boundValueOps(k).get();
|
||||
|
||||
if (conversation != null) {
|
||||
AiragApp app = conversation.getApp();
|
||||
if (null == app) {
|
||||
continue;
|
||||
}
|
||||
String conversationAppId = app.getId();
|
||||
if (appId.equals(conversationAppId)) {
|
||||
conversation.setApp(null);
|
||||
conversation.setMessages(null);
|
||||
conversations.add(conversation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 对会话列表按创建时间降序排序
|
||||
conversations.sort((o1, o2) -> {
|
||||
Date date1 = o1.getCreateTime();
|
||||
Date date2 = o2.getCreateTime();
|
||||
if (date1 == null && date2 == null) {
|
||||
return 0;
|
||||
}
|
||||
if (date1 == null) {
|
||||
return 1;
|
||||
}
|
||||
if (date2 == null) {
|
||||
return -1;
|
||||
}
|
||||
return date2.compareTo(date1);
|
||||
});
|
||||
|
||||
// 返回结果
|
||||
return Result.ok(conversations);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> getMessages(String conversationId) {
|
||||
AssertUtils.assertNotEmpty("请先选择会话", conversationId);
|
||||
String key = getConversationCacheKey(conversationId, null);
|
||||
if (oConvertUtils.isEmpty(key)) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
}
|
||||
ChatConversation chatConversation = (ChatConversation) redisTemplate.boundValueOps(key).get();
|
||||
if (oConvertUtils.isObjectEmpty(chatConversation)) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
}
|
||||
return Result.ok(chatConversation.getMessages());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> clearMessage(String conversationId) {
|
||||
AssertUtils.assertNotEmpty("请先选择会话", conversationId);
|
||||
String key = getConversationCacheKey(conversationId, null);
|
||||
if (oConvertUtils.isEmpty(key)) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
}
|
||||
ChatConversation chatConversation = (ChatConversation) redisTemplate.boundValueOps(key).get();
|
||||
if (null != chatConversation && oConvertUtils.isObjectNotEmpty(chatConversation.getMessages())) {
|
||||
chatConversation.getMessages().clear();
|
||||
saveChatConversation(chatConversation);
|
||||
}
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> deleteConversation(String conversationId) {
|
||||
AssertUtils.assertNotEmpty("请选择要删除的会话", conversationId);
|
||||
String key = getConversationCacheKey(conversationId, null);
|
||||
if (oConvertUtils.isNotEmpty(key)) {
|
||||
Boolean delete = redisTemplate.delete(key);
|
||||
if (delete) {
|
||||
return Result.ok();
|
||||
} else {
|
||||
return Result.error("删除会话失败");
|
||||
}
|
||||
}
|
||||
log.warn("[ai-chat]删除会话:未找到会话:{}", conversationId);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> updateConversationTitle(ChatConversation updateTitleParams) {
|
||||
AssertUtils.assertNotEmpty("请先选择会话", updateTitleParams);
|
||||
AssertUtils.assertNotEmpty("请先选择会话", updateTitleParams.getId());
|
||||
AssertUtils.assertNotEmpty("请输入会话标题", updateTitleParams.getTitle());
|
||||
String key = getConversationCacheKey(updateTitleParams.getId(), null);
|
||||
if (oConvertUtils.isEmpty(key)) {
|
||||
log.warn("[ai-chat]删除会话:未找到会话:{}", updateTitleParams.getId());
|
||||
return Result.ok();
|
||||
}
|
||||
ChatConversation chatConversation = (ChatConversation) redisTemplate.boundValueOps(key).get();
|
||||
chatConversation.setTitle(updateTitleParams.getTitle());
|
||||
saveChatConversation(chatConversation);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话缓存key
|
||||
*
|
||||
* @param conversationId
|
||||
* @param httpRequest
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 19:27
|
||||
*/
|
||||
private String getConversationCacheKey(String conversationId,HttpServletRequest httpRequest) {
|
||||
if (oConvertUtils.isEmpty(conversationId)) {
|
||||
return null;
|
||||
}
|
||||
String key = getConversationDirCacheKey(httpRequest);
|
||||
key = key + ":" + conversationId;
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户会话的缓存目录
|
||||
*
|
||||
* @param httpRequest
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/26 15:09
|
||||
*/
|
||||
private String getConversationDirCacheKey(HttpServletRequest httpRequest) {
|
||||
String username = getUsername(httpRequest);
|
||||
// 如果用户不存在,获取当前请求的sessionid
|
||||
if (oConvertUtils.isEmpty(username)) {
|
||||
try {
|
||||
if (null == httpRequest) {
|
||||
httpRequest = SpringContextUtils.getHttpServletRequest();
|
||||
}
|
||||
username = httpRequest.getSession().getId();
|
||||
} catch (Exception e) {
|
||||
log.error("获取当前请求的sessionid失败", e);
|
||||
}
|
||||
}
|
||||
AssertUtils.assertNotEmpty("请先登录", username);
|
||||
return "airag:chat:" + username;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话
|
||||
*
|
||||
* @param app
|
||||
* @param conversationId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 19:19
|
||||
*/
|
||||
@NotNull
|
||||
private ChatConversation getOrCreateChatConversation(AiragApp app, String conversationId) {
|
||||
if (oConvertUtils.isObjectEmpty(app)) {
|
||||
app = new AiragApp();
|
||||
app.setId(AiAppConsts.DEFAULT_APP_ID);
|
||||
}
|
||||
ChatConversation chatConversation = null;
|
||||
String key = getConversationCacheKey(conversationId, null);
|
||||
if (oConvertUtils.isNotEmpty(key)) {
|
||||
chatConversation = (ChatConversation) redisTemplate.boundValueOps(key).get();
|
||||
}
|
||||
if (null == chatConversation) {
|
||||
chatConversation = createConversation(conversationId);
|
||||
}
|
||||
chatConversation.setApp(app);
|
||||
return chatConversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的会话
|
||||
*
|
||||
* @param conversationId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/26 15:53
|
||||
*/
|
||||
@NotNull
|
||||
private ChatConversation createConversation(String conversationId) {
|
||||
// 新会话
|
||||
conversationId = oConvertUtils.getString(conversationId, UUIDGenerator.generate());
|
||||
ChatConversation chatConversation = new ChatConversation();
|
||||
chatConversation.setId(conversationId);
|
||||
chatConversation.setCreateTime(new Date());
|
||||
return chatConversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话
|
||||
*
|
||||
* @param chatConversation
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 19:27
|
||||
*/
|
||||
private void saveChatConversation(ChatConversation chatConversation) {
|
||||
saveChatConversation(chatConversation, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话
|
||||
*
|
||||
* @param chatConversation
|
||||
* @param temp 是否临时会话
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 19:27
|
||||
*/
|
||||
private void saveChatConversation(ChatConversation chatConversation, boolean temp,HttpServletRequest httpRequest) {
|
||||
if (null == chatConversation) {
|
||||
return;
|
||||
}
|
||||
String key = getConversationCacheKey(chatConversation.getId(), httpRequest);
|
||||
if (oConvertUtils.isEmpty(key)) {
|
||||
return;
|
||||
}
|
||||
BoundValueOperations chatRedisCacheOp = redisTemplate.boundValueOps(key);
|
||||
chatRedisCacheOp.set(chatConversation);
|
||||
if (temp) {
|
||||
chatRedisCacheOp.expire(3, TimeUnit.HOURS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造消息
|
||||
*
|
||||
* @param conversation
|
||||
* @param topicId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 15:26
|
||||
*/
|
||||
private List<ChatMessage> collateMessage(ChatConversation conversation, String topicId) {
|
||||
List<MessageHistory> messagesHistory = conversation.getMessages();
|
||||
if (oConvertUtils.isObjectEmpty(messagesHistory)) {
|
||||
return new LinkedList<>();
|
||||
}
|
||||
LinkedList<ChatMessage> chatMessages = new LinkedList<>();
|
||||
for (int i = messagesHistory.size() - 1; i >= 0; i--) {
|
||||
MessageHistory history = messagesHistory.get(i);
|
||||
if (topicId.equals(history.getTopicId())) {
|
||||
ChatMessage chatMessage = null;
|
||||
switch (history.getRole()) {
|
||||
case AiragConsts.MESSAGE_ROLE_USER:
|
||||
List<Content> contents = new ArrayList<>();
|
||||
List<MessageHistory.ImageHistory> images = history.getImages();
|
||||
if (oConvertUtils.isObjectNotEmpty(images)
|
||||
&& !images.isEmpty()) {
|
||||
contents.addAll(images.stream().map(imageHistory -> {
|
||||
if (oConvertUtils.isNotEmpty(imageHistory.getUrl())) {
|
||||
return ImageContent.from(imageHistory.getUrl());
|
||||
} else {
|
||||
return ImageContent.from(imageHistory.getBase64Data(), imageHistory.getMimeType());
|
||||
}
|
||||
}).collect(Collectors.toList()));
|
||||
}
|
||||
contents.add(TextContent.from(history.getContent()));
|
||||
chatMessage = UserMessage.from(contents);
|
||||
break;
|
||||
case AiragConsts.MESSAGE_ROLE_AI:
|
||||
chatMessage = new AiMessage(history.getContent());
|
||||
break;
|
||||
}
|
||||
if (null == chatMessage) {
|
||||
continue;
|
||||
}
|
||||
chatMessages.addFirst(chatMessage);
|
||||
}
|
||||
}
|
||||
return chatMessages;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 追加消息
|
||||
*
|
||||
* @param messages
|
||||
* @param message
|
||||
* @param chatConversation
|
||||
* @param topicId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 19:05
|
||||
*/
|
||||
private void appendMessage(List<ChatMessage> messages, ChatMessage message, ChatConversation
|
||||
chatConversation, String topicId) {
|
||||
|
||||
if (message.type().equals(ChatMessageType.SYSTEM)) {
|
||||
// 系统消息,放到消息列表最前面,并且不记录历史
|
||||
messages.add(0, message);
|
||||
return;
|
||||
} else {
|
||||
messages.add(message);
|
||||
}
|
||||
List<MessageHistory> histories = chatConversation.getMessages();
|
||||
if (oConvertUtils.isObjectEmpty(histories)) {
|
||||
histories = new ArrayList<>();
|
||||
}
|
||||
// 消息记录
|
||||
MessageHistory historyMessage = MessageHistory.builder()
|
||||
.conversationId(chatConversation.getId())
|
||||
.topicId(topicId)
|
||||
.datetime(DateUtils.now())
|
||||
.build();
|
||||
if (message.type().equals(ChatMessageType.USER)) {
|
||||
historyMessage.setRole(AiragConsts.MESSAGE_ROLE_USER);
|
||||
StringBuilder textContent = new StringBuilder();
|
||||
List<MessageHistory.ImageHistory> images = new ArrayList<>();
|
||||
List<Content> contents = ((UserMessage) message).contents();
|
||||
contents.forEach(content -> {
|
||||
if (content.type().equals(ContentType.IMAGE)) {
|
||||
ImageContent imageContent = (ImageContent) content;
|
||||
Image image = imageContent.image();
|
||||
MessageHistory.ImageHistory imageMessage = MessageHistory.ImageHistory.from(image.url(), image.base64Data(), image.mimeType());
|
||||
images.add(imageMessage);
|
||||
} else if (content.type().equals(ContentType.TEXT)) {
|
||||
textContent.append(((TextContent) content).text()).append("\n");
|
||||
}
|
||||
});
|
||||
historyMessage.setContent(textContent.toString());
|
||||
historyMessage.setImages(images);
|
||||
} else if (message.type().equals(ChatMessageType.AI)) {
|
||||
historyMessage.setRole(AiragConsts.MESSAGE_ROLE_AI);
|
||||
historyMessage.setContent(((AiMessage) message).text());
|
||||
}
|
||||
histories.add(historyMessage);
|
||||
chatConversation.setMessages(histories);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息
|
||||
*
|
||||
* @param chatConversation
|
||||
* @param topicId
|
||||
* @param sendParams
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/28 11:04
|
||||
*/
|
||||
@NotNull
|
||||
private SseEmitter doChat(ChatConversation chatConversation, String topicId, ChatSendParams sendParams) {
|
||||
// 从历史消息中组装本次的消息列表
|
||||
List<ChatMessage> messages = collateMessage(chatConversation, topicId);
|
||||
|
||||
AiragApp aiApp = chatConversation.getApp();
|
||||
// 每次会话都生成一个新的,用来缓存emitter
|
||||
String requestId = UUIDGenerator.generate();
|
||||
SseEmitter emitter = new SseEmitter(-0L);
|
||||
// 缓存emitter
|
||||
AiragLocalCache.put(AiragConsts.CACHE_TYPE_SSE, requestId, emitter);
|
||||
try {
|
||||
// 组装用户消息
|
||||
UserMessage userMessage = aiChatHandler.buildUserMessage(sendParams.getContent(), sendParams.getImages());
|
||||
// 追加消息
|
||||
appendMessage(messages, userMessage, chatConversation, topicId);
|
||||
/* 这里应该是有几种情况:
|
||||
* 1. 非ai应用:获取默认模型->开始聊天
|
||||
* 2. AI应用-聊天助手(ChatAssistant):从应用信息组装模型和提示词->开始聊天
|
||||
* 3. AI应用-聊天流程(ChatFlow):从应用信息获取模型,流程,组装入参->调用工作流
|
||||
*/
|
||||
if (null != aiApp && !AiAppConsts.DEFAULT_APP_ID.equals(aiApp.getId())) {
|
||||
// ai应用:查询应用信息(ChatAssistant,chatflow),模型信息,组装模型-提示词,知识库等
|
||||
if (AiAppConsts.APP_TYPE_CHAT_FLOW.equals(aiApp.getType())) {
|
||||
// ai应用:聊天流程(ChatFlow)
|
||||
sendWithFlow(requestId, aiApp.getFlowId(), chatConversation, topicId, messages, sendParams);
|
||||
} else {
|
||||
// AI应用-聊天助手(ChatAssistant):从应用信息组装模型和提示词
|
||||
sendWithAppChat(requestId, messages, chatConversation, topicId);
|
||||
}
|
||||
} else {
|
||||
// 发消息
|
||||
sendWithDefault(requestId, chatConversation, topicId, null, messages, null);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
log.error(e.getMessage(), e);
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_FLOW_ERROR, chatConversation.getId(), topicId);
|
||||
eventData.setData(EventFlowData.builder().success(false).message(e.getMessage()).build());
|
||||
closeSSE(emitter, eventData);
|
||||
}
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行流程
|
||||
*
|
||||
* @param requestId
|
||||
* @param flowId
|
||||
* @param chatConversation
|
||||
* @param topicId
|
||||
* @param messages
|
||||
* @param sendParams
|
||||
* @author chenrui
|
||||
* @date 2025/2/27 14:55
|
||||
*/
|
||||
private void sendWithFlow(String requestId, String flowId, ChatConversation chatConversation, String topicId, List<ChatMessage> messages, ChatSendParams sendParams) {
|
||||
FlowRunParams flowRunParams = new FlowRunParams();
|
||||
flowRunParams.setRequestId(requestId);
|
||||
flowRunParams.setFlowId(flowId);
|
||||
flowRunParams.setConversationId(chatConversation.getId());
|
||||
flowRunParams.setTopicId(topicId);
|
||||
// 支持流式
|
||||
flowRunParams.setResponseMode(FlowConsts.FLOW_RESPONSE_MODE_STREAMING);
|
||||
Map<String, Object> flowInputParams = new HashMap<>();
|
||||
List<MessageHistory> histories = new ArrayList<>();
|
||||
if (oConvertUtils.isObjectNotEmpty(chatConversation.getMessages())) {
|
||||
// 创建历史消息的副本(不直接操作原来的list)
|
||||
histories.addAll(chatConversation.getMessages());
|
||||
// 移除最后一条历史消息(最后一条是当前发出去的这一条消息)
|
||||
histories.remove(histories.size() - 1);
|
||||
}
|
||||
flowInputParams.put(FlowConsts.FLOW_INPUT_PARAM_HISTORY, histories);
|
||||
flowInputParams.put(FlowConsts.FLOW_INPUT_PARAM_QUESTION, sendParams.getContent());
|
||||
flowInputParams.put(FlowConsts.FLOW_INPUT_PARAM_IMAGES, sendParams.getImages());
|
||||
flowRunParams.setInputParams(flowInputParams);
|
||||
HttpServletRequest httpRequest = SpringContextUtils.getHttpServletRequest();
|
||||
flowRunParams.setHttpRequest(httpRequest);
|
||||
// 流程结束后,记录ai返回并保存会话
|
||||
// sse
|
||||
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
|
||||
flowRunParams.setEventCallback(eventData -> {
|
||||
if (EventData.EVENT_FLOW_FINISHED.equals(eventData.getEvent())) {
|
||||
EventFlowData data = (EventFlowData) eventData.getData();
|
||||
Object outputs = data.getOutputs();
|
||||
if (oConvertUtils.isObjectNotEmpty(outputs)) {
|
||||
AiMessage aiMessage;
|
||||
if (outputs instanceof String) {
|
||||
// 兼容推理模型
|
||||
String messageText = String.valueOf(outputs);
|
||||
messageText = messageText.replaceAll("<think>([\\s\\S]*?)</think>", "> $1");
|
||||
aiMessage = new AiMessage(messageText);
|
||||
} else {
|
||||
aiMessage = new AiMessage(JSONObject.toJSONString(outputs));
|
||||
}
|
||||
EventData msgEventData = new EventData(requestId, null, EventData.EVENT_MESSAGE, chatConversation.getId(), topicId);
|
||||
EventMessageData messageEventData = EventMessageData.builder()
|
||||
.message(aiMessage.text())
|
||||
.build();
|
||||
msgEventData.setData(messageEventData);
|
||||
try {
|
||||
String eventStr = JSONObject.toJSONString(msgEventData);
|
||||
log.debug("[AI应用]接收FLOW返回消息:{}", eventStr);
|
||||
emitter.send(SseEmitter.event().data(eventStr));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
appendMessage(messages, aiMessage, chatConversation, topicId);
|
||||
// 保存会话
|
||||
saveChatConversation(chatConversation, false, httpRequest);
|
||||
}
|
||||
}
|
||||
});
|
||||
airagFlowService.runFlow(flowRunParams);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 发送app聊天
|
||||
*
|
||||
* @param requestId
|
||||
* @param messages
|
||||
* @param chatConversation
|
||||
* @param topicId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/28 10:41
|
||||
*/
|
||||
private void sendWithAppChat(String requestId, List<ChatMessage> messages, ChatConversation chatConversation, String topicId) {
|
||||
AiragApp aiApp = chatConversation.getApp();
|
||||
String modelId = aiApp.getModelId();
|
||||
AssertUtils.assertNotEmpty("请先选择模型", modelId);
|
||||
// AI应用提示词
|
||||
String prompt = aiApp.getPrompt();
|
||||
if (oConvertUtils.isNotEmpty(prompt)) {
|
||||
appendMessage(messages, new SystemMessage(prompt), chatConversation, topicId);
|
||||
}
|
||||
|
||||
AIChatParams aiChatParams = new AIChatParams();
|
||||
// AI应用自定义的模型参数
|
||||
String metadataStr = aiApp.getMetadata();
|
||||
if (oConvertUtils.isNotEmpty(metadataStr)) {
|
||||
JSONObject metadata = JSONObject.parseObject(metadataStr);
|
||||
if(oConvertUtils.isNotEmpty(metadata)){
|
||||
if (metadata.containsKey("temperature")) {
|
||||
aiChatParams.setTemperature(metadata.getDouble("temperature"));
|
||||
}
|
||||
if (metadata.containsKey("topP")) {
|
||||
aiChatParams.setTopP(metadata.getDouble("temperature"));
|
||||
}
|
||||
if (metadata.containsKey("presencePenalty")) {
|
||||
aiChatParams.setPresencePenalty(metadata.getDouble("temperature"));
|
||||
}
|
||||
if (metadata.containsKey("frequencyPenalty")) {
|
||||
aiChatParams.setFrequencyPenalty(metadata.getDouble("temperature"));
|
||||
}
|
||||
if (metadata.containsKey("maxTokens")) {
|
||||
aiChatParams.setMaxTokens(metadata.getInteger("temperature"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 发消息
|
||||
sendWithDefault(requestId, chatConversation, topicId, modelId, messages, aiChatParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理聊天
|
||||
* 向大模型发送消息并接受响应
|
||||
*
|
||||
* @param chatConversation
|
||||
* @param topicId
|
||||
* @param modelId
|
||||
* @param messages
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 19:24
|
||||
*/
|
||||
private void sendWithDefault(String requestId, ChatConversation chatConversation, String topicId, String modelId,
|
||||
List<ChatMessage> messages,AIChatParams aiChatParams) {
|
||||
// 调用ai聊天
|
||||
if(null == aiChatParams){
|
||||
aiChatParams = new AIChatParams();
|
||||
}
|
||||
aiChatParams.setKnowIds(chatConversation.getApp().getKnowIds());
|
||||
aiChatParams.setMaxMsgNumber(oConvertUtils.getInt(chatConversation.getApp().getMsgNum(), 5));
|
||||
HttpServletRequest httpRequest = SpringContextUtils.getHttpServletRequest();
|
||||
TokenStream chatStream = aiChatHandler.chatByDefaultModel(messages, aiChatParams);
|
||||
/**
|
||||
* 是否正在思考
|
||||
*/
|
||||
AtomicBoolean isThinking = new AtomicBoolean(false);
|
||||
// ai聊天响应逻辑
|
||||
chatStream.onNext((String resMessage) -> {
|
||||
// 兼容推理模型
|
||||
if ("<think>".equals(resMessage)) {
|
||||
isThinking.set(true);
|
||||
resMessage = "> ";
|
||||
}
|
||||
if ("</think>".equals(resMessage)) {
|
||||
isThinking.set(false);
|
||||
resMessage = "\n\n";
|
||||
}
|
||||
if (isThinking.get()) {
|
||||
if (null != resMessage && resMessage.contains("\n")) {
|
||||
resMessage = "\n> ";
|
||||
}
|
||||
}
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_MESSAGE, chatConversation.getId(), topicId);
|
||||
EventMessageData messageEventData = EventMessageData.builder()
|
||||
.message(resMessage)
|
||||
.build();
|
||||
eventData.setData(messageEventData);
|
||||
// sse
|
||||
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
|
||||
if (null == emitter) {
|
||||
log.warn("[AI应用]接收LLM返回会话已关闭");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String eventStr = JSONObject.toJSONString(eventData);
|
||||
log.debug("[AI应用]接收LLM返回消息:{}", eventStr);
|
||||
emitter.send(SseEmitter.event().data(eventStr));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
})
|
||||
.onComplete((responseMessage) -> {
|
||||
// 记录ai的回复
|
||||
AiMessage aiMessage = responseMessage.content();
|
||||
FinishReason finishReason = responseMessage.finishReason();
|
||||
String respText = aiMessage.text();
|
||||
// sse
|
||||
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
|
||||
if (null == emitter) {
|
||||
log.warn("[AI应用]接收LLM返回会话已关闭");
|
||||
return;
|
||||
}
|
||||
if (FinishReason.STOP.equals(finishReason) || null == finishReason) {
|
||||
// 正常结束
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_MESSAGE_END, chatConversation.getId(), topicId);
|
||||
try {
|
||||
log.debug("[AI应用]接收LLM返回消息完成:{}", respText);
|
||||
emitter.send(SseEmitter.event().data(eventData));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
appendMessage(messages, aiMessage, chatConversation, topicId);
|
||||
// 保存会话
|
||||
saveChatConversation(chatConversation,false,httpRequest);
|
||||
closeSSE(emitter, eventData);
|
||||
} else if (FinishReason.TOOL_EXECUTION.equals(finishReason)) {
|
||||
// 需要执行工具
|
||||
// TODO author: chenrui for: date:2025/3/7
|
||||
} else {
|
||||
// 异常结束
|
||||
log.error("调用模型异常:" + respText);
|
||||
if (respText.contains("insufficient Balance")) {
|
||||
respText = "大预言模型账号余额不足!";
|
||||
}
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_FLOW_ERROR, chatConversation.getId(), topicId);
|
||||
eventData.setData(EventFlowData.builder().success(false).message(respText).build());
|
||||
closeSSE(emitter, eventData);
|
||||
}
|
||||
})
|
||||
.onError((Throwable error) -> {
|
||||
// sse
|
||||
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
|
||||
if (null == emitter) {
|
||||
log.warn("[AI应用]接收LLM返回会话已关闭");
|
||||
return;
|
||||
}
|
||||
String errMsg = "调用大模型接口失败:" + error.getMessage();
|
||||
log.error(errMsg, error);
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_FLOW_ERROR, chatConversation.getId(), topicId);
|
||||
eventData.setData(EventFlowData.builder().success(false).message(errMsg).build());
|
||||
closeSSE(emitter, eventData);
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天返回结果
|
||||
*
|
||||
* @author chenrui
|
||||
* @date 2025/2/28 11:05
|
||||
*/
|
||||
private static class ChatResult {
|
||||
public final SseEmitter emitter;
|
||||
public final AiragModel chatModel;
|
||||
|
||||
public ChatResult(SseEmitter emitter, AiragModel chatModel) {
|
||||
this.emitter = emitter;
|
||||
this.chatModel = chatModel;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 总结会话标题
|
||||
* 几个问题: <br/>
|
||||
* 1. 如果在发消息时同步总结会话标题,会导致接口很慢甚至超时.
|
||||
* 2. 但如果异步更新会话标题会导致消息记录丢失(不全)或者标题丢失,需要写很多逻辑去保证最终一致
|
||||
* so 暂时先不用AI更新会话标题. 后期如果需要单独再增加一个接口,由前端调用或者在第一次消息接收完成后再异步更新
|
||||
*
|
||||
* @param chatConversation
|
||||
* @param question
|
||||
* @param modelId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/25 17:12
|
||||
*/
|
||||
protected void summaryConversationTitle(ChatConversation chatConversation, String question, String modelId) {
|
||||
if (oConvertUtils.isEmpty(chatConversation.getId())) {
|
||||
return;
|
||||
}
|
||||
String key = getConversationCacheKey(chatConversation.getId(), null);
|
||||
if (oConvertUtils.isEmpty(key)) {
|
||||
return;
|
||||
}
|
||||
CompletableFuture.runAsync(() -> {
|
||||
List<ChatMessage> messages = new LinkedList<>();
|
||||
String systemMsgStr = "根据用户的问题,总结会话标题.\n" +
|
||||
"要求如下:\n" +
|
||||
"1. 使用中文回答.\n" +
|
||||
"2. 标题长度控制在5个汉字10个英文字符以内\n" +
|
||||
"3. 直接回复会话标题,不要有其他任何无关描述\n" +
|
||||
"4. 如果无法总结,回复不知道\n";
|
||||
messages.add(new SystemMessage(systemMsgStr));
|
||||
messages.add(new UserMessage(question));
|
||||
String summaryTitle;
|
||||
try {
|
||||
summaryTitle = aiChatHandler.completions(modelId, messages, null);
|
||||
log.info("总结会话完成{}", summaryTitle);
|
||||
if (summaryTitle.equalsIgnoreCase("不知道")) {
|
||||
summaryTitle = "";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("AI总结会话失败" + e.getMessage(), e);
|
||||
summaryTitle = "";
|
||||
}
|
||||
// 更新会话标题
|
||||
ChatConversation cachedConversation = (ChatConversation) redisTemplate.boundValueOps(key).get();
|
||||
if (null == cachedConversation) {
|
||||
cachedConversation = chatConversation;
|
||||
}
|
||||
if (oConvertUtils.isEmpty(chatConversation.getTitle())) {
|
||||
// 再次判断标题是否为空,只有标题为空才更新
|
||||
if (oConvertUtils.isNotEmpty(summaryTitle)) {
|
||||
cachedConversation.setTitle(summaryTitle);
|
||||
} else {
|
||||
cachedConversation.setTitle(question.length() > 5 ? question.substring(0, 5) : question);
|
||||
}
|
||||
//保存会话
|
||||
saveChatConversation(cachedConversation);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户名
|
||||
* @param httpRequest
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/27 15:05
|
||||
*/
|
||||
private String getUsername(HttpServletRequest httpRequest) {
|
||||
try {
|
||||
TokenUtils.getTokenByRequest();
|
||||
String token;
|
||||
if(null != httpRequest){
|
||||
token = TokenUtils.getTokenByRequest(httpRequest);
|
||||
}else{
|
||||
token = TokenUtils.getTokenByRequest();
|
||||
}
|
||||
if (TokenUtils.verifyToken(token, sysBaseApi, redisUtil)) {
|
||||
return JwtUtil.getUsername(token);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package org.jeecg.modules.airag.app.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.jeecg.modules.airag.app.entity.AiragApp;
|
||||
|
||||
/**
|
||||
* @Description: 应用调试入参
|
||||
* @Author: chenrui
|
||||
* @Date: 2025/2/25 11:47
|
||||
*/
|
||||
@Data
|
||||
public class AppDebugParams extends ChatSendParams {
|
||||
|
||||
/**
|
||||
* 应用信息
|
||||
*/
|
||||
AiragApp app;
|
||||
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package org.jeecg.modules.airag.app.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import org.jeecg.modules.airag.app.entity.AiragApp;
|
||||
import org.jeecg.modules.airag.common.vo.MessageHistory;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @Description: 聊天会话
|
||||
* @Author: chenrui
|
||||
* @Date: 2025/2/25 14:56
|
||||
*/
|
||||
@Data
|
||||
public class ChatConversation {
|
||||
|
||||
/**
|
||||
* 会话id
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 会话标题
|
||||
*/
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 消息记录
|
||||
*/
|
||||
private List<MessageHistory> messages;
|
||||
|
||||
/**
|
||||
* app
|
||||
*/
|
||||
private AiragApp app;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Date createTime;
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package org.jeecg.modules.airag.app.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @Description: 发送消息的入参
|
||||
* @Author: chenrui
|
||||
* @Date: 2025/2/25 11:47
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
public class ChatSendParams {
|
||||
|
||||
public ChatSendParams(String content, String conversationId, String topicId, String appId) {
|
||||
this.content = content;
|
||||
this.conversationId = conversationId;
|
||||
this.topicId = topicId;
|
||||
this.appId = appId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户输入的聊天内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 对话会话ID
|
||||
*/
|
||||
private String conversationId;
|
||||
|
||||
/**
|
||||
* 对话主题ID(用于关联历史记录)
|
||||
*/
|
||||
private String topicId;
|
||||
|
||||
/**
|
||||
* 应用id
|
||||
*/
|
||||
private String appId;
|
||||
|
||||
/**
|
||||
* 图片列表
|
||||
*/
|
||||
private List<String> images;
|
||||
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package org.jeecg.modules.airag.llm.config;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 向量存储库配置
|
||||
*
|
||||
* @Author: chenrui
|
||||
* @Date: 2025/2/18 14:24
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = EmbedStoreConfigBean.PREFIX)
|
||||
public class EmbedStoreConfigBean {
|
||||
public static final String PREFIX = "jeecg.airag.embed-store";
|
||||
|
||||
/**
|
||||
* host
|
||||
*/
|
||||
private String host = "127.0.0.1";
|
||||
/**
|
||||
* 端口
|
||||
*/
|
||||
private int port = 5432;
|
||||
/**
|
||||
* 数据库
|
||||
*/
|
||||
private String database = "postgres";
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
private String user = "postgres";
|
||||
/**
|
||||
* 密码
|
||||
*/
|
||||
private String password = "postgres";
|
||||
|
||||
/**
|
||||
* 存储向量的表
|
||||
*/
|
||||
private String table = "embeddings";
|
||||
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package org.jeecg.modules.airag.llm.config;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 知识库配置
|
||||
*
|
||||
* @Author: chenrui
|
||||
* @Date: 2025-04-01 14:19
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = KnowConfigBean.PREFIX)
|
||||
public class KnowConfigBean {
|
||||
public static final String PREFIX = "jeecg.airag.know";
|
||||
|
||||
/**
|
||||
* 开启MinerU解析
|
||||
*/
|
||||
private boolean enableMinerU = false;
|
||||
|
||||
/**
|
||||
* conda的环境(默认不使用conda)
|
||||
*/
|
||||
private String condaEnv = null;
|
||||
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package org.jeecg.modules.airag.llm.consts;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* @Description: airag模型常量类
|
||||
* @Author: chenrui
|
||||
* @Date: 2025/2/12 17:35
|
||||
*/
|
||||
public class LLMConsts {
|
||||
|
||||
|
||||
/**
|
||||
* 正则表达式:是否是网页
|
||||
*/
|
||||
public static final Pattern WEB_PATTERN = Pattern.compile("^(http|https)://.*");
|
||||
|
||||
/**
|
||||
* 状态:启用
|
||||
*/
|
||||
public static final String STATUS_ENABLE = "enable";
|
||||
/**
|
||||
* 状态:禁用
|
||||
*/
|
||||
public static final String STATUS_DISABLE = "disable";
|
||||
|
||||
|
||||
/**
|
||||
* 模型类型:向量
|
||||
*/
|
||||
public static final String MODEL_TYPE_EMBED = "EMBED";
|
||||
|
||||
/**
|
||||
* 模型类型:聊天
|
||||
*/
|
||||
public static final String MODEL_TYPE_LLM = "LLM";
|
||||
|
||||
/**
|
||||
* 知识库:文档状态:草稿
|
||||
*/
|
||||
public static final String KNOWLEDGE_DOC_STATUS_DRAFT = "draft";
|
||||
/**
|
||||
* 知识库:文档状态:构建中
|
||||
*/
|
||||
public static final String KNOWLEDGE_DOC_STATUS_BUILDING = "building";
|
||||
/**
|
||||
* 知识库:文档状态:构建完成
|
||||
*/
|
||||
public static final String KNOWLEDGE_DOC_STATUS_COMPLETE = "complete";
|
||||
|
||||
|
||||
/**
|
||||
* 知识库:文档类型:文本
|
||||
*/
|
||||
public static final String KNOWLEDGE_DOC_TYPE_TEXT = "text";
|
||||
/**
|
||||
* 知识库:文档类型:文件
|
||||
*/
|
||||
public static final String KNOWLEDGE_DOC_TYPE_FILE = "file";
|
||||
/**
|
||||
* 知识库:文档类型:网页
|
||||
*/
|
||||
public static final String KNOWLEDGE_DOC_TYPE_WEB = "web";
|
||||
|
||||
/**
|
||||
* 知识库:文档元数据:文件路径
|
||||
*/
|
||||
public static final String KNOWLEDGE_DOC_METADATA_FILEPATH = "filePath";
|
||||
|
||||
/**
|
||||
* 知识库:文档元数据:资源路径
|
||||
*/
|
||||
public static final String KNOWLEDGE_DOC_METADATA_SOURCES_PATH = "sourcesPath";
|
||||
|
||||
}
|
||||
@ -0,0 +1,320 @@
|
||||
package org.jeecg.modules.airag.llm.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.query.QueryGenerator;
|
||||
import org.jeecg.common.util.AssertUtils;
|
||||
import org.jeecg.modules.airag.common.vo.knowledge.KnowledgeSearchResult;
|
||||
import org.jeecg.modules.airag.llm.consts.LLMConsts;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledge;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledgeDoc;
|
||||
import org.jeecg.modules.airag.llm.handler.EmbeddingHandler;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeDocService;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/**
|
||||
* @Description: AIRag知识库
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-18
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/airag/knowledge")
|
||||
@Slf4j
|
||||
public class AiragKnowledgeController {
|
||||
@Autowired
|
||||
private IAiragKnowledgeService airagKnowledgeService;
|
||||
|
||||
@Autowired
|
||||
private IAiragKnowledgeDocService airagKnowledgeDocService;
|
||||
|
||||
@Autowired
|
||||
EmbeddingHandler embeddingHandler;
|
||||
|
||||
/**
|
||||
* 分页列表查询知识库
|
||||
*
|
||||
* @param airagKnowledge
|
||||
* @param pageNo
|
||||
* @param pageSize
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@GetMapping(value = "/list")
|
||||
public Result<IPage<AiragKnowledge>> queryPageList(AiragKnowledge airagKnowledge,
|
||||
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
|
||||
HttpServletRequest req) {
|
||||
QueryWrapper<AiragKnowledge> queryWrapper = QueryGenerator.initQueryWrapper(airagKnowledge, req.getParameterMap());
|
||||
Page<AiragKnowledge> page = new Page<AiragKnowledge>(pageNo, pageSize);
|
||||
IPage<AiragKnowledge> pageList = airagKnowledgeService.page(page, queryWrapper);
|
||||
return Result.OK(pageList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加知识库
|
||||
*
|
||||
* @param airagKnowledge 知识库
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 17:09
|
||||
*/
|
||||
@PostMapping(value = "/add")
|
||||
public Result<String> add(@RequestBody AiragKnowledge airagKnowledge) {
|
||||
airagKnowledge.setStatus(LLMConsts.STATUS_ENABLE);
|
||||
airagKnowledgeService.save(airagKnowledge);
|
||||
return Result.OK("添加成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑知识库
|
||||
*
|
||||
* @param airagKnowledge 知识库
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 17:09
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
|
||||
public Result<String> edit(@RequestBody AiragKnowledge airagKnowledge) {
|
||||
AiragKnowledge airagKnowledgeEntity = airagKnowledgeService.getById(airagKnowledge.getId());
|
||||
if (airagKnowledgeEntity == null) {
|
||||
return Result.error("未找到对应数据");
|
||||
}
|
||||
String oldEmbedId = airagKnowledgeEntity.getEmbedId();
|
||||
airagKnowledgeService.updateById(airagKnowledge);
|
||||
if (!oldEmbedId.equalsIgnoreCase(airagKnowledge.getEmbedId())) {
|
||||
// 更新了模型,重建文档
|
||||
airagKnowledgeDocService.rebuildDocumentByKnowId(airagKnowledge.getId());
|
||||
}
|
||||
return Result.OK("编辑成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建知识库
|
||||
*
|
||||
* @param knowIds
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/12 17:05
|
||||
*/
|
||||
@PutMapping(value = "/rebuild")
|
||||
public Result<?> rebuild(@RequestParam("knowIds") String knowIds) {
|
||||
String[] knowIdArr = knowIds.split(",");
|
||||
for (String knowId : knowIdArr) {
|
||||
airagKnowledgeDocService.rebuildDocumentByKnowId(knowId);
|
||||
}
|
||||
return Result.OK("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过id删除知识库
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 17:09
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@DeleteMapping(value = "/delete")
|
||||
public Result<String> delete(@RequestParam(name = "id", required = true) String id) {
|
||||
airagKnowledgeDocService.removeByKnowIds(Collections.singletonList(id));
|
||||
airagKnowledgeService.removeById(id);
|
||||
return Result.OK("删除成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除知识库
|
||||
*
|
||||
* @param ids
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 17:09
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@DeleteMapping(value = "/deleteBatch")
|
||||
public Result<String> deleteBatch(@RequestParam(name = "ids", required = true) String ids) {
|
||||
List<String> idsList = Arrays.asList(ids.split(","));
|
||||
airagKnowledgeDocService.removeByKnowIds(idsList);
|
||||
airagKnowledgeService.removeByIds(idsList);
|
||||
return Result.OK("批量删除成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过id查询知识库
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 17:09
|
||||
*/
|
||||
@GetMapping(value = "/queryById")
|
||||
public Result<AiragKnowledge> queryById(@RequestParam(name = "id", required = true) String id) {
|
||||
AiragKnowledge airagKnowledge = airagKnowledgeService.getById(id);
|
||||
if (airagKnowledge == null) {
|
||||
return Result.error("未找到对应数据");
|
||||
}
|
||||
return Result.OK(airagKnowledge);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档分页查询
|
||||
*
|
||||
* @param airagKnowledgeDoc
|
||||
* @param pageNo
|
||||
* @param pageSize
|
||||
* @param req
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 18:37
|
||||
*/
|
||||
@GetMapping(value = "/doc/list")
|
||||
public Result<IPage<AiragKnowledgeDoc>> queryDocumentPageList(AiragKnowledgeDoc airagKnowledgeDoc,
|
||||
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
|
||||
HttpServletRequest req) {
|
||||
AssertUtils.assertNotEmpty("请先选择知识库", airagKnowledgeDoc.getKnowledgeId());
|
||||
QueryWrapper<AiragKnowledgeDoc> queryWrapper = QueryGenerator.initQueryWrapper(airagKnowledgeDoc, req.getParameterMap());
|
||||
Page<AiragKnowledgeDoc> page = new Page<>(pageNo, pageSize);
|
||||
IPage<AiragKnowledgeDoc> pageList = airagKnowledgeDocService.page(page, queryWrapper);
|
||||
return Result.OK(pageList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增或编辑文档
|
||||
*
|
||||
* @param airagKnowledgeDoc 知识库文档
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 15:47
|
||||
*/
|
||||
@PostMapping(value = "/doc/edit")
|
||||
public Result<?> addDocument(@RequestBody AiragKnowledgeDoc airagKnowledgeDoc) {
|
||||
return airagKnowledgeDocService.editDocument(airagKnowledgeDoc);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从压缩包导入文档
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/20 11:29
|
||||
*/
|
||||
@PostMapping(value = "/doc/import/zip")
|
||||
public Result<?> importDocumentFromZip(@RequestParam(name = "knowId", required = true) String knowId,
|
||||
@RequestParam(name = "file", required = true) MultipartFile file) {
|
||||
return airagKnowledgeDocService.importDocumentFromZip(knowId,file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过文档库查询导入任务列表
|
||||
* @param knowId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/20 11:37
|
||||
*/
|
||||
@GetMapping(value = "/doc/import/task/list")
|
||||
public Result<?> importDocumentTaskList(@RequestParam(name = "knowId", required = true) String knowId) {
|
||||
return Result.OK(Collections.emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新向量化文档
|
||||
*
|
||||
* @param docIds 文档id集合
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 15:47
|
||||
*/
|
||||
@PutMapping(value = "/doc/rebuild")
|
||||
public Result<?> rebuildDocument(@RequestParam("docIds") String docIds) {
|
||||
return airagKnowledgeDocService.rebuildDocument(docIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除文档
|
||||
*
|
||||
* @param ids
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 17:09
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@DeleteMapping(value = "/doc/deleteBatch")
|
||||
public Result<String> deleteDocumentBatch(@RequestParam(name = "ids", required = true) String ids) {
|
||||
List<String> idsList = Arrays.asList(ids.split(","));
|
||||
airagKnowledgeDocService.removeDocByIds(idsList);
|
||||
return Result.OK("批量删除成功!");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 命中测试
|
||||
*
|
||||
* @param knowId 知识库id
|
||||
* @param queryText 查询内容
|
||||
* @param topNumber 最多返回条数
|
||||
* @param similarity 最小分数
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 17:09
|
||||
*/
|
||||
@GetMapping(value = "/embedding/hitTest/{knowId}")
|
||||
public Result<?> hitTest(@PathVariable("knowId") String knowId,
|
||||
@RequestParam(name = "queryText") String queryText,
|
||||
@RequestParam(name = "topNumber") Integer topNumber,
|
||||
@RequestParam(name = "similarity") Double similarity) {
|
||||
List<Map<String, Object>> searchResp = embeddingHandler.searchEmbedding(knowId, queryText, topNumber, similarity);
|
||||
return Result.ok(searchResp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向量查询
|
||||
*
|
||||
* @param knowIds 知识库ids
|
||||
* @param queryText 查询内容
|
||||
* @param topNumber 最多返回条数
|
||||
* @param similarity 最小分数
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 17:09
|
||||
*/
|
||||
@GetMapping(value = "/embedding/search")
|
||||
public Result<?> embeddingSearch(@RequestParam("knowIds") List<String> knowIds,
|
||||
@RequestParam(name = "queryText") String queryText,
|
||||
@RequestParam(name = "topNumber", required = false) Integer topNumber,
|
||||
@RequestParam(name = "similarity", required = false) Double similarity) {
|
||||
KnowledgeSearchResult searchResp = embeddingHandler.embeddingSearch(knowIds, queryText, topNumber, similarity);
|
||||
return Result.ok(searchResp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过ids批量查询知识库
|
||||
*
|
||||
* @param ids
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/27 16:44
|
||||
*/
|
||||
@GetMapping(value = "/query/batch/byId")
|
||||
public Result<?> queryBatchByIds(@RequestParam(name = "ids", required = true) String ids) {
|
||||
List<String> idList = Arrays.asList(ids.split(","));
|
||||
List<AiragKnowledge> airagKnowledges = airagKnowledgeService.listByIds(idList);
|
||||
return Result.OK(airagKnowledges);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
package org.jeecg.modules.airag.llm.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.base.controller.JeecgController;
|
||||
import org.jeecg.common.system.query.QueryGenerator;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragModelService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* @Description: AiRag模型配置
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-14
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Tag(name = "AiRag模型配置")
|
||||
@RestController
|
||||
@RequestMapping("/airag/airagModel")
|
||||
@Slf4j
|
||||
public class AiragModelController extends JeecgController<AiragModel, IAiragModelService> {
|
||||
@Autowired
|
||||
private IAiragModelService airagModelService;
|
||||
|
||||
|
||||
/**
|
||||
* 分页列表查询
|
||||
*
|
||||
* @param airagModel
|
||||
* @param pageNo
|
||||
* @param pageSize
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@GetMapping(value = "/list")
|
||||
public Result<IPage<AiragModel>> queryPageList(AiragModel airagModel, @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, HttpServletRequest req) {
|
||||
QueryWrapper<AiragModel> queryWrapper = QueryGenerator.initQueryWrapper(airagModel, req.getParameterMap());
|
||||
Page<AiragModel> page = new Page<AiragModel>(pageNo, pageSize);
|
||||
IPage<AiragModel> pageList = airagModelService.page(page, queryWrapper);
|
||||
return Result.OK(pageList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加
|
||||
*
|
||||
* @param airagModel
|
||||
* @return
|
||||
*/
|
||||
@PostMapping(value = "/add")
|
||||
public Result<String> add(@RequestBody AiragModel airagModel) {
|
||||
airagModelService.save(airagModel);
|
||||
return Result.OK("添加成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*
|
||||
* @param airagModel
|
||||
* @return
|
||||
*/
|
||||
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
|
||||
public Result<String> edit(@RequestBody AiragModel airagModel) {
|
||||
airagModelService.updateById(airagModel);
|
||||
return Result.OK("编辑成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过id删除
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@DeleteMapping(value = "/delete")
|
||||
public Result<String> delete(@RequestParam(name = "id", required = true) String id) {
|
||||
airagModelService.removeById(id);
|
||||
return Result.OK("删除成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除
|
||||
*
|
||||
* @param ids
|
||||
* @return
|
||||
*/
|
||||
@DeleteMapping(value = "/deleteBatch")
|
||||
public Result<String> deleteBatch(@RequestParam(name = "ids", required = true) String ids) {
|
||||
this.airagModelService.removeByIds(Arrays.asList(ids.split(",")));
|
||||
return Result.OK("批量删除成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过id查询
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@GetMapping(value = "/queryById")
|
||||
public Result<AiragModel> queryById(@RequestParam(name = "id", required = true) String id) {
|
||||
AiragModel airagModel = airagModelService.getById(id);
|
||||
if (airagModel == null) {
|
||||
return Result.error("未找到对应数据");
|
||||
}
|
||||
return Result.OK(airagModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出excel
|
||||
*
|
||||
* @param request
|
||||
* @param airagModel
|
||||
*/
|
||||
@RequestMapping(value = "/exportXls")
|
||||
public ModelAndView exportXls(HttpServletRequest request, AiragModel airagModel) {
|
||||
return super.exportXls(request, airagModel, AiragModel.class, "AiRag模型配置");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过excel导入数据
|
||||
*
|
||||
* @param request
|
||||
* @param response
|
||||
* @return
|
||||
*/
|
||||
@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
|
||||
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
|
||||
return super.importExcel(request, response, AiragModel.class);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,274 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by FernFlower decompiler)
|
||||
//
|
||||
|
||||
package org.jeecg.modules.airag.llm.document;
|
||||
|
||||
import dev.langchain4j.data.document.BlankDocumentException;
|
||||
import dev.langchain4j.data.document.Document;
|
||||
import dev.langchain4j.internal.Utils;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.poi.hslf.usermodel.HSLFTextParagraph;
|
||||
import org.apache.poi.hwpf.HWPFDocument;
|
||||
import org.apache.poi.hwpf.extractor.WordExtractor;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import org.apache.poi.xslf.usermodel.XMLSlideShow;
|
||||
import org.apache.poi.xslf.usermodel.XSLFSlide;
|
||||
import org.apache.poi.xslf.usermodel.XSLFTextShape;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
|
||||
import org.apache.tika.Tika;
|
||||
import org.apache.tika.exception.ZeroByteFileException;
|
||||
import org.apache.tika.metadata.Metadata;
|
||||
import org.apache.tika.parser.AutoDetectParser;
|
||||
import org.apache.tika.parser.ParseContext;
|
||||
import org.apache.tika.parser.Parser;
|
||||
import org.apache.tika.sax.BodyContentHandler;
|
||||
import org.jeecg.common.util.AssertUtils;
|
||||
import org.xml.sax.ContentHandler;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* tika文档解析器,重写langchain4j的TikaDocumentParser <br/>
|
||||
* jeecgboot目前不支持poi5.x,所以langchain4j同的方法不能用,自己实现
|
||||
* @author chenrui
|
||||
* @date 2025/3/5 16:19
|
||||
*/
|
||||
public class TikaDocumentParser {
|
||||
private static final Tika tika = new Tika();
|
||||
private static final int NO_WRITE_LIMIT = -1;
|
||||
public static final Supplier<Parser> DEFAULT_PARSER_SUPPLIER = AutoDetectParser::new;
|
||||
public static final Supplier<Metadata> DEFAULT_METADATA_SUPPLIER = Metadata::new;
|
||||
public static final Supplier<ParseContext> DEFAULT_PARSE_CONTEXT_SUPPLIER = ParseContext::new;
|
||||
public static final Supplier<ContentHandler> DEFAULT_CONTENT_HANDLER_SUPPLIER = () -> new BodyContentHandler(-1);
|
||||
private final Supplier<Parser> parserSupplier;
|
||||
private final Supplier<ContentHandler> contentHandlerSupplier;
|
||||
private final Supplier<Metadata> metadataSupplier;
|
||||
private final Supplier<ParseContext> parseContextSupplier;
|
||||
|
||||
public TikaDocumentParser() {
|
||||
this((Supplier) ((Supplier) null), (Supplier) null, (Supplier) null, (Supplier) null);
|
||||
}
|
||||
|
||||
|
||||
public TikaDocumentParser(Supplier<Parser> parserSupplier, Supplier<ContentHandler> contentHandlerSupplier, Supplier<Metadata> metadataSupplier, Supplier<ParseContext> parseContextSupplier) {
|
||||
this.parserSupplier = (Supplier) Utils.getOrDefault(parserSupplier, () -> DEFAULT_PARSER_SUPPLIER);
|
||||
this.contentHandlerSupplier = (Supplier) Utils.getOrDefault(contentHandlerSupplier, () -> DEFAULT_CONTENT_HANDLER_SUPPLIER);
|
||||
this.metadataSupplier = (Supplier) Utils.getOrDefault(metadataSupplier, () -> DEFAULT_METADATA_SUPPLIER);
|
||||
this.parseContextSupplier = (Supplier) Utils.getOrDefault(parseContextSupplier, () -> DEFAULT_PARSE_CONTEXT_SUPPLIER);
|
||||
}
|
||||
|
||||
public Document parse(File file) {
|
||||
AssertUtils.assertNotEmpty("请选择文件", file);
|
||||
try {
|
||||
// 用于解析
|
||||
InputStream isForParsing = Files.newInputStream(file.toPath());
|
||||
// 使用 Tika 自动检测 MIME 类型
|
||||
String fileName = file.getName().toLowerCase();
|
||||
if (fileName.endsWith(".txt")
|
||||
|| fileName.endsWith(".md")
|
||||
|| fileName.endsWith(".pdf")) {
|
||||
return extractByTika(isForParsing);
|
||||
} else if (fileName.endsWith(".docx")) {
|
||||
return extractTextFromDocx(isForParsing);
|
||||
} else if (fileName.endsWith(".doc")) {
|
||||
return extractTextFromDoc(isForParsing);
|
||||
} else if (fileName.endsWith(".xlsx")) {
|
||||
return extractTextFromExcel(isForParsing);
|
||||
} else if (fileName.endsWith(".xls")) {
|
||||
return extractTextFromExcel(isForParsing);
|
||||
} else if (fileName.endsWith(".pptx")) {
|
||||
return extractTextFromPptx(isForParsing);
|
||||
} else if (fileName.endsWith(".ppt")) {
|
||||
return extractTextFromPpt(isForParsing);
|
||||
} else {
|
||||
throw new IllegalArgumentException("不支持的文件格式: " + FilenameUtils.getExtension(fileName));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Document tryExtractDocOrDocx(InputStream inputStream) throws IOException {
|
||||
try {
|
||||
// 先尝试 DOCX(基于 OPC XML 格式)
|
||||
return extractTextFromDocx(inputStream);
|
||||
} catch (Exception e1) {
|
||||
try {
|
||||
// 如果 DOCX 解析失败,则尝试 DOC(基于二进制格式)
|
||||
return extractTextFromDoc(inputStream);
|
||||
} catch (Exception e2) {
|
||||
throw new IOException("无法解析 DOC 或 DOCX 文件", e2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用tika提取文件内容 <br/>
|
||||
* pdf/text/md等文件使用tika提取
|
||||
*
|
||||
* @param inputStream
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/5 14:41
|
||||
*/
|
||||
private Document extractByTika(InputStream inputStream) {
|
||||
try {
|
||||
Parser parser = (Parser) this.parserSupplier.get();
|
||||
ContentHandler contentHandler = (ContentHandler) this.contentHandlerSupplier.get();
|
||||
Metadata metadata = (Metadata) this.metadataSupplier.get();
|
||||
ParseContext parseContext = (ParseContext) this.parseContextSupplier.get();
|
||||
parser.parse(inputStream, contentHandler, metadata, parseContext);
|
||||
String text = contentHandler.toString();
|
||||
if (Utils.isNullOrBlank(text)) {
|
||||
throw new BlankDocumentException();
|
||||
} else {
|
||||
return Document.from(text);
|
||||
}
|
||||
} catch (BlankDocumentException e) {
|
||||
throw e;
|
||||
} catch (ZeroByteFileException var8) {
|
||||
throw new BlankDocumentException();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取docx文件内容
|
||||
*
|
||||
* @param inputStream
|
||||
* @return
|
||||
* @throws IOException
|
||||
* @author chenrui
|
||||
* @date 2025/3/5 14:42
|
||||
* @deprecated 因为jeecg主项目目前不支持poi5.x, 自己实现提取功能.
|
||||
*/
|
||||
@Deprecated
|
||||
private static Document extractTextFromDocx(InputStream inputStream) throws IOException {
|
||||
try (XWPFDocument document = new XWPFDocument(inputStream)) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
for (XWPFParagraph para : document.getParagraphs()) {
|
||||
text.append(para.getText()).append("\n");
|
||||
}
|
||||
return Document.from(text.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取doc文件内容
|
||||
*
|
||||
* @param inputStream
|
||||
* @return
|
||||
* @throws IOException
|
||||
* @author chenrui
|
||||
* @date 2025/3/5 14:42
|
||||
* @deprecated 因为jeecg主项目目前不支持poi5.x, 自己实现提取功能.
|
||||
*/
|
||||
@Deprecated
|
||||
private static Document extractTextFromDoc(InputStream inputStream) throws IOException {
|
||||
try (HWPFDocument document = new HWPFDocument(inputStream);
|
||||
WordExtractor extractor = new WordExtractor(document)) {
|
||||
return Document.from(extractor.getText());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取excel文件内容
|
||||
*
|
||||
* @param inputStream
|
||||
* @return
|
||||
* @throws IOException
|
||||
* @author chenrui
|
||||
* @date 2025/3/5 14:43
|
||||
* @deprecated 因为jeecg主项目目前不支持poi5.x, 自己实现提取功能.
|
||||
*/
|
||||
@Deprecated
|
||||
private static Document extractTextFromExcel(InputStream inputStream) throws IOException {
|
||||
try (Workbook workbook = WorkbookFactory.create(inputStream)) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
for (Sheet sheet : workbook) {
|
||||
text.append("Sheet: ").append(sheet.getSheetName()).append("\n");
|
||||
for (Row row : sheet) {
|
||||
for (Cell cell : row) {
|
||||
text.append(cell.toString()).append("\t");
|
||||
}
|
||||
text.append("\n");
|
||||
}
|
||||
text.append("\n");
|
||||
}
|
||||
return Document.from(text.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取pptx文件内容
|
||||
*
|
||||
* @param inputStream
|
||||
* @return
|
||||
* @throws IOException
|
||||
* @author chenrui
|
||||
* @date 2025/3/5 14:43
|
||||
* @deprecated 因为jeecg主项目目前不支持poi5.x, 自己实现提取功能.
|
||||
*/
|
||||
@Deprecated
|
||||
private static Document extractTextFromPptx(InputStream inputStream) throws IOException {
|
||||
try (XMLSlideShow ppt = new XMLSlideShow(inputStream)) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
for (XSLFSlide slide : ppt.getSlides()) {
|
||||
text.append("Slide ").append(slide.getSlideNumber()).append(":\n");
|
||||
List<XSLFTextShape> shapes = slide.getShapes().stream()
|
||||
.filter(s -> s instanceof XSLFTextShape)
|
||||
.map(s -> (XSLFTextShape) s)
|
||||
.collect(Collectors.toList());
|
||||
for (XSLFTextShape shape : shapes) {
|
||||
text.append(shape.getText()).append("\n");
|
||||
}
|
||||
text.append("\n");
|
||||
}
|
||||
return Document.from(text.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取ppt文件内容
|
||||
*
|
||||
* @param inputStream
|
||||
* @return
|
||||
* @throws IOException
|
||||
* @author chenrui
|
||||
* @date 2025/3/5 14:43
|
||||
* @deprecated 因为jeecg主项目目前不支持poi5.x, 自己实现提取功能.
|
||||
*/
|
||||
@Deprecated
|
||||
private static Document extractTextFromPpt(InputStream inputStream) throws IOException {
|
||||
try (org.apache.poi.hslf.usermodel.HSLFSlideShow ppt = new org.apache.poi.hslf.usermodel.HSLFSlideShow(inputStream)) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
for (org.apache.poi.hslf.usermodel.HSLFSlide slide : ppt.getSlides()) {
|
||||
text.append("Slide ").append(slide.getSlideNumber()).append(":\n");
|
||||
for (List<HSLFTextParagraph> shapes : slide.getTextParagraphs()) {
|
||||
text.append(HSLFTextParagraph.getText(shapes)).append("\n");
|
||||
}
|
||||
text.append("\n");
|
||||
}
|
||||
return Document.from(text.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] toByteArray(InputStream inputStream) throws IOException {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] data = new byte[1024];
|
||||
int nRead;
|
||||
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
|
||||
buffer.write(data, 0, nRead);
|
||||
}
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package org.jeecg.modules.airag.llm.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import org.jeecg.common.aspect.annotation.Dict;
|
||||
import org.jeecgframework.poi.excel.annotation.Excel;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* @Description: AIRag知识库
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-18
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Schema(description="AIRag知识库")
|
||||
@Data
|
||||
@TableName("airag_knowledge")
|
||||
public class AiragKnowledge implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
@Schema(description = "主键")
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 创建人
|
||||
*/
|
||||
@Schema(description = "创建人")
|
||||
private String createBy;
|
||||
|
||||
/**
|
||||
* 创建日期
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "创建日期")
|
||||
private java.util.Date createTime;
|
||||
|
||||
/**
|
||||
* 更新人
|
||||
*/
|
||||
@Schema(description = "更新人")
|
||||
private String updateBy;
|
||||
|
||||
/**
|
||||
* 更新日期
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "更新日期")
|
||||
private java.util.Date updateTime;
|
||||
|
||||
/**
|
||||
* 所属部门
|
||||
*/
|
||||
@Schema(description = "所属部门")
|
||||
private String sysOrgCode;
|
||||
|
||||
/**
|
||||
* 租户id
|
||||
*/
|
||||
@Excel(name = "租户id", width = 15)
|
||||
@Schema(description = "租户id")
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 知识库名称
|
||||
*/
|
||||
@Excel(name = "知识库名称", width = 15)
|
||||
@Schema(description = "知识库名称")
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 向量模型id
|
||||
*/
|
||||
@Excel(name = "向量模型id", width = 15, dictTable = "airag_model where model_type = 'EMBED'", dicText = "name", dicCode = "id")
|
||||
@Dict(dictTable = "airag_model where model_type = 'EMBED'", dicText = "name", dicCode = "id")
|
||||
@Schema(description = "向量模型id")
|
||||
private String embedId;
|
||||
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
@Excel(name = "描述", width = 15)
|
||||
@Schema(description = "描述")
|
||||
private String descr;
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
@Excel(name = "状态", width = 15)
|
||||
@Schema(description = "状态")
|
||||
private String status;
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
package org.jeecg.modules.airag.llm.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import org.jeecg.common.constant.ProvinceCityArea;
|
||||
import org.jeecg.common.util.SpringContextUtils;
|
||||
import lombok.Data;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.jeecgframework.poi.excel.annotation.Excel;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
|
||||
/**
|
||||
* @Description: airag知识库文档
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-18
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Schema(description="airag知识库文档")
|
||||
@Data
|
||||
@TableName("airag_knowledge_doc")
|
||||
public class AiragKnowledgeDoc implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
@Schema(description = "主键")
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 创建人
|
||||
*/
|
||||
@Schema(description = "创建人")
|
||||
private String createBy;
|
||||
|
||||
/**
|
||||
* 创建日期
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "创建日期")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新人
|
||||
*/
|
||||
@Schema(description = "更新人")
|
||||
private String updateBy;
|
||||
|
||||
/**
|
||||
* 更新日期
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "更新日期")
|
||||
private Date updateTime;
|
||||
|
||||
/**
|
||||
* 所属部门
|
||||
*/
|
||||
@Schema(description = "所属部门")
|
||||
private String sysOrgCode;
|
||||
|
||||
/**
|
||||
* 租户id
|
||||
*/
|
||||
@Excel(name = "租户id", width = 15)
|
||||
@Schema(description = "租户id")
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 知识库id
|
||||
*/
|
||||
@Schema(description = "知识库id")
|
||||
private String knowledgeId;
|
||||
|
||||
/**
|
||||
* 标题
|
||||
*/
|
||||
@Excel(name = "标题", width = 15)
|
||||
@Schema(description = "标题")
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 类型
|
||||
*/
|
||||
@Excel(name = "类型", width = 15, dicCode = "know_doc_type")
|
||||
@Schema(description = "类型")
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 内容
|
||||
*/
|
||||
@Excel(name = "内容", width = 15)
|
||||
@Schema(description = "内容")
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 元数据,存储上传文件的存储目录以及网站站点 <br/>
|
||||
* eg. {"filePath":"https://xxxxxx","website":"http://hellp.jeecg.com"}
|
||||
*/
|
||||
@Excel(name = "元数据", width = 15)
|
||||
@Schema(description = "元数据")
|
||||
private String metadata;
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
@Excel(name = "状态", width = 15)
|
||||
@Schema(description = "状态")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 服务器基础路径
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private String baseUrl;
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
package org.jeecg.modules.airag.llm.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.util.Date;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import org.jeecg.common.constant.ProvinceCityArea;
|
||||
import org.jeecg.common.util.SpringContextUtils;
|
||||
import lombok.Data;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.jeecgframework.poi.excel.annotation.Excel;
|
||||
import org.jeecg.common.aspect.annotation.Dict;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/**
|
||||
* @Description: AiRag模型配置
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-17
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Data
|
||||
@TableName("airag_model")
|
||||
@Accessors(chain = true)
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Schema(description="AiRag模型配置")
|
||||
public class AiragModel implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
@Schema(description = "主键")
|
||||
private String id;
|
||||
/**
|
||||
* 创建人
|
||||
*/
|
||||
@Schema(description = "创建人")
|
||||
private String createBy;
|
||||
/**
|
||||
* 创建日期
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "创建日期")
|
||||
private Date createTime;
|
||||
/**
|
||||
* 更新人
|
||||
*/
|
||||
@Schema(description = "更新人")
|
||||
private String updateBy;
|
||||
/**
|
||||
* 更新日期
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "更新日期")
|
||||
private Date updateTime;
|
||||
/**
|
||||
* 所属部门
|
||||
*/
|
||||
@Schema(description = "所属部门")
|
||||
private String sysOrgCode;
|
||||
/**
|
||||
* 租户id
|
||||
*/
|
||||
@Excel(name = "租户id", width = 15)
|
||||
@Schema(description = "租户id")
|
||||
private String tenantId;
|
||||
/**
|
||||
* 名称
|
||||
*/
|
||||
@Excel(name = "名称", width = 15)
|
||||
@Schema(description = "名称")
|
||||
private String name;
|
||||
/**
|
||||
* 供应者
|
||||
*/
|
||||
@Excel(name = "供应者", width = 15, dicCode = "model_provider")
|
||||
@Dict(dicCode = "model_provider")
|
||||
@Schema(description = "供应者")
|
||||
private String provider;
|
||||
/**
|
||||
* 模型类型
|
||||
*/
|
||||
@Excel(name = "模型类型", width = 15, dicCode = "model_type")
|
||||
@Dict(dicCode = "model_type")
|
||||
@Schema(description = "模型类型")
|
||||
private String modelType;
|
||||
/**
|
||||
* 模型名称
|
||||
*/
|
||||
@Excel(name = "模型名称", width = 15)
|
||||
@Schema(description = "模型名称")
|
||||
private String modelName;
|
||||
/**
|
||||
* API域名
|
||||
*/
|
||||
@Excel(name = "API域名", width = 15)
|
||||
@Schema(description = "API域名")
|
||||
private String baseUrl;
|
||||
/**
|
||||
* 凭证信息
|
||||
*/
|
||||
@Excel(name = "凭证信息", width = 15)
|
||||
@Schema(description = "凭证信息")
|
||||
private String credential;
|
||||
/**
|
||||
* 模型参数
|
||||
*/
|
||||
@Excel(name = "模型参数", width = 15)
|
||||
@Schema(description = "模型参数")
|
||||
private String modelParams;
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
package org.jeecg.modules.airag.llm.handler;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import dev.langchain4j.data.message.*;
|
||||
import dev.langchain4j.rag.query.router.QueryRouter;
|
||||
import dev.langchain4j.service.TokenStream;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.ai.handler.LLMHandler;
|
||||
import org.jeecg.common.util.AssertUtils;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.airag.common.handler.AIChatParams;
|
||||
import org.jeecg.modules.airag.common.handler.IAIChatHandler;
|
||||
import org.jeecg.modules.airag.llm.consts.LLMConsts;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragModelService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
/**
|
||||
* 大模型聊天工具类
|
||||
*
|
||||
* @Author: chenrui
|
||||
* @Date: 2025/2/18 14:31
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AIChatHandler implements IAIChatHandler {
|
||||
|
||||
@Autowired
|
||||
IAiragModelService airagModelService;
|
||||
|
||||
@Autowired
|
||||
EmbeddingHandler embeddingHandler;
|
||||
|
||||
@Autowired
|
||||
LLMHandler llmHandler;
|
||||
|
||||
|
||||
@Value(value = "${jeecg.path.upload:}")
|
||||
private String uploadpath;
|
||||
|
||||
/**
|
||||
* 问答
|
||||
*
|
||||
* @param modelId
|
||||
* @param messages
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 21:03
|
||||
*/
|
||||
@Override
|
||||
public String completions(String modelId, List<ChatMessage> messages) {
|
||||
AssertUtils.assertNotEmpty("至少发送一条消息", messages);
|
||||
AssertUtils.assertNotEmpty("请选择模型", modelId);
|
||||
// 整理消息
|
||||
return completions(modelId, messages, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 问答
|
||||
*
|
||||
* @param modelId
|
||||
* @param messages
|
||||
* @param params
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 21:03
|
||||
*/
|
||||
@Override
|
||||
public String completions(String modelId, List<ChatMessage> messages, AIChatParams params) {
|
||||
AssertUtils.assertNotEmpty("至少发送一条消息", messages);
|
||||
AssertUtils.assertNotEmpty("请选择模型", modelId);
|
||||
|
||||
AiragModel airagModel = airagModelService.getById(modelId);
|
||||
return completions(airagModel, messages, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 问答
|
||||
*
|
||||
* @param airagModel
|
||||
* @param messages
|
||||
* @param params
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/24 17:30
|
||||
*/
|
||||
private String completions(AiragModel airagModel, List<ChatMessage> messages, AIChatParams params) {
|
||||
params = mergeParams(airagModel, params);
|
||||
String resp = llmHandler.completions(messages, params);
|
||||
if (resp.contains("</think>")
|
||||
&& (null == params.getNoThinking() || params.getNoThinking())) {
|
||||
String[] thinkSplit = resp.split("</think>");
|
||||
resp = thinkSplit[thinkSplit.length - 1];
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用默认模型问答
|
||||
*
|
||||
* @param messages
|
||||
* @param params
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/12 15:13
|
||||
*/
|
||||
@Override
|
||||
public String completionsByDefaultModel(List<ChatMessage> messages, AIChatParams params) {
|
||||
return completions(new AiragModel(), messages, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天(流式)
|
||||
*
|
||||
* @param modelId
|
||||
* @param messages
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/20 21:06
|
||||
*/
|
||||
@Override
|
||||
public TokenStream chat(String modelId, List<ChatMessage> messages) {
|
||||
return chat(modelId, messages, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天(流式)
|
||||
*
|
||||
* @param modelId
|
||||
* @param messages
|
||||
* @param params
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 21:03
|
||||
*/
|
||||
@Override
|
||||
public TokenStream chat(String modelId, List<ChatMessage> messages, AIChatParams params) {
|
||||
AssertUtils.assertNotEmpty("至少发送一条消息", messages);
|
||||
AssertUtils.assertNotEmpty("请选择模型", modelId);
|
||||
|
||||
AiragModel airagModel = airagModelService.getById(modelId);
|
||||
return chat(airagModel, messages, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天(流式)
|
||||
*
|
||||
* @param airagModel
|
||||
* @param messages
|
||||
* @param params
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/24 17:29
|
||||
*/
|
||||
private TokenStream chat(AiragModel airagModel, List<ChatMessage> messages, AIChatParams params) {
|
||||
params = mergeParams(airagModel, params);
|
||||
return llmHandler.chat(messages, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用默认模型聊天
|
||||
*
|
||||
* @param messages
|
||||
* @param params
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/12 15:13
|
||||
*/
|
||||
@Override
|
||||
public TokenStream chatByDefaultModel(List<ChatMessage> messages, AIChatParams params) {
|
||||
return chat(new AiragModel(), messages, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 airagmodel和params,params为准
|
||||
*
|
||||
* @param airagModel
|
||||
* @param params
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/11 17:45
|
||||
*/
|
||||
private AIChatParams mergeParams(AiragModel airagModel, AIChatParams params) {
|
||||
if (null == airagModel) {
|
||||
return params;
|
||||
}
|
||||
if (params == null) {
|
||||
params = new AIChatParams();
|
||||
}
|
||||
|
||||
params.setProvider(airagModel.getProvider());
|
||||
params.setModelName(airagModel.getModelName());
|
||||
params.setBaseUrl(airagModel.getBaseUrl());
|
||||
if (oConvertUtils.isObjectNotEmpty(airagModel.getCredential())) {
|
||||
JSONObject modelCredential = JSONObject.parseObject(airagModel.getCredential());
|
||||
params.setApiKey(oConvertUtils.getString(modelCredential.getString("apiKey"), null));
|
||||
params.setSecretKey(oConvertUtils.getString(modelCredential.getString("secretKey"), null));
|
||||
}
|
||||
if (oConvertUtils.isObjectNotEmpty(airagModel.getModelParams())) {
|
||||
JSONObject modelParams = JSONObject.parseObject(airagModel.getModelParams());
|
||||
if (oConvertUtils.isObjectEmpty(params.getTemperature())) {
|
||||
params.setTemperature(modelParams.getDouble("temperature"));
|
||||
}
|
||||
if (oConvertUtils.isObjectEmpty(params.getTopP())) {
|
||||
params.setTopP(modelParams.getDouble("topP"));
|
||||
}
|
||||
if (oConvertUtils.isObjectEmpty(params.getPresencePenalty())) {
|
||||
params.setPresencePenalty(modelParams.getDouble("presencePenalty"));
|
||||
}
|
||||
if (oConvertUtils.isObjectEmpty(params.getFrequencyPenalty())) {
|
||||
params.setFrequencyPenalty(modelParams.getDouble("frequencyPenalty"));
|
||||
}
|
||||
if (oConvertUtils.isObjectEmpty(params.getMaxTokens())) {
|
||||
params.setMaxTokens(modelParams.getInteger("maxTokens"));
|
||||
}
|
||||
if (oConvertUtils.isObjectEmpty(params.getTimeout())) {
|
||||
params.setMaxTokens(modelParams.getInteger("timeout"));
|
||||
}
|
||||
}
|
||||
|
||||
// RAG
|
||||
List<String> knowIds = params.getKnowIds();
|
||||
if (oConvertUtils.isObjectNotEmpty(knowIds)) {
|
||||
QueryRouter queryRouter = embeddingHandler.getQueryRouter(knowIds, params.getTopNumber(), params.getSimilarity());
|
||||
params.setQueryRouter(queryRouter);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserMessage buildUserMessage(String content, List<String> images) {
|
||||
AssertUtils.assertNotEmpty("请输入消息内容", content);
|
||||
List<Content> contents = new ArrayList<>();
|
||||
contents.add(TextContent.from(content));
|
||||
if (oConvertUtils.isObjectNotEmpty(images)) {
|
||||
// 获取所有图片,将他们转换为ImageContent
|
||||
List<ImageContent> imageContents = buildImageContents(images);
|
||||
contents.addAll(imageContents);
|
||||
}
|
||||
return UserMessage.from(contents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ImageContent> buildImageContents(List<String> images) {
|
||||
List<ImageContent> imageContents = new ArrayList<>();
|
||||
for (String imageUrl : images) {
|
||||
Matcher matcher = LLMConsts.WEB_PATTERN.matcher(imageUrl);
|
||||
if (matcher.matches()) {
|
||||
// 来源于网络
|
||||
imageContents.add(ImageContent.from(imageUrl));
|
||||
} else {
|
||||
// 本地文件
|
||||
String filePath = uploadpath + File.separator + imageUrl;
|
||||
// 读取文件并转换为 base64 编码字符串
|
||||
try {
|
||||
Path path = Paths.get(filePath);
|
||||
byte[] fileContent = Files.readAllBytes(path);
|
||||
String base64Data = Base64.getEncoder().encodeToString(fileContent);
|
||||
// 获取文件的 MIME 类型
|
||||
String mimeType = Files.probeContentType(path);
|
||||
// 构建 ImageContent 对象
|
||||
imageContents.add(ImageContent.from(base64Data, mimeType));
|
||||
} catch (IOException e) {
|
||||
log.error("读取文件失败: " + filePath, e);
|
||||
throw new RuntimeException("发送消息失败,读取文件异常:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return imageContents;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
package org.jeecg.modules.airag.llm.handler;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang.ArrayUtils;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @Description: 命令行执行工具类
|
||||
* @Author: chenrui
|
||||
* @Date: 2024/4/8 10:11
|
||||
*/
|
||||
@Slf4j
|
||||
public class CommandExecUtil {
|
||||
|
||||
|
||||
/**
|
||||
* 执行命令行
|
||||
*
|
||||
* @param command
|
||||
* @param args
|
||||
* @return
|
||||
* @throws IOException
|
||||
* @author chenrui
|
||||
* @date 2024/4/9 10:59
|
||||
*/
|
||||
public static String execCommand(String command, String[] args) throws IOException {
|
||||
if (null == command || command.isEmpty()) {
|
||||
throw new IllegalArgumentException("命令不能为空");
|
||||
}
|
||||
return execCommand(command.split(" "), args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令行
|
||||
*
|
||||
* @param command 脚本目录
|
||||
* @param args 参数
|
||||
* @author chenrui
|
||||
* @date 2024/4/09 10:30
|
||||
*/
|
||||
public static String execCommand(String[] command, String[] args) throws IOException {
|
||||
|
||||
if (null == command || command.length == 0) {
|
||||
throw new IllegalArgumentException("命令不能为空");
|
||||
}
|
||||
|
||||
if (null != args && args.length > 0) {
|
||||
command = (String[]) ArrayUtils.addAll(command, args);
|
||||
}
|
||||
|
||||
// windows系统处理文件夹空格问题
|
||||
if (System.getProperty("os.name").toLowerCase().startsWith("windows")) {
|
||||
List<String> commandNew = new ArrayList<>(command.length + 2);
|
||||
commandNew.addAll(Arrays.asList("cmd.exe", "/c"));
|
||||
for (String tempCommand : command) {
|
||||
if (tempCommand.contains(" ")) {
|
||||
tempCommand = "\"" + tempCommand.replaceAll("\"", "'") + "\"";
|
||||
}
|
||||
commandNew.add(tempCommand);
|
||||
}
|
||||
command = commandNew.toArray(new String[0]);
|
||||
}
|
||||
|
||||
|
||||
Process process = null;
|
||||
try {
|
||||
log.debug(" =============================== Runtime command Script ===============================" );
|
||||
log.debug(String.join(" ", command));
|
||||
log.debug(" =============================== Runtime command Script =============================== " );
|
||||
process = Runtime.getRuntime().exec(command);
|
||||
try (ByteArrayOutputStream resultOutStream = new ByteArrayOutputStream();
|
||||
InputStream processInStream = new BufferedInputStream(process.getInputStream())) {
|
||||
new Thread(new InputStreamRunnable(process.getErrorStream(), "ErrorStream")).start();
|
||||
int num;
|
||||
byte[] bs = new byte[1024];
|
||||
while ((num = processInStream.read(bs)) != -1) {
|
||||
resultOutStream.write(bs, 0, num);
|
||||
String stepMsg = new String(bs);
|
||||
// log.debug("命令行日志:" + stepMsg);
|
||||
if (stepMsg.contains("input any key to continue...")) {
|
||||
process.destroy();
|
||||
}
|
||||
}
|
||||
String result = resultOutStream.toString();
|
||||
log.debug("执行命令完成:" + result);
|
||||
return result;
|
||||
} catch (IOException ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error(e.getMessage(), e);
|
||||
throw e;
|
||||
} finally {
|
||||
if (process != null) {
|
||||
process.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* exec 控制台输出获取线程类
|
||||
* 使用单独的线程获取控制台输出,防止输入流阻塞
|
||||
*
|
||||
* @author chenrui
|
||||
* @date 2024/4/09 10:30
|
||||
*/
|
||||
static class InputStreamRunnable implements Runnable {
|
||||
BufferedReader bReader = null;
|
||||
String type = null;
|
||||
|
||||
public InputStreamRunnable(InputStream is, String _type) {
|
||||
try {
|
||||
bReader = new BufferedReader(new InputStreamReader(new BufferedInputStream(is), StandardCharsets.UTF_8));
|
||||
type = _type;
|
||||
} catch (Exception ex) {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public void run() {
|
||||
String line;
|
||||
int lineNum = 0;
|
||||
|
||||
try {
|
||||
while ((line = bReader.readLine()) != null) {
|
||||
lineNum++;
|
||||
// Thread.sleep(200);
|
||||
}
|
||||
bReader.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,576 @@
|
||||
package org.jeecg.modules.airag.llm.handler;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.google.common.collect.Lists;
|
||||
import dev.langchain4j.data.document.Document;
|
||||
import dev.langchain4j.data.document.DocumentSplitter;
|
||||
import dev.langchain4j.data.document.Metadata;
|
||||
import dev.langchain4j.data.document.splitter.DocumentSplitters;
|
||||
import dev.langchain4j.data.embedding.Embedding;
|
||||
import dev.langchain4j.data.segment.TextSegment;
|
||||
import dev.langchain4j.model.embedding.EmbeddingModel;
|
||||
import dev.langchain4j.model.openai.OpenAiTokenizer;
|
||||
import dev.langchain4j.rag.content.retriever.ContentRetriever;
|
||||
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
|
||||
import dev.langchain4j.rag.query.router.DefaultQueryRouter;
|
||||
import dev.langchain4j.rag.query.router.QueryRouter;
|
||||
import dev.langchain4j.store.embedding.EmbeddingMatch;
|
||||
import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
|
||||
import dev.langchain4j.store.embedding.EmbeddingStore;
|
||||
import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;
|
||||
import dev.langchain4j.store.embedding.pgvector.PgVectorEmbeddingStore;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.tika.parser.AutoDetectParser;
|
||||
import org.jeecg.ai.factory.AiModelFactory;
|
||||
import org.jeecg.ai.factory.AiModelOptions;
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.common.util.*;
|
||||
import org.jeecg.modules.airag.common.handler.IEmbeddingHandler;
|
||||
import org.jeecg.modules.airag.common.vo.knowledge.KnowledgeSearchResult;
|
||||
import org.jeecg.modules.airag.llm.config.EmbedStoreConfigBean;
|
||||
import org.jeecg.modules.airag.llm.config.KnowConfigBean;
|
||||
import org.jeecg.modules.airag.llm.consts.LLMConsts;
|
||||
import org.jeecg.modules.airag.llm.document.TikaDocumentParser;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledge;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledgeDoc;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeService;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragModelService;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static dev.langchain4j.store.embedding.filter.MetadataFilterBuilder.metadataKey;
|
||||
import static org.jeecg.modules.airag.llm.consts.LLMConsts.KNOWLEDGE_DOC_TYPE_FILE;
|
||||
import static org.jeecg.modules.airag.llm.consts.LLMConsts.KNOWLEDGE_DOC_TYPE_WEB;
|
||||
|
||||
/**
|
||||
* 向量工具类
|
||||
*
|
||||
* @Author: chenrui
|
||||
* @Date: 2025/2/18 14:31
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class EmbeddingHandler implements IEmbeddingHandler {
|
||||
|
||||
@Autowired
|
||||
EmbedStoreConfigBean embedStoreConfigBean;
|
||||
|
||||
@Autowired
|
||||
@Lazy
|
||||
private IAiragModelService airagModelService;
|
||||
|
||||
@Autowired
|
||||
@Lazy
|
||||
private IAiragKnowledgeService airagKnowledgeService;
|
||||
|
||||
@Value(value = "${jeecg.path.upload:}")
|
||||
private String uploadpath;
|
||||
|
||||
@Autowired
|
||||
KnowConfigBean knowConfigBean;
|
||||
|
||||
/**
|
||||
* 默认分段长度
|
||||
*/
|
||||
private static final int DEFAULT_SEGMENT_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* 默认分段重叠长度
|
||||
*/
|
||||
private static final int DEFAULT_OVERLAP_SIZE = 50;
|
||||
|
||||
/**
|
||||
* 向量存储元数据:knowledgeId
|
||||
*/
|
||||
public static final String EMBED_STORE_METADATA_KNOWLEDGEID = "knowledgeId";
|
||||
|
||||
/**
|
||||
* 向量存储元数据:docId
|
||||
*/
|
||||
public static final String EMBED_STORE_METADATA_DOCID = "docId";
|
||||
|
||||
/**
|
||||
* 向量存储元数据:docName
|
||||
*/
|
||||
public static final String EMBED_STORE_METADATA_DOCNAME = "docName";
|
||||
|
||||
/**
|
||||
* 向量存储缓存
|
||||
*/
|
||||
private static final ConcurrentHashMap<String, EmbeddingStore<TextSegment>> EMBED_STORE_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 向量化文档
|
||||
*
|
||||
* @param knowId
|
||||
* @param doc
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 11:52
|
||||
*/
|
||||
public Map<String, Object> embeddingDocument(String knowId, AiragKnowledgeDoc doc) {
|
||||
AiragKnowledge airagKnowledge = airagKnowledgeService.getById(knowId);
|
||||
AssertUtils.assertNotEmpty("知识库不存在", airagKnowledge);
|
||||
AssertUtils.assertNotEmpty("请先为知识库配置向量模型库", airagKnowledge.getEmbedId());
|
||||
AssertUtils.assertNotEmpty("文档不能为空", doc);
|
||||
// 读取文档
|
||||
String content = doc.getContent();
|
||||
// 向量化并存储
|
||||
if (oConvertUtils.isEmpty(content)) {
|
||||
switch (doc.getType()) {
|
||||
case KNOWLEDGE_DOC_TYPE_FILE:
|
||||
//解析文件
|
||||
if (knowConfigBean.isEnableMinerU()) {
|
||||
parseFileByMinerU(doc);
|
||||
}
|
||||
content = parseFile(doc);
|
||||
break;
|
||||
case KNOWLEDGE_DOC_TYPE_WEB:
|
||||
// TODO author: chenrui for:读取网站内容 date:2025/2/18
|
||||
break;
|
||||
}
|
||||
}
|
||||
//update-begin---author:chenrui ---date:20250307 for:[QQYUN-11443]【AI】是不是应该把标题也生成到向量库里,标题一般是有意义的------------
|
||||
if (oConvertUtils.isNotEmpty(doc.getTitle())) {
|
||||
content = doc.getTitle() + "\n\n" + content;
|
||||
}
|
||||
//update-end---author:chenrui ---date:20250307 for:[QQYUN-11443]【AI】是不是应该把标题也生成到向量库里,标题一般是有意义的------------
|
||||
|
||||
// 向量化 date:2025/2/18
|
||||
AiragModel model = getEmbedModelData(airagKnowledge.getEmbedId());
|
||||
AiModelOptions modelOp = buildModelOptions(model);
|
||||
EmbeddingModel embeddingModel = AiModelFactory.createEmbeddingModel(modelOp);
|
||||
EmbeddingStore<TextSegment> embeddingStore = getEmbedStore(model);
|
||||
// 删除旧数据
|
||||
embeddingStore.removeAll(metadataKey(EMBED_STORE_METADATA_DOCID).isEqualTo(doc.getId()));
|
||||
// 分段器
|
||||
DocumentSplitter splitter = DocumentSplitters.recursive(DEFAULT_SEGMENT_SIZE, DEFAULT_OVERLAP_SIZE, new OpenAiTokenizer());
|
||||
// 分段并存储
|
||||
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
|
||||
.documentSplitter(splitter)
|
||||
.embeddingModel(embeddingModel)
|
||||
.embeddingStore(embeddingStore)
|
||||
.build();
|
||||
Metadata metadata = Metadata.metadata(EMBED_STORE_METADATA_DOCID, doc.getId())
|
||||
.put(EMBED_STORE_METADATA_KNOWLEDGEID, doc.getKnowledgeId())
|
||||
.put(EMBED_STORE_METADATA_DOCNAME, FilenameUtils.getName(doc.getTitle()));
|
||||
Document from = Document.from(content, metadata);
|
||||
ingestor.ingest(from);
|
||||
return metadata.toMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* 向量查询(多知识库)
|
||||
*
|
||||
* @param knowIds
|
||||
* @param queryText
|
||||
* @param topNumber
|
||||
* @param similarity
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 16:52
|
||||
*/
|
||||
public KnowledgeSearchResult embeddingSearch(List<String> knowIds, String queryText, Integer topNumber, Double similarity) {
|
||||
AssertUtils.assertNotEmpty("请选择知识库", knowIds);
|
||||
AssertUtils.assertNotEmpty("请填写查询内容", queryText);
|
||||
|
||||
topNumber = oConvertUtils.getInteger(topNumber, 5);
|
||||
|
||||
//命中的文档列表
|
||||
List<Map<String, Object>> documents = new ArrayList<>(16);
|
||||
for (String knowId : knowIds) {
|
||||
List<Map<String, Object>> searchResp = searchEmbedding(knowId, queryText, topNumber, similarity);
|
||||
if (oConvertUtils.isObjectNotEmpty(searchResp)) {
|
||||
documents.addAll(searchResp);
|
||||
}
|
||||
}
|
||||
|
||||
//命中的文档内容
|
||||
StringBuilder data = new StringBuilder();
|
||||
// 对documents按score降序排序并取前topNumber个
|
||||
List<Map<String, Object>> sortedDocuments = documents.stream()
|
||||
.sorted(Comparator.comparingDouble((Map<String, Object> doc) -> (Double) doc.get("score")).reversed())
|
||||
.limit(topNumber)
|
||||
.peek(doc -> data.append(doc.get("content")).append("\n"))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new KnowledgeSearchResult(data.toString(), sortedDocuments);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向量查询
|
||||
*
|
||||
* @param knowId
|
||||
* @param queryText
|
||||
* @param topNumber
|
||||
* @param similarity
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 16:52
|
||||
*/
|
||||
public List<Map<String, Object>> searchEmbedding(String knowId, String queryText, Integer topNumber, Double similarity) {
|
||||
AssertUtils.assertNotEmpty("请选择知识库", knowId);
|
||||
AiragKnowledge knowledge = airagKnowledgeService.getById(knowId);
|
||||
AssertUtils.assertNotEmpty("知识库不存在", knowledge);
|
||||
AssertUtils.assertNotEmpty("请填写查询内容", queryText);
|
||||
AiragModel model = getEmbedModelData(knowledge.getEmbedId());
|
||||
|
||||
AiModelOptions modelOp = buildModelOptions(model);
|
||||
EmbeddingModel embeddingModel = AiModelFactory.createEmbeddingModel(modelOp);
|
||||
Embedding queryEmbedding = embeddingModel.embed(queryText).content();
|
||||
|
||||
topNumber = oConvertUtils.getInteger(topNumber, modelOp.getTopNumber());
|
||||
similarity = oConvertUtils.getDou(similarity, modelOp.getSimilarity());
|
||||
EmbeddingSearchRequest embeddingSearchRequest = EmbeddingSearchRequest.builder()
|
||||
.queryEmbedding(queryEmbedding)
|
||||
.maxResults(topNumber)
|
||||
.minScore(similarity)
|
||||
.filter(metadataKey(EMBED_STORE_METADATA_KNOWLEDGEID).isEqualTo(knowId))
|
||||
.build();
|
||||
|
||||
EmbeddingStore<TextSegment> embeddingStore = getEmbedStore(model);
|
||||
List<EmbeddingMatch<TextSegment>> relevant = embeddingStore.search(embeddingSearchRequest).matches();
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
if (oConvertUtils.isObjectNotEmpty(relevant)) {
|
||||
result = relevant.stream().map(matchRes -> {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("score", matchRes.score());
|
||||
data.put("content", matchRes.embedded().text());
|
||||
Metadata metadata = matchRes.embedded().metadata();
|
||||
data.put("chunk", metadata.getInteger("index"));
|
||||
data.put(EMBED_STORE_METADATA_DOCNAME, metadata.getString(EMBED_STORE_METADATA_DOCNAME));
|
||||
return data;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取向量查询路由
|
||||
*
|
||||
* @param knowIds
|
||||
* @param topNumber
|
||||
* @param similarity
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/20 21:03
|
||||
*/
|
||||
public QueryRouter getQueryRouter(List<String> knowIds, Integer topNumber, Double similarity) {
|
||||
AssertUtils.assertNotEmpty("请选择知识库", knowIds);
|
||||
List<ContentRetriever> retrievers = Lists.newArrayList();
|
||||
for (String knowId : knowIds) {
|
||||
if (oConvertUtils.isEmpty(knowId)) {
|
||||
continue;
|
||||
}
|
||||
AiragKnowledge knowledge = airagKnowledgeService.getById(knowId);
|
||||
AssertUtils.assertNotEmpty("知识库不存在", knowledge);
|
||||
AiragModel model = getEmbedModelData(knowledge.getEmbedId());
|
||||
AiModelOptions modelOptions = buildModelOptions(model);
|
||||
EmbeddingModel embeddingModel = AiModelFactory.createEmbeddingModel(modelOptions);
|
||||
|
||||
EmbeddingStore<TextSegment> embeddingStore = getEmbedStore(model);
|
||||
topNumber = oConvertUtils.getInteger(topNumber, 5);
|
||||
similarity = oConvertUtils.getDou(similarity, 0.75);
|
||||
// 构建一个嵌入存储内容检索器,用于从嵌入存储中检索内容
|
||||
EmbeddingStoreContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
|
||||
.embeddingStore(embeddingStore)
|
||||
.embeddingModel(embeddingModel)
|
||||
.maxResults(topNumber)
|
||||
.minScore(similarity)
|
||||
.filter(metadataKey(EMBED_STORE_METADATA_KNOWLEDGEID).isEqualTo(knowId))
|
||||
.build();
|
||||
retrievers.add(contentRetriever);
|
||||
}
|
||||
if (retrievers.isEmpty()) {
|
||||
return null;
|
||||
} else {
|
||||
return new DefaultQueryRouter(retrievers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除向量化文档
|
||||
*
|
||||
* @param knowId
|
||||
* @param modelId
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 19:07
|
||||
*/
|
||||
public void deleteEmbedDocsByKnowId(String knowId, String modelId) {
|
||||
AssertUtils.assertNotEmpty("选择知识库", knowId);
|
||||
AiragModel model = getEmbedModelData(modelId);
|
||||
|
||||
EmbeddingStore<TextSegment> embeddingStore = getEmbedStore(model);
|
||||
// 删除数据
|
||||
embeddingStore.removeAll(metadataKey(EMBED_STORE_METADATA_KNOWLEDGEID).isEqualTo(knowId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除向量化文档
|
||||
*
|
||||
* @param docIds
|
||||
* @param modelId
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 19:07
|
||||
*/
|
||||
public void deleteEmbedDocsByDocIds(List<String> docIds, String modelId) {
|
||||
AssertUtils.assertNotEmpty("选择文档", docIds);
|
||||
AiragModel model = getEmbedModelData(modelId);
|
||||
|
||||
EmbeddingStore<TextSegment> embeddingStore = getEmbedStore(model);
|
||||
// 删除数据
|
||||
embeddingStore.removeAll(metadataKey(EMBED_STORE_METADATA_DOCID).isIn(docIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询向量模型数据
|
||||
*
|
||||
* @param modelId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/20 20:08
|
||||
*/
|
||||
private AiragModel getEmbedModelData(String modelId) {
|
||||
AssertUtils.assertNotEmpty("向量模型不能为空", modelId);
|
||||
AiragModel model = airagModelService.getById(modelId);
|
||||
AssertUtils.assertNotEmpty("向量模型不存在", model);
|
||||
AssertUtils.assertEquals("仅支持向量模型", LLMConsts.MODEL_TYPE_EMBED, model.getModelType());
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取向量存储
|
||||
*
|
||||
* @param model
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 14:56
|
||||
*/
|
||||
private EmbeddingStore<TextSegment> getEmbedStore(AiragModel model) {
|
||||
AssertUtils.assertNotEmpty("未配置模型", model);
|
||||
String modelId = model.getId();
|
||||
String connectionInfo = embedStoreConfigBean.getHost() + embedStoreConfigBean.getPort() + embedStoreConfigBean.getDatabase();
|
||||
String key = modelId + connectionInfo;
|
||||
if (EMBED_STORE_CACHE.containsKey(key)) {
|
||||
return EMBED_STORE_CACHE.get(key);
|
||||
}
|
||||
|
||||
|
||||
AiModelOptions modelOp = buildModelOptions(model);
|
||||
EmbeddingModel embeddingModel = AiModelFactory.createEmbeddingModel(modelOp);
|
||||
EmbeddingStore<TextSegment> embeddingStore = PgVectorEmbeddingStore.builder()
|
||||
// Connection and table parameters
|
||||
.host(embedStoreConfigBean.getHost())
|
||||
.port(embedStoreConfigBean.getPort())
|
||||
.database(embedStoreConfigBean.getDatabase())
|
||||
.user(embedStoreConfigBean.getUser())
|
||||
.password(embedStoreConfigBean.getPassword())
|
||||
.table(embedStoreConfigBean.getTable())
|
||||
// Embedding dimension
|
||||
// Required: Must match the embedding model’s output dimension
|
||||
.dimension(embeddingModel.dimension())
|
||||
// Indexing and performance options
|
||||
// Enable IVFFlat index
|
||||
.useIndex(true)
|
||||
// Number of lists
|
||||
// for IVFFlat index
|
||||
.indexListSize(100)
|
||||
// Table creation options
|
||||
// Automatically create the table if it doesn’t exist
|
||||
.createTable(true)
|
||||
//Don’t drop the table first (set to true if you want a fresh start)
|
||||
.dropTableFirst(false)
|
||||
.build();
|
||||
EMBED_STORE_CACHE.put(key, embeddingStore);
|
||||
return embeddingStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造ModelOptions
|
||||
*
|
||||
* @param model
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/11 17:45
|
||||
*/
|
||||
public static AiModelOptions buildModelOptions(AiragModel model) {
|
||||
AiModelOptions.AiModelOptionsBuilder modelOpBuilder = AiModelOptions.builder()
|
||||
.provider(model.getProvider())
|
||||
.modelName(model.getModelName())
|
||||
.baseUrl(model.getBaseUrl());
|
||||
if (oConvertUtils.isObjectNotEmpty(model.getCredential())) {
|
||||
JSONObject modelCredential = JSONObject.parseObject(model.getCredential());
|
||||
modelOpBuilder.apiKey(oConvertUtils.getString(modelCredential.getString("apiKey"), null));
|
||||
modelOpBuilder.secretKey(oConvertUtils.getString(modelCredential.getString("secretKey"), null));
|
||||
}
|
||||
modelOpBuilder.topNumber(5);
|
||||
modelOpBuilder.similarity(0.75);
|
||||
return modelOpBuilder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文件
|
||||
*
|
||||
* @param doc
|
||||
* @author chenrui
|
||||
* @date 2025/3/5 11:31
|
||||
*/
|
||||
private String parseFile(AiragKnowledgeDoc doc) {
|
||||
String metadata = doc.getMetadata();
|
||||
AssertUtils.assertNotEmpty("请先上传文件", metadata);
|
||||
JSONObject metadataJson = JSONObject.parseObject(metadata);
|
||||
if (!metadataJson.containsKey(LLMConsts.KNOWLEDGE_DOC_METADATA_FILEPATH)) {
|
||||
throw new JeecgBootException("请先上传文件");
|
||||
}
|
||||
String filePath = metadataJson.getString(LLMConsts.KNOWLEDGE_DOC_METADATA_FILEPATH);
|
||||
AssertUtils.assertNotEmpty("请先上传文件", filePath);
|
||||
// 网络资源,先下载到临时目录
|
||||
filePath = ensureFile(filePath);
|
||||
// 提取文档内容
|
||||
File docFile = new File(filePath);
|
||||
if (docFile.exists()) {
|
||||
Document document = new TikaDocumentParser(AutoDetectParser::new, null, null, null).parse(docFile);
|
||||
if (null != document) {
|
||||
String content = document.text();
|
||||
// 判断是否md文档
|
||||
String fileType = FilenameUtils.getExtension(docFile.getName());
|
||||
if ("md".contains(fileType)) {
|
||||
// 如果是md文件,查找所有图片语法,如果是本地图片,替换成网络图片
|
||||
String baseUrl = doc.getBaseUrl() + "/sys/common/static/";
|
||||
String sourcePath = metadataJson.getString(LLMConsts.KNOWLEDGE_DOC_METADATA_SOURCES_PATH);
|
||||
sourcePath = sourcePath.replaceFirst("^" + uploadpath, "").replace("\\", "/");
|
||||
baseUrl = baseUrl + sourcePath + "/";
|
||||
StringBuffer sb = replaceImageUrl(content, baseUrl);
|
||||
content = sb.toString();
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static StringBuffer replaceImageUrl(String content, String baseUrl) {
|
||||
// 正则表达式匹配md文件中的图片语法 
|
||||
String mdImagePattern = "!\\[(.*?)]\\((.*?)(\\s*=\\d+)?\\)";
|
||||
Pattern pattern = Pattern.compile(mdImagePattern);
|
||||
Matcher matcher = pattern.matcher(content);
|
||||
|
||||
StringBuffer sb = new StringBuffer();
|
||||
while (matcher.find()) {
|
||||
String imageUrl = matcher.group(2);
|
||||
// 检查是否是本地图片路径
|
||||
if (!imageUrl.startsWith("http")) {
|
||||
// 替换成网络图片路径
|
||||
String networkImageUrl = baseUrl + imageUrl;
|
||||
matcher.appendReplacement(sb, "");
|
||||
} else {
|
||||
matcher.appendReplacement(sb, "");
|
||||
}
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
return sb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过MinerU解析文件
|
||||
*
|
||||
* @param doc
|
||||
* @author chenrui
|
||||
* @date 2025/4/1 17:37
|
||||
*/
|
||||
private void parseFileByMinerU(AiragKnowledgeDoc doc) {
|
||||
String metadata = doc.getMetadata();
|
||||
AssertUtils.assertNotEmpty("请先上传文件", metadata);
|
||||
JSONObject metadataJson = JSONObject.parseObject(metadata);
|
||||
if (!metadataJson.containsKey(LLMConsts.KNOWLEDGE_DOC_METADATA_FILEPATH)) {
|
||||
throw new JeecgBootException("请先上传文件");
|
||||
}
|
||||
String filePath = metadataJson.getString(LLMConsts.KNOWLEDGE_DOC_METADATA_FILEPATH);
|
||||
AssertUtils.assertNotEmpty("请先上传文件", filePath);
|
||||
filePath = ensureFile(filePath);
|
||||
|
||||
File docFile = new File(filePath);
|
||||
String fileType = FilenameUtils.getExtension(filePath);
|
||||
if (!docFile.exists()
|
||||
|| "txt".equalsIgnoreCase(fileType)
|
||||
|| "md".equalsIgnoreCase(fileType)) {
|
||||
return ;
|
||||
}
|
||||
|
||||
String command = "magic-pdf";
|
||||
if (oConvertUtils.isNotEmpty(knowConfigBean.getCondaEnv())) {
|
||||
command = "conda run -n " + knowConfigBean.getCondaEnv() + " " + command;
|
||||
}
|
||||
|
||||
String outputPath = docFile.getParentFile().getAbsolutePath();
|
||||
String[] args = {
|
||||
"-p", docFile.getAbsolutePath(),
|
||||
"-o", outputPath,
|
||||
};
|
||||
|
||||
try {
|
||||
String execLog = CommandExecUtil.execCommand(command, args);
|
||||
log.info("执行命令行:" + command + " args:" + Arrays.toString(args) + "\n log::" + execLog);
|
||||
// 如果成功,替换文件路径和静态资源路径
|
||||
String fileBaseName = FilenameUtils.getBaseName(docFile.getName());
|
||||
String newFileDir = outputPath + File.separator + fileBaseName + File.separator + "auto" + File.separator ;
|
||||
// 先检查文件是否存在,存在才替换
|
||||
File convertedFile = new File(newFileDir + fileBaseName + ".md");
|
||||
if (convertedFile.exists()) {
|
||||
log.info("文件转换成md成功,替换文件路径和静态资源路径");
|
||||
newFileDir = newFileDir.replaceFirst("^" + uploadpath, "");
|
||||
metadataJson.put(LLMConsts.KNOWLEDGE_DOC_METADATA_FILEPATH, newFileDir + fileBaseName + ".md");
|
||||
metadataJson.put(LLMConsts.KNOWLEDGE_DOC_METADATA_SOURCES_PATH, newFileDir);
|
||||
doc.setMetadata(metadataJson.toJSONString());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("文件转换md失败,使用传统提取方案{}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保文件存在
|
||||
* @param filePath
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/4/1 17:36
|
||||
*/
|
||||
@NotNull
|
||||
private String ensureFile(String filePath) {
|
||||
// 网络资源,先下载到临时目录
|
||||
Matcher matcher = LLMConsts.WEB_PATTERN.matcher(filePath);
|
||||
if (matcher.matches()) {
|
||||
log.info("网络资源,下载到临时目录:" + filePath);
|
||||
// 准备文件
|
||||
String tempFilePath = uploadpath + File.separator + "tmp" + File.separator + UUIDGenerator.generate() + File.separator;
|
||||
String fileName = filePath;
|
||||
if (fileName.contains("?")) {
|
||||
fileName = fileName.substring(0, fileName.indexOf("?"));
|
||||
}
|
||||
fileName = FilenameUtils.getName(fileName);
|
||||
tempFilePath = tempFilePath + fileName;
|
||||
FileDownloadUtils.download2DiskFromNet(filePath, tempFilePath);
|
||||
filePath = tempFilePath;
|
||||
} else {
|
||||
//本地文件
|
||||
filePath = uploadpath + File.separator + filePath;
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package org.jeecg.modules.airag.llm.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledgeDoc;
|
||||
|
||||
/**
|
||||
* @Description: airag知识库文档
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-18
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface AiragKnowledgeDocMapper extends BaseMapper<AiragKnowledgeDoc> {
|
||||
|
||||
/**
|
||||
* 通过主表id删除子表数据
|
||||
*
|
||||
* @param mainId 主表id
|
||||
* @return boolean
|
||||
*/
|
||||
public boolean deleteByMainId(@Param("mainId") String mainId);
|
||||
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package org.jeecg.modules.airag.llm.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledge;
|
||||
|
||||
/**
|
||||
* @Description: AIRag知识库
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-18
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface AiragKnowledgeMapper extends BaseMapper<AiragKnowledge> {
|
||||
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package org.jeecg.modules.airag.llm.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
|
||||
/**
|
||||
* @Description: AiRag模型配置
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-14
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface AiragModelMapper extends BaseMapper<AiragModel> {
|
||||
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.jeecg.modules.airag.llm.mapper.AiragKnowledgeDocMapper">
|
||||
|
||||
<delete id="deleteByMainId" parameterType="java.lang.String">
|
||||
DELETE
|
||||
FROM airag_knowledge_doc
|
||||
WHERE knowledge_id = #{mainId}
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.jeecg.modules.airag.llm.mapper.AiragKnowledgeMapper">
|
||||
|
||||
</mapper>
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.jeecg.modules.airag.llm.mapper.AiragModelMapper">
|
||||
|
||||
</mapper>
|
||||
@ -0,0 +1,79 @@
|
||||
package org.jeecg.modules.airag.llm.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledgeDoc;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* airag知识库文档
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-18
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface IAiragKnowledgeDocService extends IService<AiragKnowledgeDoc> {
|
||||
|
||||
/**
|
||||
* 重建文档
|
||||
*
|
||||
* @param docIds
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 11:14
|
||||
*/
|
||||
Result<?> rebuildDocument(String docIds);
|
||||
|
||||
/**
|
||||
* 添加文档
|
||||
*
|
||||
* @param airagKnowledgeDoc
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 15:30
|
||||
*/
|
||||
Result<?> editDocument(AiragKnowledgeDoc airagKnowledgeDoc);
|
||||
|
||||
|
||||
/**
|
||||
* 通过知识库id重建文档
|
||||
*
|
||||
* @param knowId
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 18:54
|
||||
*/
|
||||
Result<?> rebuildDocumentByKnowId(String knowId);
|
||||
|
||||
|
||||
/**
|
||||
* 通过知识库id删除文档
|
||||
*
|
||||
* @param knowIds
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 18:59
|
||||
*/
|
||||
Result<?> removeByKnowIds(List<String> knowIds);
|
||||
|
||||
/**
|
||||
* 通过文档id批量删除文档
|
||||
*
|
||||
* @param docIds
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/18 19:16
|
||||
*/
|
||||
Result<?> removeDocByIds(List<String> docIds);
|
||||
|
||||
/**
|
||||
* 从zip包导入文档
|
||||
* @param knowId
|
||||
* @param file
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/3/20 13:50
|
||||
*/
|
||||
Result<?> importDocumentFromZip(String knowId, MultipartFile file);
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package org.jeecg.modules.airag.llm.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledge;
|
||||
|
||||
/**
|
||||
* AIRag知识库
|
||||
*
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-18
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface IAiragKnowledgeService extends IService<AiragKnowledge> {
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package org.jeecg.modules.airag.llm.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import dev.langchain4j.data.message.ChatMessage;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @Description: AiRag模型配置
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-14
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface IAiragModelService extends IService<AiragModel> {
|
||||
|
||||
}
|
||||
@ -0,0 +1,317 @@
|
||||
package org.jeecg.modules.airag.llm.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.common.util.*;
|
||||
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
|
||||
import org.jeecg.modules.airag.llm.consts.LLMConsts;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledge;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledgeDoc;
|
||||
import org.jeecg.modules.airag.llm.handler.EmbeddingHandler;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragKnowledgeDocMapper;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragKnowledgeMapper;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeDocService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
import static org.jeecg.modules.airag.llm.consts.LLMConsts.*;
|
||||
|
||||
/**
|
||||
* @Description: airag知识库文档
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-18
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AiragKnowledgeDocServiceImpl extends ServiceImpl<AiragKnowledgeDocMapper, AiragKnowledgeDoc> implements IAiragKnowledgeDocService {
|
||||
|
||||
@Autowired
|
||||
private AiragKnowledgeDocMapper airagKnowledgeDocMapper;
|
||||
|
||||
@Autowired
|
||||
private AiragKnowledgeMapper airagKnowledgeMapper;
|
||||
|
||||
@Autowired
|
||||
EmbeddingHandler embeddingHandler;
|
||||
|
||||
|
||||
@Value(value = "${jeecg.path.upload:}")
|
||||
private String uploadpath;
|
||||
|
||||
/**
|
||||
* 支持的文档类型
|
||||
*/
|
||||
private static final List<String> SUPPORT_DOC_TYPE = Arrays.asList("txt", "pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls", "md");
|
||||
|
||||
/**
|
||||
* 向量化线程池大小
|
||||
*/
|
||||
private static final int THREAD_POOL_SIZE = 10;
|
||||
|
||||
/**
|
||||
* 向量化文档线程池
|
||||
*/
|
||||
private static final ExecutorService buildDocExecutorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
|
||||
|
||||
|
||||
@Transactional(rollbackFor = {Exception.class})
|
||||
@Override
|
||||
public Result<?> editDocument(AiragKnowledgeDoc airagKnowledgeDoc) {
|
||||
AssertUtils.assertNotEmpty("文档不能未空", airagKnowledgeDoc);
|
||||
AssertUtils.assertNotEmpty("知识库不能未空", airagKnowledgeDoc.getKnowledgeId());
|
||||
AssertUtils.assertNotEmpty("文档标题不能未空", airagKnowledgeDoc.getTitle());
|
||||
AssertUtils.assertNotEmpty("文档类型不能未空", airagKnowledgeDoc.getType());
|
||||
if (KNOWLEDGE_DOC_TYPE_TEXT.equals(airagKnowledgeDoc.getType())) {
|
||||
AssertUtils.assertNotEmpty("文档内容不能为空", airagKnowledgeDoc.getContent());
|
||||
}
|
||||
|
||||
airagKnowledgeDoc.setStatus(KNOWLEDGE_DOC_STATUS_DRAFT);
|
||||
// 保存到数据库
|
||||
if (this.saveOrUpdate(airagKnowledgeDoc)) {
|
||||
// 重建向量
|
||||
return this.rebuildDocument(airagKnowledgeDoc.getId());
|
||||
} else {
|
||||
return Result.error("保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> rebuildDocumentByKnowId(String knowId) {
|
||||
AssertUtils.assertNotEmpty("知识库id不能为空", knowId);
|
||||
List<AiragKnowledgeDoc> docList = airagKnowledgeDocMapper.selectList(Wrappers.lambdaQuery(AiragKnowledgeDoc.class).eq(AiragKnowledgeDoc::getKnowledgeId, knowId));
|
||||
if (oConvertUtils.isObjectEmpty(docList)) {
|
||||
return Result.OK();
|
||||
}
|
||||
String docIds = docList.stream().map(AiragKnowledgeDoc::getId).collect(Collectors.joining(","));
|
||||
return rebuildDocument(docIds);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = {Exception.class})
|
||||
@Override
|
||||
public Result<?> rebuildDocument(String docIds) {
|
||||
AssertUtils.assertNotEmpty("请选择要重建的文档", docIds);
|
||||
List<String> docIdList = Arrays.asList(docIds.split(","));
|
||||
// 查询数据
|
||||
List<AiragKnowledgeDoc> docList = airagKnowledgeDocMapper.selectBatchIds(docIdList);
|
||||
AssertUtils.assertNotEmpty("文档不存在", docList);
|
||||
|
||||
HttpServletRequest request = SpringContextUtils.getHttpServletRequest();
|
||||
String baseUrl = CommonUtils.getBaseUrl(request);
|
||||
// 检查状态
|
||||
List<AiragKnowledgeDoc> knowledgeDocs = docList.stream()
|
||||
.filter(doc -> !KNOWLEDGE_DOC_STATUS_BUILDING.equalsIgnoreCase(doc.getStatus()))
|
||||
.peek(doc -> {
|
||||
doc.setStatus(KNOWLEDGE_DOC_STATUS_BUILDING);
|
||||
doc.setBaseUrl(baseUrl);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
if (oConvertUtils.isObjectEmpty(knowledgeDocs)) {
|
||||
return Result.ok("向量化成功");
|
||||
}
|
||||
if (oConvertUtils.isObjectEmpty(knowledgeDocs)) {
|
||||
return Result.ok("向量化成功");
|
||||
}
|
||||
// 更新状态
|
||||
this.updateBatchById(knowledgeDocs);
|
||||
// 异步重建文档
|
||||
knowledgeDocs.forEach((doc) -> {
|
||||
CompletableFuture.runAsync(() -> {
|
||||
String knowId = doc.getKnowledgeId();
|
||||
log.info("开始重建文档, 知识库id: {}, 文档id: {}", knowId, doc.getId());
|
||||
doc.setStatus(KNOWLEDGE_DOC_STATUS_BUILDING);
|
||||
this.updateById(doc);
|
||||
Map<String, Object> metadata = embeddingHandler.embeddingDocument(knowId, doc);
|
||||
// 更新数据 date:2025/2/18
|
||||
if (null != metadata) {
|
||||
doc.setStatus(KNOWLEDGE_DOC_STATUS_COMPLETE);
|
||||
this.updateById(doc);
|
||||
log.info("重建文档成功, 知识库id: {}, 文档id: {}", knowId, doc.getId());
|
||||
} else {
|
||||
doc.setStatus(KNOWLEDGE_DOC_STATUS_DRAFT);
|
||||
this.updateById(doc);
|
||||
log.info("重建文档失败, 知识库id: {}, 文档id: {}", knowId, doc.getId());
|
||||
}
|
||||
}, buildDocExecutorService);
|
||||
});
|
||||
log.info("返回操作成功");
|
||||
return Result.ok("操作成功");
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Result<?> removeByKnowIds(List<String> knowIds) {
|
||||
AssertUtils.assertNotEmpty("选择知识库", knowIds);
|
||||
for (String knowId : knowIds) {
|
||||
AiragKnowledge airagKnowledge = airagKnowledgeMapper.selectById(knowId);
|
||||
AssertUtils.assertNotEmpty("知识库不存在", airagKnowledge);
|
||||
AssertUtils.assertNotEmpty("请先为知识库配置向量模型库", airagKnowledge.getEmbedId());
|
||||
// 删除数据
|
||||
embeddingHandler.deleteEmbedDocsByKnowId(knowId, airagKnowledge.getEmbedId());
|
||||
airagKnowledgeDocMapper.deleteByMainId(knowId);
|
||||
}
|
||||
return Result.OK();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> removeDocByIds(List<String> docIds) {
|
||||
AssertUtils.assertNotEmpty("请选择要删除的文档", docIds);
|
||||
// 查询数据
|
||||
List<AiragKnowledgeDoc> docList = airagKnowledgeDocMapper.selectBatchIds(docIds);
|
||||
AssertUtils.assertNotEmpty("文档不存在", docList);
|
||||
// 整理数据
|
||||
Map<String, List<String>> knowledgeDocs = docList.stream().collect(Collectors.groupingBy(
|
||||
AiragKnowledgeDoc::getKnowledgeId,
|
||||
Collectors.mapping(AiragKnowledgeDoc::getId, Collectors.toList())
|
||||
));
|
||||
if (oConvertUtils.isObjectEmpty(knowledgeDocs)) {
|
||||
return Result.ok("success");
|
||||
}
|
||||
knowledgeDocs.forEach((knowId, groupedDocIds) -> {
|
||||
AiragKnowledge airagKnowledge = airagKnowledgeMapper.selectById(knowId);
|
||||
AssertUtils.assertNotEmpty("知识库不存在", airagKnowledge);
|
||||
AssertUtils.assertNotEmpty("请先为知识库配置向量模型库", airagKnowledge.getEmbedId());
|
||||
// 删除数据
|
||||
embeddingHandler.deleteEmbedDocsByDocIds(groupedDocIds, airagKnowledge.getEmbedId());
|
||||
airagKnowledgeDocMapper.deleteBatchIds(groupedDocIds);
|
||||
});
|
||||
return Result.ok("success");
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = {Exception.class})
|
||||
@Override
|
||||
public Result<?> importDocumentFromZip(String knowId, MultipartFile zipFile) {
|
||||
AssertUtils.assertNotEmpty("请先选择知识库", knowId);
|
||||
AssertUtils.assertNotEmpty("请上传文件", zipFile);
|
||||
long startTime = System.currentTimeMillis();
|
||||
log.info("开始上传知识库文档(zip), 知识库id: {}, 文件名: {}", knowId, zipFile.getOriginalFilename());
|
||||
|
||||
try {
|
||||
String bizPath = knowId + File.separator + UUIDGenerator.generate();
|
||||
String workDir = uploadpath + File.separator + bizPath + File.separator;
|
||||
String sourcesPath = workDir + "files";
|
||||
|
||||
SsrfFileTypeFilter.checkUploadFileType(zipFile);
|
||||
// 通过filePath 检查文件是不是压缩包(zip)
|
||||
String zipFileName = FilenameUtils.getBaseName(zipFile.getOriginalFilename());
|
||||
String fileExt = FilenameUtils.getExtension(zipFile.getOriginalFilename());
|
||||
if (null == fileExt || !fileExt.equalsIgnoreCase("zip")) {
|
||||
throw new JeecgBootException("请上传zip压缩包");
|
||||
}
|
||||
String uploadedZipPath = CommonUtils.uploadLocal(zipFile, bizPath, uploadpath);
|
||||
// 解压缩文件
|
||||
List<AiragKnowledgeDoc> docList = new ArrayList<>();
|
||||
AtomicInteger fileCount = new AtomicInteger(0);
|
||||
unzipFile(uploadpath + File.separator + uploadedZipPath, sourcesPath, uploadedFile -> {
|
||||
// 仅支持txt、pdf、docx、pptx、html、md文件
|
||||
String fileName = uploadedFile.getName();
|
||||
if (!SUPPORT_DOC_TYPE.contains(FilenameUtils.getExtension(fileName).toLowerCase())) {
|
||||
log.warn("不支持的文件类型: {}", fileName);
|
||||
return;
|
||||
}
|
||||
String baseName = FilenameUtils.getBaseName(fileName);
|
||||
AiragKnowledgeDoc doc = new AiragKnowledgeDoc();
|
||||
doc.setKnowledgeId(knowId);
|
||||
doc.setTitle(baseName);
|
||||
doc.setType(LLMConsts.KNOWLEDGE_DOC_TYPE_FILE);
|
||||
doc.setStatus(LLMConsts.KNOWLEDGE_DOC_STATUS_DRAFT);
|
||||
JSONObject metadata = new JSONObject();
|
||||
String relativePath = uploadedFile.getPath().replaceFirst("^" + uploadpath, "");
|
||||
metadata.put(LLMConsts.KNOWLEDGE_DOC_METADATA_FILEPATH, relativePath);
|
||||
metadata.put(LLMConsts.KNOWLEDGE_DOC_METADATA_SOURCES_PATH, sourcesPath);
|
||||
doc.setMetadata(metadata.toJSONString());
|
||||
docList.add(doc);
|
||||
});
|
||||
// 保存数据
|
||||
this.saveBatch(docList);
|
||||
// 重建文档
|
||||
String docIds = docList.stream().map(AiragKnowledgeDoc::getId).filter(oConvertUtils::isObjectNotEmpty).collect(Collectors.joining(","));
|
||||
rebuildDocument(docIds);
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
log.info("上传知识库文档(zip)成功, 知识库id: {}, 文件名: {}, 耗时: {}ms", knowId, zipFile.getOriginalFilename(), (System.currentTimeMillis() - startTime));
|
||||
return Result.ok("上传成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压缩文件
|
||||
*
|
||||
* @param zipFilePath
|
||||
* @param destDir
|
||||
* @param afterExtract
|
||||
* @throws IOException
|
||||
* @author chenrui
|
||||
* @date 2025/3/20 14:37
|
||||
*/
|
||||
public static void unzipFile(String zipFilePath, String destDir, Consumer<File> afterExtract) throws
|
||||
IOException {
|
||||
// 创建目标目录
|
||||
File dir = new File(destDir);
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs();
|
||||
}
|
||||
|
||||
try (ZipFile zipFile = new ZipFile(zipFilePath)) {
|
||||
Enumeration<? extends ZipEntry> entries = zipFile.entries();
|
||||
byte[] buffer = new byte[1024];
|
||||
|
||||
while (entries.hasMoreElements()) {
|
||||
ZipEntry ze = entries.nextElement();
|
||||
File newFile = new File(destDir, ze.getName());
|
||||
|
||||
// 预防 ZIP 路径穿越攻击
|
||||
String canonicalDestDirPath = dir.getCanonicalPath();
|
||||
String canonicalFilePath = newFile.getCanonicalPath();
|
||||
if (!canonicalFilePath.startsWith(canonicalDestDirPath + File.separator)) {
|
||||
throw new IOException("ZIP 路径穿越攻击被阻止: " + ze.getName());
|
||||
}
|
||||
|
||||
if (ze.isDirectory()) {
|
||||
newFile.mkdirs();
|
||||
} else {
|
||||
// 创建父目录
|
||||
new File(newFile.getParent()).mkdirs();
|
||||
|
||||
// 读取 ZIP 文件并写入新文件
|
||||
try (InputStream zis = zipFile.getInputStream(ze);
|
||||
FileOutputStream fos = new FileOutputStream(newFile)) {
|
||||
int len;
|
||||
while ((len = zis.read(buffer)) > 0) {
|
||||
fos.write(buffer, 0, len);
|
||||
}
|
||||
}
|
||||
|
||||
if (afterExtract != null) {
|
||||
afterExtract.accept(newFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package org.jeecg.modules.airag.llm.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledge;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragKnowledgeMapper;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* @Description: AIRag知识库
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-18
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Service
|
||||
public class AiragKnowledgeServiceImpl extends ServiceImpl<AiragKnowledgeMapper, AiragKnowledge> implements IAiragKnowledgeService {
|
||||
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package org.jeecg.modules.airag.llm.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragModelMapper;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragModelService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* @Description: AiRag模型配置
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-02-14
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Service
|
||||
public class AiragModelServiceImpl extends ServiceImpl<AiragModelMapper, AiragModel> implements IAiragModelService {
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package org.jeecg.modules.airag.llm.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 知识库查询返回结果
|
||||
*
|
||||
* @Author: chenrui
|
||||
* @Date: 2025/2/18 17:53
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class KnowledgeSearchResult {
|
||||
|
||||
/**
|
||||
* 命中的文档内容
|
||||
*/
|
||||
String data;
|
||||
|
||||
/**
|
||||
* 命中的文档列表
|
||||
*/
|
||||
List<Map<String, Object>> documents;
|
||||
|
||||
public KnowledgeSearchResult(String data, List<Map<String, Object>> documents) {
|
||||
this.data = data;
|
||||
this.documents = documents;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
server:
|
||||
port: 7008
|
||||
spring:
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
time-zone: GMT+8
|
||||
autoconfigure:
|
||||
exclude:
|
||||
- com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure
|
||||
- org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration
|
||||
datasource:
|
||||
druid:
|
||||
stat-view-servlet:
|
||||
enabled: true
|
||||
loginUsername: admin
|
||||
loginPassword: 123456
|
||||
allow:
|
||||
web-stat-filter:
|
||||
enabled: true
|
||||
dynamic:
|
||||
druid: # 全局druid参数,绝大部分值和默认保持一致。(现已支持的参数如下,不清楚含义不要乱设置)
|
||||
# 连接池的配置信息
|
||||
# 初始化大小,最小,最大
|
||||
initial-size: 5
|
||||
min-idle: 5
|
||||
maxActive: 1000
|
||||
# 配置获取连接等待超时的时间
|
||||
maxWait: 60000
|
||||
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
|
||||
timeBetweenEvictionRunsMillis: 60000
|
||||
# 配置一个连接在池中最小生存的时间,单位是毫秒
|
||||
minEvictableIdleTimeMillis: 300000
|
||||
# validationQuery: SELECT 1
|
||||
testWhileIdle: true
|
||||
testOnBorrow: false
|
||||
testOnReturn: false
|
||||
# 打开PSCache,并且指定每个连接上PSCache的大小
|
||||
poolPreparedStatements: true
|
||||
maxPoolPreparedStatementPerConnectionSize: 20
|
||||
# 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
|
||||
# !!!!!mysql
|
||||
# filters: stat,slf4j,wall
|
||||
# !!!!!DM
|
||||
filters: stat,slf4j
|
||||
# 允许SELECT语句的WHERE子句是一个永真条件
|
||||
# wall:
|
||||
# selectWhereAlwayTrueCheck: false
|
||||
# 打开mergeSql功能;慢SQL记录
|
||||
stat:
|
||||
merge-sql: true
|
||||
slow-sql-millis: 5000
|
||||
datasource:
|
||||
master:
|
||||
## !!!!!MYSQL
|
||||
url: jdbc:mysql://localhost:3306/jeecg-boot-dev?characterEncoding=UTF-8&useUnicode=true&useSSL=false&tinyInt1isBit=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
|
||||
username: root
|
||||
password: 123456
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
redis:
|
||||
database: 0
|
||||
host: 192.168.1.188
|
||||
port: 6379
|
||||
password: 'res983'
|
||||
jeecg:
|
||||
ai-rag:
|
||||
embed-store:
|
||||
host: "localhost"
|
||||
port: 15432
|
||||
database: "postgres"
|
||||
user: "postgres"
|
||||
password: "123456"
|
||||
table: "embeddings"
|
||||
@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration debug="false">
|
||||
<!--定义日志文件的存储地址 -->
|
||||
<property name="LOG_HOME" value="../logs" />
|
||||
|
||||
<!--<property name="COLOR_PATTERN" value="%black(%contextName-) %red(%d{yyyy-MM-dd HH:mm:ss}) %green([%thread]) %highlight(%-5level) %boldMagenta( %replace(%caller{1}){'\t|Caller.{1}0|\r\n', ''})- %gray(%msg%xEx%n)" />-->
|
||||
<!-- 控制台输出 -->
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
||||
<!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度%msg:日志消息,%n是换行符
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}:%L - %msg%n</pattern>-->
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{50}:%L) - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 按照每天生成日志文件 -->
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<!--日志文件输出的文件名 -->
|
||||
<FileNamePattern>${LOG_HOME}/jeecgboot-%d{yyyy-MM-dd}.%i.log</FileNamePattern>
|
||||
<!--日志文件保留天数 -->
|
||||
<MaxHistory>30</MaxHistory>
|
||||
<maxFileSize>10MB</maxFileSize>
|
||||
</rollingPolicy>
|
||||
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
||||
<!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度%msg:日志消息,%n是换行符 -->
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}:%L - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 生成 error html格式日志开始 -->
|
||||
<appender name="HTML" class="ch.qos.logback.core.FileAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<!--设置日志级别,过滤掉info日志,只输入error日志-->
|
||||
<level>ERROR</level>
|
||||
</filter>
|
||||
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
|
||||
<layout class="ch.qos.logback.classic.html.HTMLLayout">
|
||||
<pattern>%p%d%msg%M%F{32}%L</pattern>
|
||||
</layout>
|
||||
</encoder>
|
||||
<file>${LOG_HOME}/error-log.html</file>
|
||||
</appender>
|
||||
<!-- 生成 error html格式日志结束 -->
|
||||
|
||||
<!-- 每天生成一个html格式的日志开始 -->
|
||||
<appender name="FILE_HTML" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<!--日志文件输出的文件名 -->
|
||||
<FileNamePattern>${LOG_HOME}/jeecgboot-%d{yyyy-MM-dd}.%i.html</FileNamePattern>
|
||||
<!--日志文件保留天数 -->
|
||||
<MaxHistory>30</MaxHistory>
|
||||
<MaxFileSize>10MB</MaxFileSize>
|
||||
</rollingPolicy>
|
||||
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
|
||||
<layout class="ch.qos.logback.classic.html.HTMLLayout">
|
||||
<pattern>%p%d%msg%M%F{32}%L</pattern>
|
||||
</layout>
|
||||
</encoder>
|
||||
</appender>
|
||||
<!-- 每天生成一个html格式的日志结束 -->
|
||||
|
||||
<!--myibatis log configure -->
|
||||
<logger name="com.apache.ibatis" level="TRACE" />
|
||||
<logger name="java.sql.Connection" level="DEBUG" />
|
||||
<logger name="java.sql.Statement" level="DEBUG" />
|
||||
<logger name="java.sql.PreparedStatement" level="DEBUG" />
|
||||
<logger name="logging.level.dev.langchain4j" level="DEBUG" />
|
||||
<logger name="logging.level.dev.ai4j.openai4j" level="DEBUG" />
|
||||
<!-- 日志输出级别 -->
|
||||
<root level="info">
|
||||
<appender-ref ref="STDOUT" />
|
||||
<!-- <appender-ref ref="FILE" />-->
|
||||
<!-- <appender-ref ref="HTML" />-->
|
||||
<!-- <appender-ref ref="FILE_HTML" />-->
|
||||
</root>
|
||||
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user