新增 CLAUDE.md 文件以提供项目指导,添加 .claudeignore 文件以排除不必要的文件,更新 pom.xml 版本至 3.9.2,修复多个路径遍历和 SQL 注入漏洞,优化字典翻译切面逻辑,增强文件上传和下载的安全性,新增音频文件类型支持,改进动态数据源的安全校验。

This commit is contained in:
geht
2026-05-18 20:05:03 +08:00
parent 67ca5287e2
commit 140f4a816e
589 changed files with 65043 additions and 4682 deletions

View File

@@ -6,7 +6,7 @@
<parent>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-boot-module</artifactId>
<version>3.9.1</version>
<version>3.9.2</version>
</parent>
<artifactId>jeecg-boot-module-airag</artifactId>
@@ -41,14 +41,14 @@
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>1.9.1</version>
<version>1.12.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-bom</artifactId>
<version>1.9.1-beta17</version>
<version>1.12.1-beta21</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -75,7 +75,7 @@
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-aiflow</artifactId>
<version>3.9.1-beta1</version>
<version>3.9.2-beta</version>
<exclusions>
<exclusion>
<groupId>commons-io</groupId>
@@ -103,7 +103,7 @@
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-graaljs</artifactId>
<version>${liteflow.version}</version>
<scope>compile</scope>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
@@ -160,6 +160,10 @@
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-google-ai-gemini</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-zhipu-ai</artifactId>
@@ -233,12 +237,29 @@
<artifactId>tika-parser-text-module</artifactId>
<version>${apache-tika.version}</version>
</dependency>
<!--skills-->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-skills</artifactId>
</dependency>
<!--命令模式-->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-experimental-skills-shell</artifactId>
<version>1.12.2-beta22</version>
</dependency>
<!-- word模版引擎 -->
<dependency>
<groupId>com.deepoove</groupId>
<artifactId>poi-tl</artifactId>
<version>1.12.2</version>
</dependency>
<!-- jsoup HTML parser library @ https://jsoup.org/ -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.22.1</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,107 @@
package org.jeecg.modules.airag.api;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.airag.api.IAiragBaseApi;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.exception.JeecgBootBizTipException;
import org.jeecg.common.util.AssertUtils;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.airag.app.entity.AiragApp;
import org.jeecg.modules.airag.app.service.IAiragAppService;
import org.jeecg.modules.airag.app.service.IAiragVariableService;
import org.jeecg.modules.airag.llm.consts.LLMConsts;
import org.jeecg.modules.airag.llm.entity.AiragKnowledgeDoc;
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeDocService;
import org.jeecg.modules.airag.prompts.entity.AiragPrompts;
import org.jeecg.modules.airag.prompts.service.IAiragPromptsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
/**
* airag baseAPI 实现类
*/
@Slf4j
@Primary
@Service("airagBaseApiImpl")
public class AiragBaseApiImpl implements IAiragBaseApi {
@Autowired
private IAiragKnowledgeDocService airagKnowledgeDocService;
@Override
public String knowledgeWriteTextDocument(String knowledgeId, String title, String content, String segmentConfig) {
AssertUtils.assertNotEmpty("知识库ID不能为空", knowledgeId);
AssertUtils.assertNotEmpty("写入内容不能为空", content);
AiragKnowledgeDoc knowledgeDoc = new AiragKnowledgeDoc();
knowledgeDoc.setKnowledgeId(knowledgeId);
knowledgeDoc.setTitle(title);
knowledgeDoc.setType(LLMConsts.KNOWLEDGE_DOC_TYPE_TEXT);
knowledgeDoc.setContent(content);
// 将分段策略配置写入文档的metadata中EmbeddingHandler会从中读取分段配置
if (oConvertUtils.isNotEmpty(segmentConfig)) {
knowledgeDoc.setMetadata(segmentConfig);
}
Result<?> result = airagKnowledgeDocService.editDocument(knowledgeDoc);
if (!result.isSuccess()) {
throw new JeecgBootBizTipException(result.getMessage());
}
if (knowledgeDoc.getId() == null) {
throw new JeecgBootBizTipException("知识库文档ID为空");
}
log.info("[AI-KNOWLEDGE] 文档写入完成,知识库:{}, 文档ID:{}", knowledgeId, knowledgeDoc.getId());
return knowledgeDoc.getId();
}
@Autowired
private IAiragAppService airagAppService;
@Autowired
private IAiragVariableService airagVariableService;
@Autowired
private IAiragPromptsService airagPromptsService;
@Override
public String getChatVariable(String appId, String username, String name) {
return airagVariableService.getVariable(username, appId, name);
}
@Override
public void setChatVariable(String appId, String username, String name, String value) {
AssertUtils.assertNotEmpty("应用ID不能为空", appId);
AssertUtils.assertNotEmpty("用户名不能为空", username);
AssertUtils.assertNotEmpty("变量名不能为空", name);
airagVariableService.updateVariable(username, appId, name, value != null ? value : "");
}
@Override
public String getMemoryIdByAppId(String appId) {
if (oConvertUtils.isEmpty(appId)) {
return null;
}
LambdaQueryWrapper<AiragApp> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(AiragApp::getId, appId)
.eq(AiragApp::getIzOpenMemory, 1)
.isNotNull(AiragApp::getMemoryId)
.ne(AiragApp::getMemoryId, "")
.select(AiragApp::getMemoryId);
AiragApp app = airagAppService.getOne(queryWrapper);
return app != null ? app.getMemoryId() : null;
}
@Override
public String getPromptContent(String promptId) {
if (oConvertUtils.isEmpty(promptId)) {
return null;
}
AiragPrompts prompt = airagPromptsService.getById(promptId);
if (prompt == null) {
log.warn("[AiragBaseApi]提示词不存在promptId={}", promptId);
return null;
}
return prompt.getContent();
}
}

View File

@@ -64,4 +64,27 @@ public class AiAppConsts {
* AI写作redis请求前缀
*/
public static final String ARTICLE_WRITER_KEY = "airag:chat:article:write:{}";
/**
* ai绘画类型: 绘图
*/
public static final String AI_DRAW_TYPE_DRAW = "draw";
/**
* ai绘画类型: 换脸
*/
public static final String AI_DRAW_TYPE_FACE = "face";
/**
* ai绘画类型: 混图
*/
public static final String AI_DRAW_TYPE_MIX = "mix";
/**
* ai绘画 会话redis请求前缀
*/
public static final String POSTER_TASK_PREFIX = "airag:poster:task:";
/** 任务结果在 Redis 中保留 1 小时 */
public static final long POSTER_TASK_TTL = 3600L;
}

View File

@@ -173,4 +173,9 @@ public class Prompts {
*/
public static final String AI_TOUCHE_PROMPT = "请针对如下内容:[{}] 进行润色。 回复格式:{},语气:{},语言:{},长度:{}。";
/**
* ai绘画提示词
*/
public static final String AI_DRAW_PROMPT = "风格:{},视角:{},人物镜头:{},灯光:{},图片尺寸:{};";
}

View File

@@ -23,8 +23,10 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.stream.Collectors;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.system.vo.DictModel;
/**
* @Description: AI应用
@@ -62,6 +64,25 @@ public class AiragAppController extends JeecgController<AiragApp, IAiragAppServi
return Result.OK(pageList);
}
/**
* 字典列表查询(不分页,按创建时间倒序)
*
* @param airagApp 支持通过实体字段动态过滤,如 type 等
* @param req HTTP请求
* @return 应用字典列表
*/
@GetMapping(value = "/listDict")
public Result<List<DictModel>> listDict(AiragApp airagApp, HttpServletRequest req) {
QueryWrapper<AiragApp> queryWrapper = QueryGenerator.initQueryWrapper(airagApp, req.getParameterMap());
queryWrapper.select("id", "name");
queryWrapper.orderByDesc("create_time");
List<AiragApp> list = airagAppService.list(queryWrapper);
List<DictModel> dictList = list.stream()
.map(app -> new DictModel(app.getId(), app.getName()))
.collect(Collectors.toList());
return Result.OK(dictList);
}
/**
* 新增或编辑
*
@@ -70,10 +91,24 @@ public class AiragAppController extends JeecgController<AiragApp, IAiragAppServi
*/
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
@RequiresPermissions("airag:app:edit")
public Result<String> edit(@RequestBody AiragApp airagApp) {
public Result<String> edit(@RequestBody AiragApp airagApp, HttpServletRequest request) {
AssertUtils.assertNotEmpty("参数异常", airagApp);
AssertUtils.assertNotEmpty("请输入应用名称", airagApp.getName());
AssertUtils.assertNotEmpty("请选择应用类型", airagApp.getType());
//update-begin---author:zhangdaihao ---date:20260415 for[issues/9462]AI应用edit接口跨租户数据写入漏洞------------
//SaaS多租户隔离禁止跨租户写入防止通过请求体伪造tenantId污染其他租户数据
if (MybatisPlusSaasConfig.OPEN_SYSTEM_TENANT_CONTROL) {
String currentTenantId = TokenUtils.getTenantIdByRequest(request);
if (airagApp.getId() != null && !airagApp.getId().isEmpty()) {
AiragApp dbApp = airagAppService.getById(airagApp.getId());
if (dbApp == null || !dbApp.getTenantId().equals(currentTenantId)) {
return Result.error("保存AI应用失败不能修改其他租户的AI应用");
}
}
//强制使用当前登录租户,忽略客户端传入值
airagApp.setTenantId(currentTenantId);
}
//update-end---author:zhangdaihao ---date:20260415 for[issues/9462]AI应用edit接口跨租户数据写入漏洞------------
airagApp.setStatus(AiAppConsts.STATUS_ENABLE);
airagAppService.saveOrUpdate(airagApp);
return Result.OK("保存完成!", airagApp.getId());

View File

@@ -8,6 +8,7 @@ import org.jeecg.common.constant.CommonConstant;
import org.jeecg.common.util.CommonUtils;
import org.jeecg.config.shiro.IgnoreAuth;
import org.jeecg.modules.airag.app.service.IAiragChatService;
import org.jeecg.modules.airag.app.vo.AiDrawGenerateVo;
import org.jeecg.modules.airag.app.vo.AiWriteGenerateVo;
import org.jeecg.modules.airag.app.vo.ChatConversation;
import org.jeecg.modules.airag.app.vo.ChatSendParams;
@@ -266,11 +267,31 @@ public class AiragChatController {
* @return
*/
@PostMapping("/genAiPoster")
public Result<String> genAiPoster(@RequestBody ChatSendParams chatSendParams){
String imageUrl = chatService.genAiPoster(chatSendParams);
public Result<String> genAiPoster(@RequestBody AiDrawGenerateVo aiDrawGenerateVo){
String imageUrl = chatService.genAiPoster(aiDrawGenerateVo);
return Result.OK(imageUrl);
}
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
/**
* 异步提交AI海报生成任务立即返回taskId
*/
@PostMapping("/genAiPosterAsync")
public Result<String> genAiPosterAsync(@RequestBody AiDrawGenerateVo aiDrawGenerateVo) {
String taskId = chatService.genAiPosterAsync(aiDrawGenerateVo);
return Result.OK(taskId);
}
/**
* 查询AI海报异步任务结果
* status: pending / success / failed
*/
@GetMapping("/getAiPosterResult/{taskId}")
public Result<?> getAiPosterResult(@PathVariable String taskId) {
return chatService.getAiPosterResult(taskId);
}
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
/**
* 生成ai写作

View File

@@ -0,0 +1,36 @@
package org.jeecg.modules.airag.app.enums;
/**
* @Description: 图像编辑枚举
*
* @author: wangshuai
* @date: 2026/2/28 16:52
*/
public enum ImageEditEnum {
WANX2_1_IMAGEEDIT("wanx2.1-imageedit"),
WAN2_5_I2I_PREVIEW("wan2.5-i2i-preview");
private final String modelName;
ImageEditEnum(String modelName) {
this.modelName = modelName;
}
public String getModelName() {
return modelName;
}
/**
* 检查模型名称是否是图像编辑模型
* @param modelName 模型名称
* @return 是否是图像编辑模型
*/
public static boolean isImageEditModel(String modelName) {
for (ImageEditEnum model : values()) {
if (model.getModelName().equals(modelName)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,57 @@
package org.jeecg.modules.airag.app.enums;
import org.apache.commons.lang3.StringUtils;
/**
* @Description: 图片大小比例枚举
*
* @author: wangshuai
* @date: 2026/2/4 19:55
*/
public enum ImageSizeEnum {
SIZE_1024_1024("1024*1024", "1:1"),
SIZE_1280_720("1280*720", "16:9"),
SIZE_720_1280("720*1280", "9:16"),
SIZE_1024_768("1024*768", "4:3"),
SIZE_768_1024("768*1024", "3:4");
ImageSizeEnum(String size, String ratio) {
this.size = size;
this.ratio = ratio;
}
/**
* 大小
*/
private String size;
/**
* 比例
*/
private String ratio;
public String getSize() {
return size;
}
public String getRatio() {
return ratio;
}
/**
* 根据size获取ratio
*
* @param size
* @return
*/
public static String getRatioBySize(String size) {
if (StringUtils.isBlank(size)) {
return "1:1";
}
for (ImageSizeEnum e : ImageSizeEnum.values()) {
if (e.size.equals(size)) {
return e.ratio;
}
}
return "1:1";
}
}

View File

@@ -1,10 +1,7 @@
package org.jeecg.modules.airag.app.service;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.airag.app.vo.AiWriteGenerateVo;
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.app.vo.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
@@ -126,10 +123,26 @@ public interface IAiragChatService {
/**
* 生成海报图片
* @param chatSendParams
* @param aiDrawGenerateVo
* @return
*/
String genAiPoster(ChatSendParams chatSendParams);
String genAiPoster(AiDrawGenerateVo aiDrawGenerateVo);
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
/**
* 异步生成海报图片立即返回taskId
* @param aiDrawGenerateVo
* @return taskId
*/
String genAiPosterAsync(AiDrawGenerateVo aiDrawGenerateVo);
/**
* 查询异步海报任务结果
* @param taskId
* @return Resultdata为图片URL成功或status=pending/failed
*/
Result<?> getAiPosterResult(String taskId);
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
/**
* 生成ai创作

View File

@@ -33,6 +33,16 @@ public interface IAiragVariableService {
*/
void initVariable(String userId, String appId, String name, String defaultValue);
/**
* 获取变量值
*
* @param username 用户名
* @param appId 应用ID
* @param name 变量名
* @return 变量值不存在返回null
*/
String getVariable(String username, String appId, String name);
/**
* 添加变量更新工具
*

View File

@@ -1,6 +1,7 @@
package org.jeecg.modules.airag.app.service.impl;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import dev.langchain4j.agent.tool.ToolExecutionRequest;
@@ -19,21 +20,23 @@ import org.jeecg.common.api.vo.Result;
import org.jeecg.common.constant.SymbolConstant;
import org.jeecg.common.exception.JeecgBootBizTipException;
import org.jeecg.common.exception.JeecgBootException;
import java.nio.file.Paths;
import org.jeecg.common.system.api.ISysBaseAPI;
import org.jeecg.common.system.util.JwtUtil;
import org.jeecg.common.util.*;
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
import org.jeecg.config.AiChatConfig;
import org.jeecg.config.AiRagConfigBean;
import org.jeecg.config.JeecgBaseConfig;
import org.jeecg.config.vo.Path;
import org.jeecg.modules.airag.app.consts.AiAppConsts;
import org.jeecg.modules.airag.app.consts.Prompts;
import org.jeecg.modules.airag.app.entity.AiragApp;
import org.jeecg.modules.airag.app.enums.ImageSizeEnum;
import org.jeecg.modules.airag.app.mapper.AiragAppMapper;
import org.jeecg.modules.airag.app.service.IAiragChatService;
import org.jeecg.modules.airag.app.service.IAiragVariableService;
import org.jeecg.modules.airag.app.vo.AiWriteGenerateVo;
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.app.vo.*;
import org.jeecg.modules.airag.common.consts.AiragConsts;
import org.jeecg.modules.airag.common.handler.AIChatParams;
import org.jeecg.modules.airag.common.handler.IAIChatHandler;
@@ -43,13 +46,17 @@ 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.context.JeecgFlowContext;
import org.jeecg.modules.airag.flow.consts.FlowConsts;
import org.jeecg.modules.airag.flow.entity.AiragFlow;
import org.jeecg.modules.airag.flow.helper.JeecgTagHelper;
import org.jeecg.modules.airag.flow.service.IAiragFlowService;
import org.jeecg.modules.airag.flow.vo.api.FlowRunParams;
import org.jeecg.modules.airag.flow.vo.tool.ToolExecutionVo;
import org.jeecg.modules.airag.llm.consts.LLMConsts;
import org.jeecg.modules.airag.llm.document.TikaDocumentParser;
import org.jeecg.modules.airag.llm.entity.AiragModel;
import org.jeecg.modules.airag.flow.handler.BraveSearchToolBuilder;
import org.jeecg.modules.airag.llm.handler.AIChatHandler;
import org.jeecg.modules.airag.llm.handler.JeecgToolsProvider;
import org.jeecg.modules.airag.llm.mapper.AiragModelMapper;
@@ -60,7 +67,7 @@ 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.util.CollectionUtils;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.ByteArrayOutputStream;
@@ -70,6 +77,7 @@ import java.io.InputStream;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
@@ -117,6 +125,12 @@ public class AiragChatServiceImpl implements IAiragChatService {
@Autowired
JeecgBaseConfig jeecgBaseConfig;
@Autowired
AiChatConfig aiChatConfig;
@Autowired
AiRagConfigBean aiRagConfigBean;
/**
* 重新接收消息
@@ -193,6 +207,13 @@ public class AiragChatServiceImpl implements IAiragChatService {
@Override
public Result<?> stop(String requestId) {
AssertUtils.assertNotEmpty("requestId不能为空", requestId);
// 设置流程上下文的停止标志通知正在执行的LLM节点停止输出
JeecgFlowContext flowContext = AiragLocalCache.get(AiragConsts.CACHE_TYPE_FLOW_CONTEXT, requestId);
if (flowContext != null) {
flowContext.setStopped(true);
AiragLocalCache.remove(AiragConsts.CACHE_TYPE_FLOW_CONTEXT, requestId);
log.info("[AI-CHAT]已设置流程停止标志, requestId:{}", requestId);
}
// 从缓存中获取对应的SseEmitter
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
if (emitter != null) {
@@ -307,26 +328,22 @@ public class AiragChatServiceImpl implements IAiragChatService {
//update-begin---author:chenrui ---date:20251106 for[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
// 返回消息列表和会话设置信息
Map<String, Object> result = new HashMap<>();
// 过滤掉工具调用相关的消息(前端不需要展示
// 解析是否显示工具调用过程默认为true
boolean showToolProcess = true;
AiragApp chatApp = chatConversation.getApp();
if (chatApp != null && oConvertUtils.isNotEmpty(chatApp.getMetadata())) {
try {
JSONObject appMetadataJson = JSONObject.parseObject(chatApp.getMetadata());
if (appMetadataJson != null && "0".equals(appMetadataJson.getString("showToolProcess"))) {
showToolProcess = false;
}
} catch (Exception ignored) {
}
}
// 合并工具调用相关的消息
List<MessageHistory> messages = chatConversation.getMessages();
if (oConvertUtils.isObjectNotEmpty(messages)) {
messages = messages.stream()
.filter(msg -> !AiragConsts.MESSAGE_ROLE_TOOL.equals(msg.getRole()))
.map(msg -> {
// 克隆消息对象,移除工具执行请求信息(前端不需要)
MessageHistory displayMsg = MessageHistory.builder()
.conversationId(msg.getConversationId())
.topicId(msg.getTopicId())
.role(msg.getRole())
.content(msg.getContent())
.images(msg.getImages())
.files(msg.getFiles())
.datetime(msg.getDatetime())
.build();
// 不设置toolExecutionRequests和toolExecutionResult
return displayMsg;
})
.collect(Collectors.toList());
messages = mergeToolMessages(messages, showToolProcess);
}
result.put("messages", messages);
result.put("flowInputs", chatConversation.getFlowInputs());
@@ -339,6 +356,104 @@ public class AiragChatServiceImpl implements IAiragChatService {
//update-end---author:chenrui ---date:20251106 for[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
}
/**
* 合并工具调用相关的历史记录生成带有工具执行标签的AI消息
*
* @param histories 历史消息列表
* @param showToolProcess 是否显示工具调用过程
* @return 合并后的历史消息列表
*/
private List<MessageHistory> mergeToolMessages(List<MessageHistory> histories, boolean showToolProcess) {
List<MessageHistory> mergedMessages = new ArrayList<>();
if (oConvertUtils.isObjectEmpty(histories)) {
return mergedMessages;
}
// 缓存工具请求,便于后续快速匹配
Map<String, MessageHistory.ToolExecutionRequestHistory> requestCache = new HashMap<>();
// 当前正在合并的AI消息
MessageHistory currentAiMsg = null;
// 合并AI消息
BiConsumer<MessageHistory, Object> mergeMsg = (cacheMsg, obj) -> {
String currContent;
if (obj instanceof MessageHistory) {
MessageHistory currMsg = (MessageHistory) obj;
currContent = currMsg.getContent();
// 合并图片
if (CollectionUtils.isNotEmpty(currMsg.getImages())) {
List<MessageHistory.ImageHistory> images = CollectionUtils.isEmpty(cacheMsg.getImages()) ? new ArrayList<>() : cacheMsg.getImages();
images.addAll(currMsg.getImages());
cacheMsg.setImages(images);
}
// 合并文件
if (CollectionUtils.isNotEmpty(currMsg.getFiles())) {
List<MessageHistory.FileHistory> files = CollectionUtils.isEmpty(cacheMsg.getImages()) ? new ArrayList<>() : cacheMsg.getFiles();
files.addAll(currMsg.getFiles());
cacheMsg.setFiles(files);
}
} else {
currContent = obj.toString();
}
cacheMsg.setContent(cacheMsg.getContent() + currContent);
};
// 遍历所有消息,根据类型的不同做出不同处理
for (MessageHistory message : histories) {
// 用户消息原样保留,不参与合并
if (AiragConsts.MESSAGE_ROLE_USER.equals(message.getRole())) {
if (currentAiMsg != null) {
mergedMessages.add(currentAiMsg);
currentAiMsg = null;
}
mergedMessages.add(message);
continue;
}
// 从当前AI消息开始向后合并工具调用与连续AI消息
if (AiragConsts.MESSAGE_ROLE_AI.equals(message.getRole())) {
if (currentAiMsg == null) {
currentAiMsg = MessageHistory.builder()
.conversationId(message.getConversationId())
.topicId(message.getTopicId())
.role(message.getRole())
.content("")
.images(message.getImages())
.files(message.getFiles())
.datetime(message.getDatetime())
.build();
}
mergeMsg.accept(currentAiMsg, message);
List<MessageHistory.ToolExecutionRequestHistory> toolReqs = message.getToolExecutionRequests();
if (CollectionUtils.isNotEmpty(toolReqs)) {
for (MessageHistory.ToolExecutionRequestHistory request : toolReqs) {
if (request != null) {
// 使用工具调用id作为唯一键方便快速匹配结果
requestCache.put(request.getId(), request);
}
}
}
continue;
}
if (AiragConsts.MESSAGE_ROLE_TOOL.equals(message.getRole())) {
if (currentAiMsg == null || !showToolProcess) {
continue;
}
String toolId = message.getContent();
MessageHistory.ToolExecutionRequestHistory request = requestCache.get(toolId);
if (request == null) {
continue;
}
String toolResult = message.getToolExecutionResult();
ToolExecutionVo vo = ToolExecutionVo.build(toolId, request.getName(), request.getArguments(), toolResult);
String execTag = JeecgTagHelper.createTag(JeecgTagHelper.TAG_JEECG_TOOL_EXEC, JSON.toJSONString(vo));
mergeMsg.accept(currentAiMsg, execTag);
}
}
// 避免最后一条消息没有放入列表
if (currentAiMsg != null) {
mergedMessages.add(currentAiMsg);
}
return mergedMessages;
}
@Override
public Result<?> clearMessage(String conversationId, String sessionType) {
AssertUtils.assertNotEmpty("请先选择会话", conversationId);
@@ -713,6 +828,8 @@ public class AiragChatServiceImpl implements IAiragChatService {
break;
case AiragConsts.MESSAGE_ROLE_AI:
// 重建AI消息包括工具执行请求
// 获取内容如果为空则使用空字符串AiMessage不允许null
String aiContent = oConvertUtils.getString(history.getContent(), "");
if (oConvertUtils.isObjectNotEmpty(history.getToolExecutionRequests())) {
// 有工具执行请求重建带工具调用的AiMessage
List<ToolExecutionRequest> toolRequests = history.getToolExecutionRequests().stream()
@@ -722,9 +839,9 @@ public class AiragChatServiceImpl implements IAiragChatService {
.arguments(toolReq.getArguments())
.build())
.collect(Collectors.toList());
chatMessage = AiMessage.from(history.getContent(), toolRequests);
chatMessage = AiMessage.from(aiContent, toolRequests);
} else {
chatMessage = new AiMessage(history.getContent());
chatMessage = new AiMessage(aiContent);
}
break;
case AiragConsts.MESSAGE_ROLE_TOOL:
@@ -735,7 +852,10 @@ public class AiragChatServiceImpl implements IAiragChatService {
.name("unknown") // 工具名称在重建时不重要因为主要用于AI理解结果
.arguments("{}")
.build();
chatMessage = ToolExecutionResultMessage.from(recreatedRequest, history.getToolExecutionResult());
//update-begin---author:scott ---date:20260416 for【PR#9539】修复通义千问API不接受null消息内容-----------
String toolResult = history.getToolExecutionResult() != null ? history.getToolExecutionResult() : "";
chatMessage = ToolExecutionResultMessage.from(recreatedRequest, toolResult);
//update-end---author:scott ---date:20260416 for【PR#9539】修复通义千问API不接受null消息内容-----------
break;
}
if (null == chatMessage) {
@@ -765,7 +885,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
private void appendMessage(List<ChatMessage> messages, ChatMessage message, ChatConversation chatConversation, String topicId, List<String> files, String saveContent) {
if (message.type().equals(ChatMessageType.SYSTEM)) {
if (message instanceof SystemMessage) {
// 系统消息,放到消息列表最前面,并且不记录历史
messages.add(0, message);
return;
@@ -778,18 +898,18 @@ public class AiragChatServiceImpl implements IAiragChatService {
}
// 消息记录
MessageHistory historyMessage = MessageHistory.builder().conversationId(chatConversation.getId()).topicId(topicId).datetime(DateUtils.now()).build();
if (message.type().equals(ChatMessageType.USER)) {
if (message instanceof UserMessage) {
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)) {
if (content instanceof ImageContent) {
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)) {
} else if (content instanceof TextContent) {
textContent.append(((TextContent) content).text()).append("\n");
}
});
@@ -809,10 +929,12 @@ public class AiragChatServiceImpl implements IAiragChatService {
historyMessage.setFiles(fileHistories);
}
//update-end---author:wangshuai---date:2026-01-12---for:【QQYUN-14261】【AI】AI助手支持多模态能力- 文档---
} else if (message.type().equals(ChatMessageType.AI)) {
} else if (message instanceof AiMessage) {
historyMessage.setRole(AiragConsts.MESSAGE_ROLE_AI);
AiMessage aiMessage = (AiMessage) message;
historyMessage.setContent(aiMessage.text());
//update-begin---author:scott ---date:20260416 for【PR#9539】修复通义千问API不接受null消息内容-----------
historyMessage.setContent(aiMessage.text() != null ? aiMessage.text() : "");
//update-end---author:scott ---date:20260416 for【PR#9539】修复通义千问API不接受null消息内容-----------
// 处理工具执行请求
if (oConvertUtils.isObjectNotEmpty(aiMessage.toolExecutionRequests())) {
List<MessageHistory.ToolExecutionRequestHistory> toolRequests = new ArrayList<>();
@@ -825,7 +947,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
}
historyMessage.setToolExecutionRequests(toolRequests);
}
} else if (message.type().equals(ChatMessageType.TOOL_EXECUTION_RESULT)) {
} else if (message instanceof ToolExecutionResultMessage) {
// 工具执行结果消息
historyMessage.setRole(AiragConsts.MESSAGE_ROLE_TOOL);
ToolExecutionResultMessage toolMessage = (ToolExecutionResultMessage) message;
@@ -941,7 +1063,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
drawModelId = JSONObject.parseObject(metadata).getString("drawModelId");
}
}
AssertUtils.assertNotEmpty("请选择绘画模型", drawModelId);
//AssertUtils.assertNotEmpty("请选择绘画模型", drawModelId);
try {
List<String> images = sendParams.getImages();
List<Map<String, Object>> imageList;
@@ -1000,6 +1122,14 @@ public class AiragChatServiceImpl implements IAiragChatService {
flowRunParams.setFlowId(flowId);
flowRunParams.setConversationId(chatConversation.getId());
flowRunParams.setTopicId(topicId);
// 传入应用id变量节点需要
if (chatConversation.getApp() != null) {
flowRunParams.setAppId(chatConversation.getApp().getId());
}
// 传入记忆库id记忆节点需要
if (chatConversation.getApp() != null) {
flowRunParams.setMemoryId(chatConversation.getApp().getMemoryId());
}
// 支持流式
flowRunParams.setResponseMode(FlowConsts.FLOW_RESPONSE_MODE_STREAMING);
Map<String, Object> flowInputParams = new HashMap<>();
@@ -1138,6 +1268,14 @@ public class AiragChatServiceImpl implements IAiragChatService {
if (metadata.containsKey("maxTokens")) {
aiChatParams.setMaxTokens(metadata.getInteger("maxTokens"));
}
//update-begin---wangshuai---date:20260401 for【issues/9455】AI应用中设定的RAG参数未生效------------
if (metadata.containsKey("topNumber")) {
aiChatParams.setTopNumber(metadata.getInteger("topNumber"));
}
if (metadata.containsKey("similarity")) {
aiChatParams.setSimilarity(metadata.getDouble("similarity"));
}
//update-end---author:wangshuai ---date:20260401 for【issues/9455】AI应用中设定的RAG参数未生效------------
if (metadata.containsKey(FlowConsts.FLOW_NODE_OPTION_TIME_OUT)) {
aiChatParams.setTimeout(oConvertUtils.getInt(metadata.getInteger(FlowConsts.FLOW_NODE_OPTION_TIME_OUT), 300));
}
@@ -1163,9 +1301,9 @@ public class AiragChatServiceImpl implements IAiragChatService {
}
}
//流程不为空,构建插件
//流程不为空,构建插件(携带应用上下文参数,供变量/记忆节点使用)
if(oConvertUtils.isNotEmpty(flowId)){
Map<String, Object> result = airagFlowPluginService.getFlowsToPlugin(flowId);
Map<String, Object> result = airagFlowPluginService.getFlowsToPlugin(flowId, aiApp.getId(), memoryId);
this.addPluginToParams(aiChatParams, result);
}
@@ -1194,6 +1332,11 @@ public class AiragChatServiceImpl implements IAiragChatService {
airagVariableService.addUpdateVariableTool(aiApp,username,aiChatParams);
}
//update-begin---author:wangshuai---date:2026-03-18---for:【QQYUN-14935】Langchain4j 新版支持 Agent Skills重新定义 Java AI 应用的能力边界---
// 封装skills及上下文信息
fillSkillsParams(aiChatParams);
//update-end---author:wangshuai---date:2026-03-18---for:【QQYUN-14935】Langchain4j 新版支持 Agent Skills重新定义 Java AI 应用的能力边界---
// 打印流程耗时日志
printChatDuration(requestId, "构造应用自定义参数完成");
// 发消息
@@ -1228,6 +1371,49 @@ public class AiragChatServiceImpl implements IAiragChatService {
}
}
/**
* 封装skills参数及上下文信息
* 当配置了skillsPath时将skills路径设置到参数中并将Token、后台地址、租户ID拼接到用户消息后面
*
* @param aiChatParams AI聊天参数
*/
private void fillSkillsParams(AIChatParams aiChatParams) {
if (oConvertUtils.isEmpty(aiChatConfig.getSkillsDir()) && oConvertUtils.isEmpty(aiChatConfig.getSkillsShellDir())) {
log.info("[Skills] skillsPath OR shellSkillsDir is empty, skip skills loading");
return;
}
if (oConvertUtils.isNotEmpty(aiChatConfig.getSkillsDir())){
aiChatParams.setSkillsDir(aiChatConfig.getSkillsDir());
log.info("[Skills] skillsDir set to: {}", aiChatParams.getSkillsDir());
}
if (oConvertUtils.isNotEmpty((aiChatConfig.getSkillsShellDir()))){
aiChatParams.setSkillsShellDir(aiChatConfig.getSkillsShellDir());
log.info("[Skills] shellSkillsDir set to: {}", aiChatParams.getSkillsShellDir());
}
// 注入运行时上下文Token、后台API地址、租户ID供Skills使用
try {
HttpServletRequest request = SpringContextUtils.getHttpServletRequest();
String token = TokenUtils.getTokenByRequest(request);
String tenantId = request.getHeader("X-Tenant-Id");
// 从当前请求构造后台API地址
String apiBase = CommonUtils.getBaseUrl(request);
StringBuilder context = new StringBuilder();
context.append("以下信息由系统自动注入Skill执行时可直接使用\n");
context.append("- **API_BASE**: `").append(apiBase).append("`\n");
if (oConvertUtils.isNotEmpty(token)) {
context.append("- **X-Access-Token**: `").append(token).append("`\n");
}
if (oConvertUtils.isNotEmpty(tenantId)) {
context.append("- **X-Tenant-Id**: `").append(tenantId).append("`\n");
}
aiChatParams.setSkillsContext(context.toString());
log.info("[Skills] context injected, apiBase: {}", apiBase);
} catch (Exception e) {
log.warn("[Skills] Failed to inject context: {}", e.getMessage());
}
}
/**
* 处理聊天
* 向大模型发送消息并接受响应
@@ -1246,22 +1432,41 @@ public class AiragChatServiceImpl implements IAiragChatService {
if (null == aiChatParams) {
aiChatParams = new AIChatParams();
}
HttpServletRequest httpRequest = SpringContextUtils.getHttpServletRequest();
// 如果是默认app,加载系统默认工具
if(chatConversation.getApp().getId().equals(AiAppConsts.DEFAULT_APP_ID)){
aiChatParams.setTools(jeecgToolsProvider.getDefaultTools());
// Security fix: 仅已登录用户可加载敏感业务工具(add_user,grant_user_roles等),匿名用户仍可正常使用AI聊天
String currentUser = getUsername(httpRequest);
if (oConvertUtils.isNotEmpty(currentUser)) {
aiChatParams.setTools(jeecgToolsProvider.getDefaultTools());
}
}
//update-begin---author:wangshuai ---date:2026-04-15 forBrave Search配置迁移到AiRagConfigBean仅在联网搜索开启时注入工具-----------
// Brave Search 联网检索工具:前端 enableSearch=true 且 apiKey 已配置时才注入
if (Boolean.TRUE.equals(aiChatParams.getEnableSearch())) {
Map<ToolSpecification, ToolExecutor> braveTools = BraveSearchToolBuilder.buildTools(aiRagConfigBean.getBraveSearch());
if (!braveTools.isEmpty()) {
Map<ToolSpecification, ToolExecutor> existing = aiChatParams.getTools();
if (existing == null) {
existing = new HashMap<>();
}
existing.putAll(braveTools);
aiChatParams.setTools(existing);
}
}
//update-end---author:wangshuai ---date:2026-04-15 forBrave Search配置迁移到AiRagConfigBean仅在联网搜索开启时注入工具-----------
if(CollectionUtils.isEmpty(aiChatParams.getKnowIds())){
aiChatParams.setKnowIds(chatConversation.getApp().getKnowIds());
} else {
aiChatParams.getKnowIds().addAll(chatConversation.getApp().getKnowIds());
}
aiChatParams.setMaxMsgNumber(oConvertUtils.getInt(chatConversation.getApp().getMsgNum(), 5));
aiChatParams.setCurrentHttpRequest(SpringContextUtils.getHttpServletRequest());
HttpServletRequest httpRequest = SpringContextUtils.getHttpServletRequest();
aiChatParams.setCurrentHttpRequest(httpRequest);
// for [QQYUN-9234] MCP服务连接关闭 - 保存参数引用用于在回调中关闭MCP连接
final AIChatParams finalAiChatParams = aiChatParams;
TokenStream chatStream;
try {
aiChatParams.setTimeout(5*30*1000);
// 打印流程耗时日志
printChatDuration(requestId, "开始向LLM发送消息");
if (oConvertUtils.isNotEmpty(modelId)) {
@@ -1280,7 +1485,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
return;
}
String errMsg = "调用大模型接口失败,详情请查看后台日志。";
if(e instanceof JeecgBootException){
if(e instanceof JeecgBootException || e instanceof JeecgBootBizTipException){
errMsg = e.getMessage();
}
EventData eventData = new EventData(requestId, null, EventData.EVENT_FLOW_ERROR, chatConversation.getId(), topicId);
@@ -1288,6 +1493,38 @@ public class AiragChatServiceImpl implements IAiragChatService {
closeSSE(emitter, eventData);
throw new JeecgBootBizTipException("调用大模型接口失败:" + e.getMessage());
}
// 发送消息给前端
BiConsumer<String, String> send2Client = (resMessage, eventType) -> {
eventType = oConvertUtils.isNotEmpty(eventType) ? eventType : EventData.EVENT_MESSAGE;
EventData eventData = new EventData(requestId, null, eventType, chatConversation.getId(), topicId);
EventMessageData messageEventData = EventMessageData.builder().message(resMessage).build();
eventData.setData(messageEventData);
eventData.setRequestId(requestId);
// sse
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
if (null == emitter) {
log.warn("[AI应用]接收LLM返回会话已关闭");
return;
}
sendMessage2Client(emitter, eventData);
};
// 解析是否显示工具调用过程默认为true
boolean showToolProcess = true;
String appMetadataStr = chatConversation.getApp().getMetadata();
if (oConvertUtils.isNotEmpty(appMetadataStr)) {
try {
JSONObject appMetadataJson = JSONObject.parseObject(appMetadataStr);
if (appMetadataJson != null && "0".equals(appMetadataJson.getString("showToolProcess"))) {
showToolProcess = false;
}
} catch (Exception ignored) {
}
}
final boolean finalShowToolProcess = showToolProcess;
/**
* 是否正在思考
*/
@@ -1301,22 +1538,19 @@ public class AiragChatServiceImpl implements IAiragChatService {
isThinking.set(false);
}
//update-end---author:wangshuai---date:2025-11-07---for:[issues/8506]/[issues/8260]/[issues/8166]新增推理模型的支持---
EventData eventData = new EventData(requestId, null, EventData.EVENT_MESSAGE, chatConversation.getId(), topicId);
EventMessageData messageEventData = EventMessageData.builder().message(resMessage).build();
eventData.setData(messageEventData);
eventData.setRequestId(requestId);
// sse
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
if (null == emitter) {
log.warn("[AI应用]接收LLM返回会话已关闭");
return;
send2Client.accept(resMessage, EventData.EVENT_MESSAGE);
}).beforeToolExecution(beforeToolExecution -> {
// 监听工具执行请求(根据配置决定是否发送给前端)
if (finalShowToolProcess) {
ToolExecutionVo vo = ToolExecutionVo.build(beforeToolExecution);
String execTag = JeecgTagHelper.createTag(JeecgTagHelper.TAG_JEECG_TOOL_EXEC, JSON.toJSONString(vo));
send2Client.accept(execTag, EventData.EVENT_TOOL_EXEC_BEFORE);
}
sendMessage2Client(emitter, eventData);
}).onToolExecuted((toolExecution) -> {
// 打印工具执行结果
log.debug("[AI应用]工具执行结果: toolName={}, toolId={}, result={}",
toolExecution.request().name(),
toolExecution.request().id(),
toolExecution.request().name(),
toolExecution.request().id(),
toolExecution.result());
// 将工具执行结果存储到消息历史中
ToolExecutionResultMessage toolResultMessage = ToolExecutionResultMessage.from(
@@ -1324,6 +1558,13 @@ public class AiragChatServiceImpl implements IAiragChatService {
toolExecution.result()
);
appendMessage(messages, toolResultMessage, chatConversation, topicId);
// 根据配置决定是否将工具调用过程发送给前端
if (finalShowToolProcess) {
ToolExecutionVo vo = ToolExecutionVo.build(toolExecution);
String execTag = JeecgTagHelper.createTag(JeecgTagHelper.TAG_JEECG_TOOL_EXEC, JSON.toJSONString(vo));
send2Client.accept(execTag, EventData.EVENT_TOOL_EXEC_DONE);
send2Client.accept(execTag, EventData.EVENT_MESSAGE);
}
}).onIntermediateResponse((chatResponse) -> {
// 中间响应包含tool_calls的AI消息
AiMessage aiMessage = chatResponse.aiMessage();
@@ -1422,14 +1663,20 @@ public class AiragChatServiceImpl implements IAiragChatService {
//update-end---author:chenrui ---date:20250425 for[QQYUN-12203]AI 聊天,超时或者服务器报错,给个友好提示------------
} else {
errMsg = "调用大模型接口失败,详情请查看后台日志。";
boolean isFindErrorMsg = false;
// 根据常见异常关键字做细致翻译
for (Map.Entry<String, String> entry : AIChatHandler.MODEL_ERROR_MAP.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (error.getMessage().contains(key)) {
errMsg = value;
isFindErrorMsg = true;
}
}
String message = error.getMessage();
if(!isFindErrorMsg && message.contains("error")) {
errMsg = JSONObject.parseObject(message).get("error").toString();
}
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);
@@ -1693,29 +1940,87 @@ public class AiragChatServiceImpl implements IAiragChatService {
/**
* ai海报生成
*
* @param chatSendParams
* @param aiDrawGenerateVo
* @return
*/
@Override
public String genAiPoster(ChatSendParams chatSendParams) {
AssertUtils.assertNotEmpty("请选择绘画模型", chatSendParams.getDrawModelId());
AssertUtils.assertNotEmpty("请填写提示词", chatSendParams.getContent());
public String genAiPoster(AiDrawGenerateVo aiDrawGenerateVo) {
AssertUtils.assertNotEmpty("请选择绘画模型", aiDrawGenerateVo.getDrawModelId());
AssertUtils.assertNotEmpty("请填写提示词", aiDrawGenerateVo.getContent());
AIChatParams aiChatParams = new AIChatParams();
if(oConvertUtils.isNotEmpty(chatSendParams.getImageSize())){
aiChatParams.setImageSize(chatSendParams.getImageSize());
//update-begin---author:wangshuai---date:2026-02-05---for:【QQYUN-14568】AI绘画功能---
if(oConvertUtils.isNotEmpty(aiDrawGenerateVo.getImageSize())){
aiChatParams.setImageSize(aiDrawGenerateVo.getImageSize());
}
String image= chatSendParams.getImageUrl();
//aiChatParams.setNegativePrompt("面部扭曲,特征丢失,边缘模糊,比例失调,模糊,多余的手指");
//绘图
if(AiAppConsts.AI_DRAW_TYPE_DRAW.equals(aiDrawGenerateVo.getType())){
String format = StrUtil.format(Prompts.AI_DRAW_PROMPT, aiDrawGenerateVo.getStyle(), aiDrawGenerateVo.getVisualAngle(), aiDrawGenerateVo.getCharacterShot(), aiDrawGenerateVo.getLighting(), ImageSizeEnum.getRatioBySize(aiDrawGenerateVo.getImageSize()));
aiDrawGenerateVo.setContent(format + aiDrawGenerateVo.getContent());
}
if((AiAppConsts.AI_DRAW_TYPE_FACE.equals(aiDrawGenerateVo.getType()) || AiAppConsts.AI_DRAW_TYPE_MIX.equals(aiDrawGenerateVo.getType())) && oConvertUtils.isNotEmpty(aiDrawGenerateVo.getImageSize())){
aiDrawGenerateVo.setContent(aiDrawGenerateVo.getContent() + "比例:" + ImageSizeEnum.getRatioBySize(aiDrawGenerateVo.getImageSize()));
}
String image= aiDrawGenerateVo.getImageUrl();
//update-end---author:wangshuai---date:2026-02-05---for:【QQYUN-14568】AI绘画功能---
List<Map<String, Object>> imageList = new ArrayList<>();
if(oConvertUtils.isEmpty(image)) {
//生成图片
imageList = aiChatHandler.imageGenerate(chatSendParams.getDrawModelId(), chatSendParams.getContent(), aiChatParams);
imageList = aiChatHandler.imageGenerate(aiDrawGenerateVo.getDrawModelId(), aiDrawGenerateVo.getContent(), aiChatParams);
} else {
//图生图
imageList = aiChatHandler.imageEdit(chatSendParams.getDrawModelId(), chatSendParams.getContent(), Arrays.asList(image.split(SymbolConstant.COMMA)), aiChatParams);
imageList = aiChatHandler.imageEdit(aiDrawGenerateVo.getDrawModelId(), aiDrawGenerateVo.getContent(), Arrays.asList(image.split(SymbolConstant.COMMA)), aiChatParams);
}
return imageList.stream().map(this::uploadImage).collect(Collectors.joining("\n"));
}
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
@Override
public String genAiPosterAsync(AiDrawGenerateVo aiDrawGenerateVo) {
AssertUtils.assertNotEmpty("请选择绘画模型", aiDrawGenerateVo.getDrawModelId());
AssertUtils.assertNotEmpty("请填写提示词", aiDrawGenerateVo.getContent());
String taskId = java.util.UUID.randomUUID().toString().replace("-", "");
// 写入 pending 状态
JSONObject task = new JSONObject();
task.put("status", "pending");
redisUtil.set(AiAppConsts.POSTER_TASK_PREFIX + taskId, task.toJSONString(), AiAppConsts.POSTER_TASK_TTL);
// 异步执行生成
SSE_THREAD_POOL.execute(() -> {
JSONObject result = new JSONObject();
try {
String imageUrl = genAiPoster(aiDrawGenerateVo);
result.put("status", "success");
result.put("imageUrl", imageUrl);
} catch (Exception e) {
log.error("[AI海报]异步生成失败 taskId={}", taskId, e);
result.put("status", "failed");
result.put("message", e.getMessage());
}
redisUtil.set(AiAppConsts.POSTER_TASK_PREFIX + taskId, result.toJSONString(), AiAppConsts.POSTER_TASK_TTL);
});
return taskId;
}
@Override
public Result<?> getAiPosterResult(String taskId) {
Object val = redisUtil.get(AiAppConsts.POSTER_TASK_PREFIX + taskId);
if (val == null) {
return Result.error("任务不存在或已过期");
}
JSONObject task = JSONObject.parseObject(val.toString());
String status = task.getString("status");
if ("success".equals(status)) {
return Result.OK(task.getString("imageUrl"));
}
if ("failed".equals(status)) {
return Result.error(task.getString("message"));
}
// pending
return Result.OK("pending", null);
}
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
/**
* 上传图片
*
@@ -1738,6 +2043,13 @@ public class AiragChatServiceImpl implements IAiragChatService {
data = Base64.getDecoder().decode(value);
} else {
//下载网络图片
//update-begin---author:zhangdaihao ---date:20260427 for[issues/9579]AI海报图片下载 SSRF 校验,拒绝 loopback/link-local------------
// genAiPoster -> uploadImage -> getDownInputStream攻击者可通过 imageUrl 触发服务端访问 localhost / 云元数据等敏感目标;
// 沿用与 #9553 一致的基础 SSRF 校验(拒绝 loopback / link-local保留对企业内网 MinIO/OSS 的兼容。
if (oConvertUtils.isNotEmpty(value) && value.toLowerCase().startsWith("http")) {
SsrfFileTypeFilter.checkSsrfHttpUrl(value);
}
//update-end-----author:zhangdaihao ---date:20260427 for[issues/9579]AI海报图片下载 SSRF 校验,拒绝 loopback/link-local------------
InputStream inputStream = FileDownloadUtils.getDownInputStream(value, "");
if (inputStream != null) {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
@@ -1796,7 +2108,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
* @return
*/
private String parseFilesToText(List<String> files) {
if (com.baomidou.mybatisplus.core.toolkit.CollectionUtils.isEmpty(files)) {
if (CollectionUtils.isEmpty(files)) {
return "";
}
StringBuilder sb = new StringBuilder();
@@ -1855,16 +2167,37 @@ public class AiragChatServiceImpl implements IAiragChatService {
private File ensureLocalFile(String fileRef, String fileName) {
String uploadpath = jeecgBaseConfig.getPath().getUpload();
if (LLMConsts.WEB_PATTERN.matcher(fileRef).matches()) {
//update-begin---author:wangshuai ---date:2026-04-13 for【issues/9519】AI附件处理路径遍历漏洞下载文件名做安全过滤临时目录隔离---
// 远程下载:使用 FilenameUtils.getName 剥离任何路径分隔符,再次校验防止 ..
String safeFileName = FilenameUtils.getName(fileName);
SsrfFileTypeFilter.checkPathTraversal(safeFileName);
//update-end---author:wangshuai ---date:2026-04-13 for【issues/9519】AI附件处理路径遍历漏洞下载文件名做安全过滤临时目录隔离---
String tempDir = uploadpath + File.separator + "chat" + File.separator + UUID.randomUUID() + File.separator;
File dir = new File(tempDir);
if (!dir.exists() && !dir.mkdirs()) {
return null;
}
String tempFilePath = tempDir + fileName;
String tempFilePath = tempDir + safeFileName;
//update-begin---author:zhangdaihao ---date:20260427 for[issues/9578]AI附件下载 SSRF 校验,拒绝 loopback/link-local------------
// /airag/chat/send 端点为 @IgnoreAuth 无认证AI 聊天解析附件存在 SSRF 风险;
// 沿用与 #9553 一致的基础 SSRF 校验(拒绝 loopback / link-local保留对企业内网 MinIO/OSS 的兼容。
SsrfFileTypeFilter.checkSsrfHttpUrl(fileRef);
//update-end-----author:zhangdaihao ---date:20260427 for[issues/9578]AI附件下载 SSRF 校验,拒绝 loopback/link-local------------
FileDownloadUtils.download2DiskFromNet(fileRef, tempFilePath);
return new File(tempFilePath);
}
return new File(uploadpath + File.separator + fileRef);
//update-begin---author:wangshuai ---date:2026-04-13 for【issues/9519】AI附件处理路径遍历漏洞规范化路径并强制校验沙箱范围---
// 本地附件1) 先做字符级路径遍历检查2) 规范化路径后必须仍在 uploadpath 下,阻止 ../ 逃逸
java.nio.file.Path root = Paths.get(uploadpath).toAbsolutePath().normalize();
SsrfFileTypeFilter.checkPathTraversal(fileRef);
String relativePath = fileRef.replaceAll("^[\\\\/]+", "");
java.nio.file.Path target = root.resolve(relativePath).toAbsolutePath().normalize();
if (!target.startsWith(root)) {
log.error("检测到路径遍历攻击! fileRef: {}, 解析后: {}", relativePath, target);
throw new JeecgBootException("文件路径包含非法字符");
}
return target.toFile();
//update-end---author:wangshuai ---date:2026-04-13 for【issues/9519】AI附件处理路径遍历漏洞规范化路径并强制校验沙箱范围---
}
//================================================= end【QQYUN-14261】【AI】AI助手支持多模态能力- 文档========================================
@@ -1887,7 +2220,9 @@ public class AiragChatServiceImpl implements IAiragChatService {
content = StrUtil.format(Prompts.AI_WRITER_PROMPT, aiWriteGenerateVo.getPrompt(), aiWriteGenerateVo.getFormat(), aiWriteGenerateVo.getTone(), aiWriteGenerateVo.getLanguage(), aiWriteGenerateVo.getLength());
} else if(reply.equals(aiWriteGenerateVo.getActiveMode())){
//回复
content = StrUtil.format(Prompts.AI_REPLY_PROMPT, aiWriteGenerateVo.getPrompt(), aiWriteGenerateVo.getOriginalContent(), aiWriteGenerateVo.getFormat(), aiWriteGenerateVo.getTone(), aiWriteGenerateVo.getLanguage(), aiWriteGenerateVo.getLength());
//update-begin---author:wangshuai ---date:2026-04-20 for【QQYUN-15179】ai写作 生成的内容不对,应该是以回复来生成,而不是内容-----------
content = StrUtil.format(Prompts.AI_REPLY_PROMPT, aiWriteGenerateVo.getOriginalContent(), aiWriteGenerateVo.getPrompt(), aiWriteGenerateVo.getFormat(), aiWriteGenerateVo.getTone(), aiWriteGenerateVo.getLanguage(), aiWriteGenerateVo.getLength());
//update-end---author:wangshuai ---date:2026-04-20 for【QQYUN-15179】ai写作 生成的内容不对,应该是以回复来生成,而不是内容-----------
} else {
content = StrUtil.format(Prompts.AI_TOUCHE_PROMPT, aiWriteGenerateVo.getPrompt(), aiWriteGenerateVo.getFormat(), aiWriteGenerateVo.getTone(), aiWriteGenerateVo.getLanguage(), aiWriteGenerateVo.getLength());
}

View File

@@ -3,6 +3,7 @@ package org.jeecg.modules.airag.app.service.impl;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import dev.langchain4j.agent.tool.ToolSpecification;
import dev.langchain4j.model.chat.request.json.JsonArraySchema;
import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
import dev.langchain4j.service.tool.ToolExecutor;
import lombok.extern.slf4j.Slf4j;
@@ -53,6 +54,24 @@ public class AiragVariableServiceImpl implements IAiragVariableService {
redisTemplate.opsForHash().putIfAbsent(key, name, defaultValue != null ? defaultValue : "");
}
/**
* 获取变量值
*
* @param username 用户名
* @param appId 应用ID
* @param name 变量名
* @return 变量值不存在返回null
*/
@Override
public String getVariable(String username, String appId, String name) {
if (oConvertUtils.isEmpty(username) || oConvertUtils.isEmpty(appId) || oConvertUtils.isEmpty(name)) {
return null;
}
String key = CACHE_PREFIX + appId + ":" + username;
Object value = redisTemplate.opsForHash().get(key, name);
return value != null ? String.valueOf(value) : null;
}
/**
* 追加提示词
*
@@ -147,7 +166,9 @@ public class AiragVariableServiceImpl implements IAiragVariableService {
}
//工具描述
StringBuilder descriptionBuilder = new StringBuilder("更新应用变量的值。仅当检测到变量的新值与当前值不一致时调用。如果已调用过或值未变,请勿重复调用。");
//update-begin---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
StringBuilder descriptionBuilder = new StringBuilder("批量更新应用变量的值。请将本次对话中所有需要更新的变量一次性传入updates数组无需多次调用。仅当变量新值与当前值确实不同时才调用本工具。");
//update-end---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
if (variableList != null && !variableList.isEmpty()) {
descriptionBuilder.append("\n\n可用变量列表");
for (AppVariableVo var : variableList) {
@@ -159,17 +180,30 @@ public class AiragVariableServiceImpl implements IAiragVariableService {
descriptionBuilder.append(": ").append(var.getDescription());
}
}
descriptionBuilder.append("\n\n注意variableName必须是上述列表中的名称之一。");
//update-begin---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
descriptionBuilder.append("\n\n注意variableName必须是上述列表中的名称之一且本工具每轮对话只需调用一次。");
//update-end---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
}
// A: 参数改为批量数组,一次可更新多个变量
JsonObjectSchema itemSchema = JsonObjectSchema.builder()
.addStringProperty("variableName", "变量名称(必须是可用变量列表中的名称之一)")
.addStringProperty("value", "变量新值")
.required("variableName", "value")
.build();
//构建更新变量的工具
ToolSpecification spec = ToolSpecification.builder()
.name("update_variable")
.description(descriptionBuilder.toString())
.parameters(JsonObjectSchema.builder()
.addStringProperty("variableName", "变量名称")
.addStringProperty("value", "变量值")
.required("variableName", "value")
//update-begin---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
.addProperty("updates", JsonArraySchema.builder()
.description("需要更新的变量列表,可包含多个变量")
.items(itemSchema)
.build())
.required("updates")
//update-end---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
.build())
.build();
@@ -177,15 +211,38 @@ public class AiragVariableServiceImpl implements IAiragVariableService {
ToolExecutor executor = (toolExecutionRequest, memoryId) -> {
try {
JSONObject args = JSONObject.parseObject(toolExecutionRequest.arguments());
String name = args.getString("variableName");
String value = args.getString("value");
JSONArray updates = args.getJSONArray("updates");
IAiragVariableService variableService = SpringContextUtils.getBean(IAiragVariableService.class);
//更新变量值
variableService.updateVariable(username, aiApp.getId(), name, value);
return "变量 " + name + " 已更新为: " + value;
//update-begin---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
// B: 返回结构化JSONLLM可明确感知"已全部完成"
JSONObject updatedMap = new JSONObject();
if (updates != null) {
for (int i = 0; i < updates.size(); i++) {
JSONObject item = updates.getJSONObject(i);
String name = item.getString("variableName");
String value = item.getString("value");
if (oConvertUtils.isNotEmpty(name)) {
variableService.updateVariable(username, aiApp.getId(), name, value);
updatedMap.put(name, value);
}
}
}
JSONObject result = new JSONObject();
result.put("success", true);
result.put("updated", updatedMap);
result.put("count", updatedMap.size());
result.put("message", "已成功更新 " + updatedMap.size() + " 个变量,无需再次调用");
return result.toJSONString();
//update-end---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
} catch (Exception e) {
log.error("更新变量失败", e);
return "更新变量失败: " + e.getMessage();
//update-begin---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
JSONObject error = new JSONObject();
error.put("success", false);
error.put("message", "更新变量失败: " + e.getMessage());
return error.toJSONString();
//update-end---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
}
};

View File

@@ -0,0 +1,58 @@
package org.jeecg.modules.airag.app.vo;
import lombok.Data;
/**
* @Description: AI绘画
*
* @author: wangshuai
* @date: 2026/2/4 18:57
*/
@Data
public class AiDrawGenerateVo {
/**
* 绘画模型的id
*/
private String drawModelId;
/**
* 图片尺寸
*/
private String imageSize;
/**
* 一张图片或者多张图片,多张图片用逗号分隔
*/
private String imageUrl;
/**
* 用户输入的聊天内容
*/
private String content;
/**
* 风格
*/
private String style;
/**
* 视角
*/
private String visualAngle;
/**
* 人物镜头
*/
private String characterShot;
/**
* 灯光
*/
private String lighting;
/**
* 类型 poster: 海报draw绘图face 换脸mix 混图
*/
private String type;
}

View File

@@ -88,11 +88,52 @@ public class LLMConsts {
*/
public static final String KNOWLEDGE_DOC_METADATA_SOURCES_PATH = "sourcesPath";
/**
* 知识库:文档元数据:网页URL
*/
public static final String KNOWLEDGE_DOC_METADATA_WEBSITE = "website";
/**
* DEEPSEEK推理模型
*/
public static final String DEEPSEEK_REASONER = "deepseek-reasoner";
//update-begin---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
/**
* DEEPSEEK 推理模型(返回 reasoning_content 字段、在多轮工具调用中要求把 reasoning_content 回传)集合,
* 后续 DeepSeek 新增推理模型时在此追加;非推理模型(如 deepseek-chat)不要加入。
* 触发场景:仅当对话存在工具调用导致的多轮请求时才会出现 "reasoning_content must be passed back" 错误,
* 单轮 Q&A(如 AI 应用聊天无工具)不会触发,但开启 sendThinking 也无副作用。
*/
public static final Set<String> DEEPSEEK_THINKING_MODELS = new HashSet<>(Arrays.asList(
"deepseek-reasoner",
"deepseek-v4-flash",
"deepseek-v4-pro"
));
/**
* 判断指定模型名是否为 DeepSeek 推理模型(返回 reasoning_content 字段)
* 匹配规则:先做大小写不敏感的精确匹配,再做关键字包含匹配(reasoner/v4-flash/v4-pro)
* 以兼容带版本后缀的变体(如 deepseek-v4-flash-0428)
*
* @param modelName 模型名(大小写不敏感、首尾空白容错)
* @return true=推理模型false=非推理模型或空
*/
public static boolean isDeepSeekThinkingModel(String modelName) {
if (modelName == null || modelName.trim().isEmpty()) {
return false;
}
String name = modelName.trim().toLowerCase();
if (DEEPSEEK_THINKING_MODELS.contains(name)) {
return true;
}
// 兼容带版本后缀或厂商前缀的变体
return name.contains("reasoner")
|| name.contains("v4-flash")
|| name.contains("v4-pro");
}
//update-end---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
/**
* 知识库类型:知识库
*/
@@ -118,4 +159,63 @@ public class LLMConsts {
*/
public static final int CHAT_FILE_MAX_COUNT = 3;
/**
* 知识库是否开启默认分段策略
*/
public static final String ENABLE_SEGMENT = "enableSegment";
/**
* 文档分段策略:使用知识库默认分段策略
*/
public static final String USE_KNOWLEDGE_DEFAULT = "useKnowledgeDefault";
/**
* 分段策略
*/
public static final String SEGMENT_STRATEGY = "segmentStrategy";
/**
* 分段策略auto 自动分段与清洗
*/
public static final String SEGMENT_STRATEGY_AUTO = "auto";
/**
* 分段策略custom 自定义
*/
public static final String SEGMENT_STRATEGY_CUSTOM = "custom";
/**
* 分段长度
*/
public static final String MAX_SEGMENT = "maxSegment";
/**
* 重叠率 0-90%
*/
public static final String OVERLAP = "overlap";
/**
* 分段标识符(\\n:换行,\\n\\n:2个换行。:中文句号,!:中文叹号,?:中文问号,. :英文句号,! :英文叹号,? :英文问号custom:自定义)
*/
public static final String SEPARATOR = "separator";
/**
* 分段标识符自定义
*/
public static final String CUSTOM_SEPARATOR = "customSeparator";
/**
* 文本预处理规则cleanSpaces替换掉连续的空格、换行符和制表符removeUrlsEmails删除所有 URL 和电子邮箱地址)
*/
public static final String TEXT_RULES = "textRules";
/**
* 替换掉连续的空格、换行符和制表符
*/
public static final String TEXT_RULES_CLEAN_SPACES = "cleanSpaces";
/**
* 删除所有URL和电子邮箱地址
*/
public static final String TEXT_RULES_REMOVE_URLS_EMAILS = "removeUrlsEmails";
}

View File

@@ -1,7 +1,7 @@
package org.jeecg.modules.airag.llm.controller;
import org.jeecg.common.airag.api.IAiragBaseApi;
import org.jeecg.modules.airag.llm.service.impl.AiragBaseApiImpl;
import org.jeecg.modules.airag.api.AiragBaseApiImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -23,9 +23,39 @@ public class AiragBaseApiController implements IAiragBaseApi {
public String knowledgeWriteTextDocument(
@RequestParam("knowledgeId") String knowledgeId,
@RequestParam("title") String title,
@RequestParam("content") String content
@RequestParam("content") String content,
@RequestParam(value = "segmentConfig", required = false) String segmentConfig
) {
return airagBaseApi.knowledgeWriteTextDocument(knowledgeId, title, content);
return airagBaseApi.knowledgeWriteTextDocument(knowledgeId, title, content, segmentConfig);
}
@PostMapping("/airag/api/getChatVariable")
public String getChatVariable(
@RequestParam("appId") String appId,
@RequestParam("username") String username,
@RequestParam("name") String name
) {
return airagBaseApi.getChatVariable(appId, username, name);
}
@PostMapping("/airag/api/setChatVariable")
public void setChatVariable(
@RequestParam("appId") String appId,
@RequestParam("username") String username,
@RequestParam("name") String name,
@RequestParam("value") String value
) {
airagBaseApi.setChatVariable(appId, username, name, value);
}
@PostMapping("/airag/api/getMemoryIdByAppId")
public String getMemoryIdByAppId(@RequestParam("appId") String appId) {
return airagBaseApi.getMemoryIdByAppId(appId);
}
@PostMapping("/airag/api/getPromptContent")
public String getPromptContent(@RequestParam("promptId") String promptId) {
return airagBaseApi.getPromptContent(promptId);
}
}

View File

@@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
@@ -42,6 +43,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @return
*/
@Operation(summary = "MCP-分页列表查询")
@RequiresPermissions("airag:mcp:list")
@GetMapping(value = "/list")
public Result<IPage<AiragMcp>> queryPageList(AiragMcp airagMcp,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@@ -61,6 +63,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @return
*/
@Operation(summary = "MCP-保存")
@RequiresPermissions("airag:mcp:save")
@PostMapping(value = "/save")
public Result<String> save(@RequestBody AiragMcp airagMcp) {
return airagMcpService.edit(airagMcp);
@@ -77,6 +80,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @date 2025/10/21 10:54
*/
@Operation(summary = "MCP-保存并同步")
@RequiresPermissions("airag:mcp:save")
@PostMapping(value = "/saveAndSync")
public Result<?> saveAndSync(@RequestBody AiragMcp airagMcp) {
Result<String> saveResult = airagMcpService.edit(airagMcp);
@@ -99,6 +103,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @date 2025/10/20 20:09
*/
@Operation(summary = "MCP-同步MCP信息")
@RequiresPermissions("airag:mcp:save")
@PostMapping(value = "/sync/{id}")
public Result<?> sync(@PathVariable(name = "id", required = true) String id) {
return airagMcpService.sync(id);
@@ -114,6 +119,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @date 2025/10/20 20:13
*/
@Operation(summary = "MCP-启用/禁用MCP信息")
@RequiresPermissions("airag:mcp:save")
@PostMapping(value = "/status/{id}/{action}")
public Result<?> toggleStatus(@PathVariable(name = "id",required = true) String id,
@PathVariable(name = "action", required = true) String action) {
@@ -129,6 +135,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @date 2025/10/30
*/
@Operation(summary = "MCP-保存插件工具")
@RequiresPermissions("airag:mcp:save")
@PostMapping(value = "/saveTools")
public Result<String> saveTools(@RequestBody SaveToolsDTO dto) {
return airagMcpService.saveTools(dto.getId(), dto.getTools());
@@ -141,6 +148,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @return
*/
@Operation(summary = "MCP-通过id删除")
@RequiresPermissions("airag:mcp:delete")
@DeleteMapping(value = "/delete")
public Result<String> delete(@RequestParam(name = "id", required = true) String id) {
airagMcpService.removeById(id);
@@ -154,6 +162,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @return
*/
@Operation(summary = "MCP-通过id查询")
//@RequiresPermissions("airag:mcp:queryById")
@GetMapping(value = "/queryById")
public Result<AiragMcp> queryById(@RequestParam(name = "id", required = true) String id) {
AiragMcp airagMcp = airagMcpService.getById(id);
@@ -169,7 +178,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @param request
* @param airagMcp
*/
// @RequiresPermissions("llm:airag_mcp:exportXls")
@RequiresPermissions("airag:mcp:export")
@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, AiragMcp airagMcp) {
return super.exportXls(request, airagMcp, AiragMcp.class, "MCP");
@@ -182,7 +191,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @param response
* @return
*/
// @RequiresPermissions("llm:airag_mcp:importExcel")
@RequiresPermissions("airag:mcp:import")
@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
return super.importExcel(request, response, AiragMcp.class);

View File

@@ -17,6 +17,7 @@ import org.jeecg.common.util.AssertUtils;
import org.jeecg.common.util.TokenUtils;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.config.mybatis.MybatisPlusSaasConfig;
import org.jeecg.modules.airag.app.enums.ImageEditEnum;
import org.jeecg.modules.airag.common.handler.AIChatParams;
import org.jeecg.modules.airag.llm.consts.LLMConsts;
import org.jeecg.modules.airag.llm.entity.AiragModel;
@@ -29,7 +30,9 @@ import org.springframework.web.servlet.ModelAndView;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Collections;
/**
@@ -82,8 +85,6 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
// 默认未激活
if(oConvertUtils.isObjectEmpty(airagModel.getActivateFlag())){
airagModel.setActivateFlag(0);
} else {
airagModel.setActivateFlag(1);
}
airagModelService.save(airagModel);
return Result.OK("添加成功!");
@@ -170,6 +171,8 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
AssertUtils.assertNotEmpty("模型名称不能为空", airagModel.getName());
AssertUtils.assertNotEmpty("模型类型不能为空", airagModel.getModelType());
AssertUtils.assertNotEmpty("基础模型不能为空", airagModel.getModelName());
//测试连接默认为已激活状态
airagModel.setActivateFlag(1);
try {
if(LLMConsts.MODEL_TYPE_LLM.equals(airagModel.getModelType())){
aiChatHandler.completions(airagModel, Collections.singletonList(UserMessage.from("To test whether it can be successfully called, simply return success")), null);
@@ -180,7 +183,16 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
//update-begin---author:wangshuai---date:2026-01-07---for:【QQYUN-12145】【AI】AI 绘画创作---=
}else if(LLMConsts.MODEL_TYPE_IMAGE.equals(airagModel.getModelType())){
AIChatParams aiChatParams = new AIChatParams();
aiChatHandler.imageGenerate(airagModel, "To test whether it can be successfully called, simply return success", aiChatParams);
//update-begin---author:wangshuai---date:2026-03-02---for:兼容图生图模型测试---
String modelName = airagModel.getModelName();
if(ImageEditEnum.isImageEditModel(modelName)){
List<String> images = new ArrayList<>();
images.add("https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/jeecg_1772268161540.jpg");
aiChatHandler.imageEdit(airagModel, "Generate a picture of a cartoon cat", images,aiChatParams);
}else{
aiChatHandler.imageGenerate(airagModel, "Generate a picture of a cartoon cat", aiChatParams);
}
//update-end---author:wangshuai---date:2026-03-02---for:兼容图生图模型测试---
}
//update-end---author:wangshuai---date:2026-01-07---for:【QQYUN-12145】【AI】AI 绘画创作---
}catch (Exception e){

View File

@@ -71,26 +71,25 @@ public class TikaDocumentParser {
public Document parse(File file) {
AssertUtils.assertNotEmpty("请选择文件", file);
try {
// 使用 Tika 自动检测 MIME 类型
String fileName = file.getName().toLowerCase();
//后缀
String ext = FilenameUtils.getExtension(fileName);
if (fileName.endsWith(".txt")
|| fileName.endsWith(".md")
|| fileName.endsWith(".pdf")) {
// 用于解析(使用FileInputStream避免file.toPath()在Linux非UTF-8环境下中文文件名报错)
InputStream isForParsing = new FileInputStream(file);
// 使用 Tika 自动检测 MIME 类型
String fileName = file.getName().toLowerCase();
//后缀
String ext = FilenameUtils.getExtension(fileName);
if (fileName.endsWith(".txt")
|| fileName.endsWith(".md")
|| fileName.endsWith(".pdf")) {
try (InputStream isForParsing = new FileInputStream(file)) {
return extractByTika(isForParsing);
//update-begin---author:wangshuai---date:2026-01-09---for:【QQYUN-14261】【AI】AI助手支持多模态能力- 文档---
} else if (FILE_SUFFIX.contains(ext.toLowerCase())) {
return parseDocExcelPdfUsingApachePoi(file);
//update-end---author:wangshuai---date:2026-01-09---for:【QQYUN-14261】【AI】AI助手支持多模态能力- 文档---
} else {
throw new IllegalArgumentException("不支持的文件格式: " + FilenameUtils.getExtension(fileName));
} catch (IOException e) {
throw new RuntimeException(e);
}
} catch (IOException e) {
throw new RuntimeException(e);
//update-begin---author:wangshuai---date:2026-01-09---for:【QQYUN-14261】【AI】AI助手支持多模态能力- 文档---
} else if (FILE_SUFFIX.contains(ext.toLowerCase())) {
return parseDocExcelPdfUsingApachePoi(file);
//update-end---author:wangshuai---date:2026-01-09---for:【QQYUN-14261】【AI】AI助手支持多模态能力- 文档---
} else {
throw new IllegalArgumentException("不支持的文件格式: " + FilenameUtils.getExtension(fileName));
}
}

View File

@@ -0,0 +1,254 @@
package org.jeecg.modules.airag.llm.document;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
import org.jsoup.select.Elements;
import java.io.IOException;
/**
* 网页解析器使用Jsoup爬取网页并转换为Markdown格式
*
* @author sjlei
* @date 2026/3/19
*/
@Slf4j
public class WebPageParser {
/**
* 请求超时时间(毫秒)
*/
private static final int TIMEOUT_MS = 15000;
/**
* 最大body大小(5MB)
*/
private static final int MAX_BODY_SIZE = 5 * 1024 * 1024;
/**
* User-Agent
*/
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
/**
* 爬取网页并转换为Markdown
*
* @param url 网页URL
* @return Markdown格式的文本内容
* @throws IOException 网络请求失败时抛出
*/
public String parseToMarkdown(String url) throws IOException {
Document doc = Jsoup.connect(url)
.userAgent(USER_AGENT)
.timeout(TIMEOUT_MS)
.maxBodySize(MAX_BODY_SIZE)
.followRedirects(true)
.get();
// 移除脚本、样式、导航、页脚等无关元素
doc.select("script, style, nav, footer, header, iframe, noscript, svg, form, button, input, select, textarea, .sidebar, .nav, .menu, .footer, .header, .ad, .advertisement, .comment, .comments").remove();
// 优先提取正文区域
Element body = extractMainContent(doc);
StringBuilder markdown = new StringBuilder();
// 提取页面标题
String title = doc.title();
if (title != null && !title.trim().isEmpty()) {
markdown.append("# ").append(title.trim()).append("\n\n");
}
// 将HTML转为Markdown
convertToMarkdown(body, markdown);
return cleanMarkdown(markdown.toString());
}
/**
* 提取正文区域优先使用article/main标签否则使用body
*/
private Element extractMainContent(Document doc) {
// 按优先级尝试获取正文容器
String[] selectors = {"article", "main", "[role=main]", ".content", ".post-content", ".article-content", ".entry-content", "#content"};
for (String selector : selectors) {
Elements elements = doc.select(selector);
if (!elements.isEmpty() && elements.first().text().length() > 100) {
return elements.first();
}
}
return doc.body() != null ? doc.body() : doc;
}
/**
* 递归将HTML元素转换为Markdown
*/
private void convertToMarkdown(Element element, StringBuilder sb) {
for (Node child : element.childNodes()) {
if (child instanceof TextNode) {
String text = ((TextNode) child).text().trim();
if (!text.isEmpty()) {
sb.append(text);
}
} else if (child instanceof Element) {
Element el = (Element) child;
String tagName = el.tagName().toLowerCase();
switch (tagName) {
case "h1":
sb.append("\n\n# ").append(el.text().trim()).append("\n\n");
break;
case "h2":
sb.append("\n\n## ").append(el.text().trim()).append("\n\n");
break;
case "h3":
sb.append("\n\n### ").append(el.text().trim()).append("\n\n");
break;
case "h4":
sb.append("\n\n#### ").append(el.text().trim()).append("\n\n");
break;
case "h5":
sb.append("\n\n##### ").append(el.text().trim()).append("\n\n");
break;
case "h6":
sb.append("\n\n###### ").append(el.text().trim()).append("\n\n");
break;
case "p":
sb.append("\n\n");
convertToMarkdown(el, sb);
sb.append("\n\n");
break;
case "br":
sb.append("\n");
break;
case "strong":
case "b":
sb.append("**").append(el.text().trim()).append("**");
break;
case "em":
case "i":
sb.append("*").append(el.text().trim()).append("*");
break;
case "code":
sb.append("`").append(el.text()).append("`");
break;
case "pre":
sb.append("\n\n```\n").append(el.text()).append("\n```\n\n");
break;
case "a":
String href = el.attr("abs:href");
String linkText = el.text().trim();
if (!linkText.isEmpty() && !href.isEmpty()) {
sb.append("[").append(linkText).append("](").append(href).append(")");
} else if (!linkText.isEmpty()) {
sb.append(linkText);
}
break;
case "img":
String src = el.attr("abs:src");
String alt = el.attr("alt");
// 只保留http(s)开头的真实图片URL过滤掉base64内联图片
if (!src.isEmpty() && src.startsWith("http")) {
sb.append("![").append(alt != null ? alt : "").append("](").append(src).append(")");
}
break;
case "ul":
sb.append("\n");
for (Element li : el.children()) {
if ("li".equals(li.tagName())) {
sb.append("- ").append(li.text().trim()).append("\n");
}
}
sb.append("\n");
break;
case "ol":
sb.append("\n");
int idx = 1;
for (Element li : el.children()) {
if ("li".equals(li.tagName())) {
sb.append(idx++).append(". ").append(li.text().trim()).append("\n");
}
}
sb.append("\n");
break;
case "blockquote":
String[] lines = el.text().trim().split("\n");
sb.append("\n");
for (String line : lines) {
sb.append("> ").append(line).append("\n");
}
sb.append("\n");
break;
case "table":
convertTableToMarkdown(el, sb);
break;
case "hr":
sb.append("\n\n---\n\n");
break;
case "div":
case "section":
case "span":
case "figure":
case "figcaption":
convertToMarkdown(el, sb);
break;
default:
convertToMarkdown(el, sb);
break;
}
}
}
}
/**
* 将HTML表格转为Markdown表格
*/
private void convertTableToMarkdown(Element table, StringBuilder sb) {
Elements rows = table.select("tr");
if (rows.isEmpty()) {
return;
}
sb.append("\n\n");
boolean headerDone = false;
for (Element row : rows) {
Elements cells = row.select("th, td");
if (cells.isEmpty()) {
continue;
}
sb.append("|");
for (Element cell : cells) {
sb.append(" ").append(cell.text().trim()).append(" |");
}
sb.append("\n");
// 在第一行后添加分隔线
if (!headerDone) {
sb.append("|");
for (int i = 0; i < cells.size(); i++) {
sb.append(" --- |");
}
sb.append("\n");
headerDone = true;
}
}
sb.append("\n");
}
/**
* 清理Markdown文本去除多余空行、首尾空白
*/
private String cleanMarkdown(String markdown) {
// 去除连续3个以上换行为2个换行
markdown = markdown.replaceAll("\n{3,}", "\n\n");
// 去除行首尾空白(保留换行)
markdown = markdown.replaceAll("(?m)^[ \t]+|[ \t]+$", "");
return markdown.trim();
}
}

View File

@@ -109,4 +109,11 @@ public class AiragKnowledge implements Serializable {
@Excel(name="类型(knowledge知识 memory 记忆)", width = 15)
@Schema(description = "类型(knowledge知识 memory 记忆)")
private java.lang.String type;
/**
* 元数据
*/
@Excel(name = "元数据", width = 15)
@Schema(description = "元数据")
private java.lang.String metadata;
}

View File

@@ -3,7 +3,6 @@ package org.jeecg.modules.airag.llm.handler;
import com.alibaba.fastjson.JSONObject;
import dev.langchain4j.agent.tool.ToolSpecification;
import dev.langchain4j.data.message.*;
import dev.langchain4j.exception.InvalidRequestException;
import dev.langchain4j.exception.ToolExecutionException;
import dev.langchain4j.mcp.McpToolProvider;
import dev.langchain4j.rag.query.router.QueryRouter;
@@ -11,10 +10,13 @@ import dev.langchain4j.service.TokenStream;
import dev.langchain4j.service.tool.ToolExecutor;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.ai.handler.LLMHandler;
import org.jeecg.common.exception.JeecgBootBizTipException;
import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.util.AssertUtils;
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.config.AiChatConfig;
import org.jeecg.config.AiRagConfigBean;
import org.jeecg.modules.airag.common.consts.AiragConsts;
import org.jeecg.modules.airag.common.handler.AIChatParams;
import org.jeecg.modules.airag.common.handler.IAIChatHandler;
@@ -24,7 +26,6 @@ import org.jeecg.modules.airag.llm.entity.AiragMcp;
import org.jeecg.modules.airag.llm.entity.AiragModel;
import org.jeecg.modules.airag.llm.mapper.AiragMcpMapper;
import org.jeecg.modules.airag.llm.mapper.AiragModelMapper;
import org.jeecg.config.AiRagConfigBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@@ -66,6 +67,9 @@ public class AIChatHandler implements IAIChatHandler {
@Value(value = "${jeecg.path.upload:}")
private String uploadpath;
@Autowired
private AiChatConfig aiChatConfig;
/**
* 问答
*
@@ -99,7 +103,7 @@ public class AIChatHandler implements IAIChatHandler {
AssertUtils.assertNotEmpty("请选择模型", modelId);
AiragModel airagModel = airagModelMapper.getByIdIgnoreTenant(modelId);
AssertUtils.assertSame("模型未激活,请先在[AI模型配置]中[测试激活]模型", airagModel.getActivateFlag(), 1);
//AssertUtils.assertSame("模型未激活,请先在[AI模型配置]中[测试激活]模型", airagModel.getActivateFlag(), 1);
return completions(airagModel, messages, params);
}
@@ -115,39 +119,22 @@ public class AIChatHandler implements IAIChatHandler {
*/
public String completions(AiragModel airagModel, List<ChatMessage> messages, AIChatParams params) {
params = mergeParams(airagModel, params);
String resp;
//update-begin---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
messages = injectThinkingPlaceholderIfNeeded(messages, airagModel.getModelName());
//update-end---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
String resp = null;
try {
resp = llmHandler.completions(messages, params);
} catch (ToolExecutionException | InvalidRequestException e) {
log.error(e.getMessage(), e);
} catch (ToolExecutionException e) {
// 工具调用执行失败:先用 matchErrorMsg 翻译 cause再拼装友好提示
String causeMsg = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
causeMsg = matchErrorMsg(causeMsg, causeMsg);
log.error("AI工具执行异常 - {}", causeMsg, e);
return "";
} catch (Exception e) {
// langchain4j 异常友好提示
String errMsg = "调用大模型接口失败,详情请查看后台日志。";
if (oConvertUtils.isNotEmpty(e.getMessage())) {
String exceptionMsg = e.getMessage();
// 检查是否是工具调用消息序列不完整的异常
if (exceptionMsg.contains("messages with role 'tool' must be a response to a preceeding message with 'tool_calls'")) {
errMsg = "消息序列不完整,可能是因为历史消息数量设置过小导致工具调用上下文丢失。建议增加历史消息数量后重试。";
log.error("AI模型调用异常: 工具调用消息序列不完整,建议增加历史消息数量。异常详情: {}", exceptionMsg, e);
throw new JeecgBootException(errMsg);
}
// 根据常见异常关键字做细致翻译
for (Map.Entry<String, String> entry : MODEL_ERROR_MAP.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (exceptionMsg.contains(key)) {
errMsg = value;
break;
}
}
}
log.error("AI模型调用异常: {}", errMsg, e);
throw new JeecgBootException(errMsg);
throw translateLlmException(e, "调用大模型接口失败,详情请查看后台日志。");
}
if (resp.contains("</think>")
if (resp != null && resp.contains("</think>")
&& (null == params.getNoThinking() || params.getNoThinking())) {
String[] thinkSplit = resp.split("</think>");
resp = thinkSplit[thinkSplit.length - 1];
@@ -199,7 +186,13 @@ public class AIChatHandler implements IAIChatHandler {
AssertUtils.assertNotEmpty("请选择模型", modelId);
AiragModel airagModel = airagModelMapper.getByIdIgnoreTenant(modelId);
AssertUtils.assertSame("模型未激活,请先在[AI模型配置]中[测试激活]模型", airagModel.getActivateFlag(), 1);
//update-begin---author:wangshuai---date:2026-03-02---for:【QQYUN-14781】实现一个AI模型未激活或者不可用的情况直接使用平台底层配置的默认模型---
//未激活的模型走默认模型
if(null == airagModel || airagModel.getActivateFlag() == 0){
log.warn("模型未激活,采用默认模型");
return chatByDefaultModel(messages,params);
}
//update-end---author:wangshuai---date:2026-03-02---for:【QQYUN-14781】实现一个AI模型未激活或者不可用的情况直接使用平台底层配置的默认模型---
return chat(airagModel, messages, params);
}
@@ -215,9 +208,64 @@ public class AIChatHandler implements IAIChatHandler {
*/
private TokenStream chat(AiragModel airagModel, List<ChatMessage> messages, AIChatParams params) {
params = mergeParams(airagModel, params);
//update-begin---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
messages = injectThinkingPlaceholderIfNeeded(messages, airagModel.getModelName());
//update-end---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
return llmHandler.chat(messages, params);
}
//update-begin---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
/**
* 当目标模型是 DeepSeek 推理模型(deepseek-v4-flash 等)时,
* 为历史 AI 消息(从 MessageHistory 重建出来的、thinking 字段为空的 AiMessage)注入占位 thinking。
*
* 原因DeepSeek 推理模型校验请求时要求每条 assistant 历史消息携带 reasoning_content 字段;
* langchain4j 的 sendThinking 仅在 AiMessage.thinking() 非空时才会注入 reasoning_content
* 而历史持久化层(MessageHistory)目前没有保存 reasoning_content重建出的 AiMessage thinking 始终为 null
* 导致 DeepSeek 返回 "The reasoning_content in the thinking mode must be passed back to the API."
*
* 临时方案:注入占位字符串让 langchain4j 通过 isNullOrEmpty 校验、把 reasoning_content 字段带上,
* 后续若 MessageHistory 升级支持持久化 reasoning_content 可去掉该兜底。
*
* @param messages 原始消息列表(可能为 null/空)
* @param modelName 目标模型名(用于判定是否需要注入)
* @return 处理后的新消息列表(若无需处理则原样返回)
* @author scott
* @date 2026-04-29
*/
private static List<ChatMessage> injectThinkingPlaceholderIfNeeded(List<ChatMessage> messages, String modelName) {
if (messages == null || messages.isEmpty()
|| !LLMConsts.isDeepSeekThinkingModel(modelName)) {
return messages;
}
List<ChatMessage> result = new ArrayList<>(messages.size());
int injected = 0;
for (ChatMessage msg : messages) {
if (msg instanceof AiMessage) {
AiMessage aiMsg = (AiMessage) msg;
if (oConvertUtils.isEmpty(aiMsg.thinking())) {
AiMessage rebuilt = AiMessage.builder()
.text(aiMsg.text())
// 占位:让 langchain4j 把 reasoning_content 字段带上以满足 DeepSeek 校验
.thinking("...")
.toolExecutionRequests(aiMsg.toolExecutionRequests())
.attributes(aiMsg.attributes())
.build();
result.add(rebuilt);
injected++;
continue;
}
}
result.add(msg);
}
if (injected > 0) {
log.info("[AI-CHAT][issues/9585] 为 DeepSeek 推理模型[{}]的 {} 条历史 AI 消息注入了占位 thinking",
modelName, injected);
}
return result;
}
//update-end---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
/**
* 使用默认模型聊天
*
@@ -256,6 +304,11 @@ public class AIChatHandler implements IAIChatHandler {
JSONObject modelCredential = JSONObject.parseObject(airagModel.getCredential());
params.setApiKey(oConvertUtils.getString(modelCredential.getString("apiKey"), null));
params.setSecretKey(oConvertUtils.getString(modelCredential.getString("secretKey"), null));
boolean httpVersionOne = false;
if(modelCredential.containsKey("httpVersionOne")){
httpVersionOne = modelCredential.getInteger("httpVersionOne") == 1;
}
params.setIzHttpVersionOne(httpVersionOne);
}
if (oConvertUtils.isObjectNotEmpty(airagModel.getModelParams())) {
JSONObject modelParams = JSONObject.parseObject(airagModel.getModelParams());
@@ -280,6 +333,11 @@ public class AIChatHandler implements IAIChatHandler {
if (oConvertUtils.isObjectEmpty(params.getEnableSearch())) {
params.setEnableSearch(modelParams.getBoolean("enableSearch"));
}
//update-begin---author:wangshuai---date:2026-03-20---for:【issues/8】保存激活qwen-vl-ocr模型报错---
if (oConvertUtils.isObjectEmpty(params.getExtraParams()) && modelParams.containsKey("extraParams")) {
params.setExtraParams(modelParams.getObject("extraParams", Map.class));
}
//update-end---author:wangshuai---date:2026-03-20---for:【issues/8】保存激活qwen-vl-ocr模型报错---
}
// RAG
@@ -306,6 +364,28 @@ public class AIChatHandler implements IAIChatHandler {
buildPlugins(params);
}
//update-begin---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
// 仅对 DeepSeek 推理模型(deepseek-reasoner/deepseek-v4-flash 等)开启思考过程的捕获与回传,
// 否则 deepseek-v4-flash 在工具调用多轮对话中会返回:
// "The reasoning_content in the thinking mode must be passed back to the API."
// 注意:不要对 deepseek-chat 等非推理模型开启,避免无意义的请求字段污染。
boolean isDsThinking = LLMConsts.isDeepSeekThinkingModel(modelName);
log.info("[AI-CHAT][issues/9585] mergeParams provider={}, modelName={}, isDeepSeekThinkingModel={}, params.returnThinking={}, params.sendThinking={}",
airagModel.getProvider(), modelName, isDsThinking, params.getReturnThinking(), params.getSendThinking());
if (isDsThinking) {
// returnThinking: 把响应中的 reasoning_content 解析到 AiMessage.thinking
if (null == params.getReturnThinking()) {
params.setReturnThinking(true);
}
// sendThinking: 把 AiMessage.thinking 以 reasoning_content 字段回传到下次请求
if (null == params.getSendThinking()) {
params.setSendThinking(true);
}
log.info("[AI-CHAT][issues/9585] mergeParams after-fix returnThinking={}, sendThinking={}",
params.getReturnThinking(), params.getSendThinking());
}
//update-end---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
return params;
}
@@ -435,7 +515,7 @@ public class AIChatHandler implements IAIChatHandler {
@Override
public List<Map<String, Object>> imageGenerate(String modelId, String messages, AIChatParams params) {
AssertUtils.assertNotEmpty("至少发送一条消息", messages);
AssertUtils.assertNotEmpty("请选择图片大模型", modelId);
//AssertUtils.assertNotEmpty("请选择图片大模型", modelId);
AiragModel airagModel = airagModelMapper.getByIdIgnoreTenant(modelId);
return this.imageGenerate(airagModel, messages, params);
}
@@ -449,59 +529,63 @@ public class AIChatHandler implements IAIChatHandler {
* @return
*/
public List<Map<String, Object>> imageGenerate(AiragModel airagModel, String messages, AIChatParams params) {
if(airagModel == null || (airagModel.getActivateFlag()!=null && airagModel.getActivateFlag() == 0)){
if (airagModel != null && oConvertUtils.isNotEmpty(airagModel.getId())) {
log.warn("模型未激活,采用默认文生图模型");
}
//判断是否配置了默认模型
if(aiChatConfig == null || oConvertUtils.isEmpty(aiChatConfig.getAiModelDraw().getApiKey())){
throw new JeecgBootBizTipException("当前系统未配置默认图像模型请前往yml中配置默认模型");
}
airagModel = this.getDefaultDrawModel(aiChatConfig.getAiModelDraw());
}
params = mergeParams(airagModel, params);
try {
return llmHandler.imageGenerate(messages, params);
} catch (Exception e) {
String errMsg = "调用绘画AI接口失败详情请查看后台日志。";
if (oConvertUtils.isNotEmpty(e.getMessage())) {
// 根据常见异常关键字做细致翻译
for (Map.Entry<String, String> entry : MODEL_ERROR_MAP.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (e.getMessage().contains(key)) {
errMsg = value;
break;
}
}
}
log.error("AI模型调用异常: {}", errMsg, e);
throw new JeecgBootException(errMsg);
throw translateLlmException(e, "调用绘画AI接口失败详情请查看后台日志。");
}
}
/**
* 图生图
*
* @param modelId
* @param messages
* @param images
* @param params
* @return
*/
@Override
public List<Map<String, Object>> imageEdit(String modelId, String messages, List<String> images, AIChatParams params) {
AssertUtils.assertNotEmpty("至少发送一条消息", messages);
AiragModel airagModel = airagModelMapper.getByIdIgnoreTenant(modelId);
return this.imageEdit(airagModel, messages, images, params);
}
/**
* 图生图
*
* @param images
* @param params
* @return
*/
public List<Map<String, Object>> imageEdit(AiragModel airagModel,String messages, List<String> images, AIChatParams params) {
if(null == airagModel || airagModel.getActivateFlag() == 0){
if (airagModel != null && oConvertUtils.isNotEmpty(airagModel.getId())) {
log.warn("模型未激活,采用默认图生图模型");
}
//判断是否配置了默认模型
if(aiChatConfig == null || oConvertUtils.isEmpty(aiChatConfig.getAiModelPicDraw().getApiKey())){
throw new JeecgBootBizTipException("当前系统未配置默认图像模型请前往yml中配置默认模型");
}
airagModel = this.getDefaultDrawModel(aiChatConfig.getAiModelPicDraw());
}
params = mergeParams(airagModel, params);
List<String> originalImageBase64List = getFirstImageBase64(images);
try {
return llmHandler.imageEdit(messages, originalImageBase64List, params);
} catch (Exception e) {
String errMsg = "调用绘画AI接口失败详情请查看后台日志。";
if (oConvertUtils.isNotEmpty(e.getMessage())) {
// 根据常见异常关键字做细致翻译
for (Map.Entry<String, String> entry : MODEL_ERROR_MAP.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (errMsg.contains(key)) {
errMsg = value;
break;
}
}
}
log.error("AI模型调用异常: {}", errMsg, e);
throw new JeecgBootException(errMsg);
throw translateLlmException(e, "调用绘画AI接口失败详情请查看后台日志。");
}
}
@@ -534,11 +618,18 @@ public class AIChatHandler implements IAIChatHandler {
fileContent = buffer.toByteArray();
}
} else {
//update-begin---author:liusq ---date:2026-03-30 for【issues/9431】修复getFirstImageBase64路径遍历漏洞(CWE-22)-----------
// 本地文件
String filePath = uploadpath + File.separator + imageUrl;
SsrfFileTypeFilter.checkPathTraversal(filePath);
Path path = Paths.get(filePath);
fileContent = Files.readAllBytes(path);
// 路径遍历校验规范化后确保文件在uploadpath目录内
File uploadDir = new File(uploadpath).getCanonicalFile();
File targetFile = new File(filePath).getCanonicalFile();
if (!targetFile.toPath().startsWith(uploadDir.toPath())) {
throw new JeecgBootException("非法文件路径,禁止访问上传目录之外的文件: " + imageUrl);
}
fileContent = Files.readAllBytes(targetFile.toPath());
//update-end---author:liusq ---date:2026-03-30 for【issues/9431】修复getFirstImageBase64路径遍历漏洞(CWE-22)-----------
}
originalImageBase64List.add(Base64.getEncoder().encodeToString(fileContent));
} catch (Exception e) {
@@ -550,4 +641,57 @@ public class AIChatHandler implements IAIChatHandler {
return originalImageBase64List;
}
//================================================= end 【QQYUN-12145】【AI】AI 绘画创作 ========================================
/**
* 将 LLM 调用异常统一翻译为友好的 JeecgBootException。
* <p>
* 处理优先级:
* <ol>
* <li>请求超时timeout→ 排队提示</li>
* <li>工具调用上下文丢失messages with role 'tool'…)→ 友好提示</li>
* <li>{@link IAIChatHandler#MODEL_ERROR_MAP} 中的关键字匹配 → 对应中文提示</li>
* <li>兜底 → defaultMsg 参数</li>
* </ol>
*
* @param e 原始异常
* @param defaultMsg 兜底提示语
* @return 封装后的 JeecgBootException供调用方直接 throw
* @author chenrui
* @date 2025/3/5
*/
private JeecgBootException translateLlmException(Exception e, String defaultMsg) {
String exceptionMsg = e.getMessage();
String errMsg = defaultMsg;
if (oConvertUtils.isNotEmpty(exceptionMsg)) {
// 1.工具调用消息序列不完整
if (exceptionMsg.contains("messages with role 'tool' must be a response to a preceeding message with 'tool_calls'")) {
errMsg = "消息序列不完整,可能是因为历史消息数量设置过小导致工具调用上下文丢失。建议增加历史消息数量后重试。";
log.error("AI模型调用异常: 工具调用消息序列不完整,建议增加历史消息数量。异常详情: {}", exceptionMsg, e);
return new JeecgBootException(errMsg);
}
// 2.根据常见异常关键字做细致翻译(大小写不敏感)
errMsg = matchErrorMsg(exceptionMsg, errMsg);
}
log.error("AI模型调用异常: {}", errMsg, e);
return new JeecgBootException(errMsg);
}
/**
* 获取默认图像模型
*
* @return
*/
private AiragModel getDefaultDrawModel(AiChatConfig.ModelConfig aiModelDraw) {
AiragModel airagModel = new AiragModel();
airagModel.setModelName(aiModelDraw.getModel());
airagModel.setBaseUrl(aiModelDraw.getApiHost());
airagModel.setProvider(aiModelDraw.getProvider());
JSONObject credentialObject = new JSONObject();
credentialObject.put("apiKey",aiModelDraw.getApiKey());
airagModel.setCredential(credentialObject.toJSONString());
return airagModel;
}
}

View File

@@ -5,9 +5,7 @@ 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;
import java.util.regex.Pattern;
/**
* @Description: 命令行执行工具类
@@ -35,6 +33,42 @@ public class CommandExecUtil {
return execCommand(command.split(" "), args);
}
/**
* 禁止在参数中出现的 Shell 元字符(适用于 Windows cmd 和 Unix shell
*/
private static final Pattern SHELL_INJECTION_PATTERN =
Pattern.compile("[&|;<>`$!\\\\\\r\\n]");
/**
* 禁止文件名中出现的危险字符(防止通过文件名注入命令)
*/
private static final Pattern FILENAME_INJECTION_PATTERN =
Pattern.compile("[&|;<>`$!\"'\\r\\n]");
/**
* 校验单个命令参数,拒绝包含 Shell 注入字符的参数
*
* @param arg 待校验参数
* @throws IllegalArgumentException 若参数包含危险字符
*/
public static void validateArg(String arg) {
if (arg != null && SHELL_INJECTION_PATTERN.matcher(arg).find()) {
throw new IllegalArgumentException("命令参数包含非法字符,已拒绝执行: " + arg);
}
}
/**
* 校验文件路径,拒绝包含危险字符(防止文件名注入)
*
* @param filePath 待校验文件路径
* @throws IllegalArgumentException 若文件路径包含危险字符
*/
public static void validateFilePath(String filePath) {
if (filePath != null && FILENAME_INJECTION_PATTERN.matcher(filePath).find()) {
throw new IllegalArgumentException("文件路径包含非法字符,已拒绝处理: " + filePath);
}
}
/**
* 执行命令行
*
@@ -50,22 +84,17 @@ public class CommandExecUtil {
}
if (null != args && args.length > 0) {
// 校验每一个用户可控的参数,防止命令注入
for (String arg : args) {
validateArg(arg);
}
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]);
}
// 直接使用 ProcessBuilder不经过系统 Shell防止 Shell 注入)
// 注意:不再使用 cmd.exe /c 或 /bin/sh -c参数数组由 JVM 直接传递给操作系统
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(false);
Process process = null;
try {

View File

@@ -27,6 +27,10 @@ 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.filter.SsrfFileTypeFilter;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.jeecg.config.AiChatConfig;
import org.jeecg.common.system.util.JwtUtil;
import org.jeecg.common.util.*;
import org.jeecg.modules.airag.common.handler.IEmbeddingHandler;
@@ -35,12 +39,14 @@ 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.document.WebPageParser;
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.mapper.AiragKnowledgeMapper;
import org.jeecg.modules.airag.llm.mapper.AiragModelMapper;
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeService;
import org.jeecg.modules.airag.llm.splitter.CustomDocumentSplitter;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -88,6 +94,9 @@ public class EmbeddingHandler implements IEmbeddingHandler {
@Autowired
KnowConfigBean knowConfigBean;
@Autowired(required = false)
private AiChatConfig aiChatConfig;
/**
* 默认分段长度
*/
@@ -140,6 +149,13 @@ public class EmbeddingHandler implements IEmbeddingHandler {
*/
private static final Pattern PATTERN_MD_IMAGE = Pattern.compile("!\\[(.*?)]\\((.*?)\\)");
//update-begin---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格向量化分段时被截断修复-----------
/**
* 正则匹配: HTML表格完整块跨行大小写不敏感
*/
private static final Pattern PATTERN_HTML_TABLE = Pattern.compile("(?is)<table\\b.*?</table>");
//update-end---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格向量化分段时被截断修复-----------
/**
* 向量化文档
*
@@ -167,7 +183,9 @@ public class EmbeddingHandler implements IEmbeddingHandler {
content = parseFile(doc);
break;
case KNOWLEDGE_DOC_TYPE_WEB:
// TODO author: chenrui for:读取网站内容 date:2025/2/18
content = parseWebPage(doc);
// 将解析的网页内容回写到文档,便于后续查看
doc.setContent(content);
break;
}
}
@@ -185,7 +203,7 @@ public class EmbeddingHandler implements IEmbeddingHandler {
// 删除旧数据
embeddingStore.removeAll(metadataKey(EMBED_STORE_METADATA_DOCID).isEqualTo(doc.getId()));
// 分段器
DocumentSplitter splitter = DocumentSplitters.recursive(DEFAULT_SEGMENT_SIZE, DEFAULT_OVERLAP_SIZE);
DocumentSplitter splitter = createDocumentSplitter(doc);
// 分段并存储
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
.documentSplitter(splitter)
@@ -215,17 +233,155 @@ public class EmbeddingHandler implements IEmbeddingHandler {
}
//update-end---author:wangshuai---date:2025-12-26---for:【QQYUN-14265】【AI】支持记忆---
Document from = Document.from(content, metadata);
//update-begin---author:jeecg---date:2026-02-26---for:[#9374]【AI知识库】千帆向量报错添加异常处理防止空指针
try {
ingestor.ingest(from);
} catch (Exception e) {
log.error("向量存储失败,请检查向量模型配置是否正确", e);
throw new JeecgBootException("向量存储失败:" + e.getMessage());
//update-begin---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格分段时被截断保留完整表格块-----------
boolean hasHtmlTable = content != null && PATTERN_HTML_TABLE.matcher(content).find();
if (hasHtmlTable) {
try {
List<TextSegment> segments = splitDocumentPreservingHtmlTables(from, splitter);
List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
embeddingStore.addAll(embeddings, segments);
} catch (Exception e) {
log.error("向量存储失败,请检查向量模型配置是否正确", e);
throw new JeecgBootException("向量存储失败:" + e.getMessage());
}
} else {
//update-begin---author:jeecg---date:2026-02-26---for:[#9374]【AI知识库】千帆向量报错添加异常处理防止空指针
try {
ingestor.ingest(from);
} catch (Exception e) {
log.error("向量存储失败,请检查向量模型配置是否正确", e);
throw new JeecgBootException("向量存储失败:" + e.getMessage());
}
//update-end---author:jeecg---date:2026-02-26---for:[#9374]【AI知识库】千帆向量报错添加异常处理防止空指针
}
//update-end---author:jeecg---date:2026-02-26---for:[#9374]【AI知识库】千帆向量报错添加异常处理防止空指针
//update-end---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格分段时被截断保留完整表格块-----------
return metadata.toMap();
}
/**
* 创建分段器
*
* @param doc
* @return
*/
private DocumentSplitter createDocumentSplitter(AiragKnowledgeDoc doc) {
DocumentSplitter splitter = null;
int maxSegment = DEFAULT_SEGMENT_SIZE;
int overlapSize = DEFAULT_OVERLAP_SIZE;
if (oConvertUtils.isNotEmpty(doc.getMetadata())) {
try {
JSONObject json = JSONObject.parseObject(doc.getMetadata());
//update-begin---wangshuai---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
// 文档使用知识库默认分段策略:读取知识库自身的 metadata 来决定分段方式
Boolean useKnowledgeDefault = json.getBoolean(LLMConsts.USE_KNOWLEDGE_DEFAULT);
if (Boolean.TRUE.equals(useKnowledgeDefault)) {
if (oConvertUtils.isNotEmpty(doc.getKnowledgeId())) {
AiragKnowledge knowledge = airagKnowledgeMapper.selectById(doc.getKnowledgeId());
if (knowledge != null && oConvertUtils.isNotEmpty(knowledge.getMetadata())) {
// 用知识库的 metadata 覆盖,后续逻辑统一处理
json = JSONObject.parseObject(knowledge.getMetadata());
} else {
// 知识库没有配置分段策略,使用默认分段器
return DocumentSplitters.recursive(maxSegment, overlapSize);
}
} else {
return DocumentSplitters.recursive(maxSegment, overlapSize);
}
}
//update-end---wangshuai---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
Object segmentStrategy = json.get(LLMConsts.SEGMENT_STRATEGY);
//update-begin---author:wangshuai ---date:2026-04-09 for【issue/9418】AI知识库上传文件太大向量化失败-----------
// 1. 不论策略是auto还是custom优先使用前端传入的分段大小和重叠度
Integer sizeObj = json.getInteger(LLMConsts.MAX_SEGMENT);
if (sizeObj != null && sizeObj > 0) {
maxSegment = sizeObj;
}
Double overlapObj = json.getDouble(LLMConsts.OVERLAP);
if (overlapObj != null && overlapObj >= 0) {
double rate = overlapObj / 100;
overlapSize = (int) (maxSegment * rate);
}
if(segmentStrategy != null && LLMConsts.SEGMENT_STRATEGY_CUSTOM.equals(segmentStrategy.toString())){
//update-end---author:wangshuai ---date:2026-04-09 for【issue/9418】AI知识库上传文件太大向量化失败-----------
String splitChar = json.getString(LLMConsts.SEPARATOR);
if (oConvertUtils.isNotEmpty(splitChar)) {
//自定义
if(LLMConsts.SEGMENT_STRATEGY_CUSTOM.equals(splitChar)){
splitChar = oConvertUtils.getString(json.getString(LLMConsts.CUSTOM_SEPARATOR),"\n");
}
// 处理转义字符
splitChar = splitChar.replace("\\n", "\n").replace("\\t", "\t").replace("\\r", "\r");
String textRules = json.getString(LLMConsts.TEXT_RULES);
splitter = new CustomDocumentSplitter(textRules, splitChar, maxSegment, overlapSize);
}
}
} catch (Exception e) {
log.warn("解析自定义分词配置失败: {}", e.getMessage());
}
}
if (splitter == null) {
splitter = DocumentSplitters.recursive(maxSegment, overlapSize);
}
return splitter;
}
//update-begin---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格分段时被截断新增保留表格完整性的分段辅助方法-----------
/**
* 按 HTML 表格边界分段:表格块完整保留,表格外文本交给 splitter 正常分段
*/
public static List<TextSegment> splitDocumentPreservingHtmlTables(Document document, DocumentSplitter splitter) {
String text = document.text();
Metadata metadata = document.metadata();
List<TextSegment> result = new ArrayList<>();
Matcher matcher = PATTERN_HTML_TABLE.matcher(text);
int lastEnd = 0;
while (matcher.find()) {
String before = text.substring(lastEnd, matcher.start());
if (!before.isBlank()) {
appendSplitText(before, metadata, splitter, result);
}
appendSegment(matcher.group(), metadata, result);
lastEnd = matcher.end();
}
String remaining = text.substring(lastEnd);
if (!remaining.isBlank()) {
appendSplitText(remaining, metadata, splitter, result);
}
reindexSegments(result);
return result;
}
/**
* 将非表格文本交给 splitter 分段后追加到 result
*/
public static void appendSplitText(String text, Metadata metadata, DocumentSplitter splitter, List<TextSegment> result) {
List<TextSegment> segments = splitter.split(Document.from(text, metadata));
result.addAll(segments);
}
/**
* 将文本作为单个完整段追加到 result不经过分段器用于保留完整表格块
*/
public static void appendSegment(String text, Metadata metadata, List<TextSegment> result) {
result.add(TextSegment.from(text, metadata));
}
/**
* 为分段列表的 metadata 写入从 0 开始的连续 index供检索时标识顺序
*/
public static void reindexSegments(List<TextSegment> segments) {
for (int i = 0; i < segments.size(); i++) {
segments.get(i).metadata().put("index", String.valueOf(i));
}
}
//update-end---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格分段时被截断新增保留表格完整性的分段辅助方法-----------
/**
* 向量查询(多知识库)
*
@@ -465,7 +621,7 @@ public class EmbeddingHandler implements IEmbeddingHandler {
}
/**
* 查询向量模型数据
* 查询向量模型数据,若未指定或不存在则回退到 yml 中配置的默认向量模型
*
* @param modelId
* @return
@@ -473,10 +629,43 @@ public class EmbeddingHandler implements IEmbeddingHandler {
* @date 2025/2/20 20:08
*/
private AiragModel getEmbedModelData(String modelId) {
AssertUtils.assertNotEmpty("向量模型不能为空", modelId);
AiragModel model = airagModelMapper.getByIdIgnoreTenant(modelId);
AssertUtils.assertNotEmpty("向量模型不存在", model);
AssertUtils.assertEquals("仅支持向量模型", LLMConsts.MODEL_TYPE_EMBED, model.getModelType());
//update-begin---author:wangshuai---date:2026-03-09---for:【QQYUN-14645】添加默认向量模型---
if (oConvertUtils.isNotEmpty(modelId)) {
AiragModel model = airagModelMapper.getByIdIgnoreTenant(modelId);
if (model != null) {
AssertUtils.assertEquals("仅支持向量模型", LLMConsts.MODEL_TYPE_EMBED, model.getModelType());
// 判断模型是否已激活,未激活则回退到默认模型
if (model.getActivateFlag() != null && model.getActivateFlag() == 1) {
return model;
}
log.warn("向量模型[{}]未激活,尝试使用 yml 中配置的默认向量模型", modelId);
}
}
// 回退到 yml 默认向量模型
if (aiChatConfig != null && oConvertUtils.isNotEmpty(aiChatConfig.getAiModelEmbed().getApiKey())) {
log.info("使用 yml 中配置的默认向量模型: {}", aiChatConfig.getAiModelEmbed().getModel());
return buildDefaultEmbedModel(aiChatConfig.getAiModelEmbed());
}
AssertUtils.assertNotEmpty("向量模型不能为空,请先配置向量模型或在 yml 中设置默认向量模型(jeecg.ai-chat.ai-model-embed)", modelId);
return null;
//update-end---author:wangshuai---date:2026-03-09---for:【QQYUN-14645】添加默认向量模型---
}
/**
* 根据 yml 配置构建默认向量模型对象
*
* @param embedConfig yml 中的向量模型配置
* @return AiragModel
*/
private AiragModel buildDefaultEmbedModel(AiChatConfig.ModelConfig embedConfig) {
AiragModel model = new AiragModel();
model.setModelName(embedConfig.getModel());
model.setBaseUrl(embedConfig.getApiHost());
model.setProvider(embedConfig.getProvider());
model.setModelType(LLMConsts.MODEL_TYPE_EMBED);
JSONObject credential = new JSONObject();
credential.put("apiKey", embedConfig.getApiKey());
model.setCredential(credential.toJSONString());
return model;
}
@@ -556,12 +745,50 @@ public class EmbeddingHandler implements IEmbeddingHandler {
JSONObject modelCredential = JSONObject.parseObject(model.getCredential());
modelOpBuilder.apiKey(oConvertUtils.getString(modelCredential.getString("apiKey"), null));
modelOpBuilder.secretKey(oConvertUtils.getString(modelCredential.getString("secretKey"), null));
if(modelCredential.containsKey("httpVersionOne")){
modelOpBuilder.izHttpVersionOne(modelCredential.getInteger("httpVersionOne") == 1);
}
}
modelOpBuilder.topNumber(5);
modelOpBuilder.similarity(0.75);
return modelOpBuilder.build();
}
/**
* 解析网页内容使用Jsoup爬取并转换为Markdown
*
* @param doc 知识库文档metadata中需包含website字段
* @return Markdown格式的网页内容
* @date 2026/3/19
*/
private String parseWebPage(AiragKnowledgeDoc doc) {
String metadata = doc.getMetadata();
AssertUtils.assertNotEmpty("请先配置网页URL", metadata);
JSONObject metadataJson = JSONObject.parseObject(metadata);
String website = metadataJson.getString(LLMConsts.KNOWLEDGE_DOC_METADATA_WEBSITE);
AssertUtils.assertNotEmpty("请先配置网页URL", website);
Matcher matcher = LLMConsts.WEB_PATTERN.matcher(website);
if (!matcher.matches()) {
throw new JeecgBootException("网页URL格式不正确请以http://或https://开头");
}
try {
WebPageParser webPageParser = new WebPageParser();
String content = webPageParser.parseToMarkdown(website);
if (oConvertUtils.isEmpty(content)) {
throw new JeecgBootException("网页内容为空请检查URL是否可访问");
}
log.info("网页解析成功, URL: {}, 内容长度: {}", website, content.length());
return content;
} catch (JeecgBootException e) {
throw e;
} catch (Exception e) {
log.error("网页解析失败, URL: {}, 错误: {}", website, e.getMessage(), e);
throw new JeecgBootException("网页解析失败: " + e.getMessage());
}
}
/**
* 解析文件
*
@@ -669,9 +896,21 @@ public class EmbeddingHandler implements IEmbeddingHandler {
return ;
}
String command = "magic-pdf";
// 安全校验:拒绝文件名/路径中含有 Shell 注入字符的文件,防止命令注入
try {
CommandExecUtil.validateFilePath(docFile.getAbsolutePath());
CommandExecUtil.validateFilePath(docFile.getName());
} catch (IllegalArgumentException e) {
log.error("文件路径包含非法字符,拒绝执行 MinerU 解析: {}", e.getMessage());
throw new JeecgBootException("文件名包含非法字符,无法处理该文件");
}
// 使用 String[] 数组构建命令,避免 split(" ") 带来的参数边界问题
String[] command;
if (oConvertUtils.isNotEmpty(knowConfigBean.getCondaEnv())) {
command = "conda run -n " + knowConfigBean.getCondaEnv() + " " + command;
command = new String[]{"conda", "run", "-n", knowConfigBean.getCondaEnv(), "magic-pdf"};
} else {
command = new String[]{"magic-pdf"};
}
String outputPath = docFile.getParentFile().getAbsolutePath();
@@ -682,7 +921,7 @@ public class EmbeddingHandler implements IEmbeddingHandler {
try {
String execLog = CommandExecUtil.execCommand(command, args);
log.info("执行命令行:" + command + " args:" + Arrays.toString(args) + "\n log::" + execLog);
log.info("执行命令行:" + Arrays.toString(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 ;
@@ -724,8 +963,22 @@ public class EmbeddingHandler implements IEmbeddingHandler {
FileDownloadUtils.download2DiskFromNet(filePath, tempFilePath);
filePath = tempFilePath;
} else {
//本地文件
filePath = uploadpath + File.separator + filePath;
//update-begin---author:wangshuai---date:2026-03-30---for:【issues/9424】CommandExecUtil 命令执行过程中存在疑似路径遍历漏洞/【issues/9425】EmbeddingHandler 知识库解析过程中疑似存在路径遍历漏洞---
// 1. 路径遍历检查:拒绝 .. 和 %2e 等绕过手段
SsrfFileTypeFilter.checkPathTraversal(filePath);
// 2. 标准化路径并校验是否在 uploadpath 范围内
Path root = Paths.get(uploadpath).toAbsolutePath().normalize();
//update-begin---author:wangshuai ---date:2026-04-13 forzip文件 filePath 以 \ 或 / 开头在Windows下被Path.resolve当成驱动器根路径导致误判路径遍历先剥掉前导分隔符-----------
// 去除前导分隔符,保证作为相对路径 resolve 到 uploadpath 之下
String relativePath = filePath.replaceAll("^[\\\\/]+", "");
Path target = root.resolve(relativePath).toAbsolutePath().normalize();
//update-end---author:wangshuai ---date:2026-04-13 forzip文件 filePath 以 \ 或 / 开头在Windows下被Path.resolve当成驱动器根路径导致误判路径遍历先剥掉前导分隔符-----------
if (!target.startsWith(root)) {
log.error("检测到路径遍历攻击! filePath: {}, 解析后: {}", filePath, target);
throw new JeecgBootException("文件路径包含非法字符");
}
filePath = target.toString();
//update-end---author:wangshuai---date:2026-03-30---for:【issues/9424】CommandExecUtil 命令执行过程中存在疑似路径遍历漏洞/【issues/9425】EmbeddingHandler 知识库解析过程中疑似存在路径遍历漏洞---
}
return filePath;
}

View File

@@ -277,7 +277,15 @@ public class PluginToolBuilder {
Object value = args.get(paramName);
if (value != null) {
url = url.replace("{" + paramName + "}", value.toString());
//update-begin---author:wangshuai---date:2026-03-30---for:【issues/9421】buildUrl路径遍历漏洞修复---
String paramValue = value.toString();
// 防止路径遍历注入:拒绝包含 ..、/ 、\ 的路径参数
if (paramValue.contains("..") || paramValue.contains("/") || paramValue.contains("\\")
|| paramValue.toLowerCase().contains("%2e") || paramValue.toLowerCase().contains("%2f")) {
throw new IllegalArgumentException("Path参数包含非法字符: " + paramName);
}
url = url.replace("{" + paramName + "}", paramValue);
//update-end---author:wangshuai---date:2026-03-30---for:【issues/9421】buildUrl路径遍历漏洞修复---
}
}
}

View File

@@ -16,4 +16,13 @@ public interface IAiragFlowPluginService {
* @param flowIds 多个流程id
*/
Map<String, Object> getFlowsToPlugin(String flowIds);
/**
* 获取流程插件(携带应用上下文参数)
*
* @param flowIds 多个流程id
* @param appId 应用ID变量节点需要
* @param memoryId 记忆库ID记忆节点需要
*/
Map<String, Object> getFlowsToPlugin(String flowIds, String appId, String memoryId);
}

View File

@@ -1,45 +0,0 @@
package org.jeecg.modules.airag.llm.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.airag.api.IAiragBaseApi;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.exception.JeecgBootBizTipException;
import org.jeecg.common.util.AssertUtils;
import org.jeecg.modules.airag.llm.consts.LLMConsts;
import org.jeecg.modules.airag.llm.entity.AiragKnowledgeDoc;
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeDocService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
/**
* airag baseAPI 实现类
*/
@Slf4j
@Primary
@Service("airagBaseApiImpl")
public class AiragBaseApiImpl implements IAiragBaseApi {
@Autowired
private IAiragKnowledgeDocService airagKnowledgeDocService;
@Override
public String knowledgeWriteTextDocument(String knowledgeId, String title, String content) {
AssertUtils.assertNotEmpty("知识库ID不能为空", knowledgeId);
AssertUtils.assertNotEmpty("写入内容不能为空", content);
AiragKnowledgeDoc knowledgeDoc = new AiragKnowledgeDoc();
knowledgeDoc.setKnowledgeId(knowledgeId);
knowledgeDoc.setTitle(title);
knowledgeDoc.setType(LLMConsts.KNOWLEDGE_DOC_TYPE_TEXT);
knowledgeDoc.setContent(content);
Result<?> result = airagKnowledgeDocService.editDocument(knowledgeDoc);
if (!result.isSuccess()) {
throw new JeecgBootBizTipException(result.getMessage());
}
if (knowledgeDoc.getId() == null) {
throw new JeecgBootBizTipException("知识库文档ID为空");
}
log.info("[AI-KNOWLEDGE] 文档写入完成,知识库:{}, 文档ID:{}", knowledgeId, knowledgeDoc.getId());
return knowledgeDoc.getId();
}
}

View File

@@ -24,6 +24,8 @@ import org.jeecg.modules.airag.llm.service.IAiragFlowPluginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
@@ -39,8 +41,17 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
@Autowired
private IAiragFlowService airagFlowService;
@Override
public Map<String, Object> getFlowsToPlugin(String flowIds, String appId, String memoryId) {
return doGetFlowsToPlugin(flowIds, appId, memoryId);
}
@Override
public Map<String, Object> getFlowsToPlugin(String flowIds) {
return doGetFlowsToPlugin(flowIds, null, null);
}
private Map<String, Object> doGetFlowsToPlugin(String flowIds, String appId, String memoryId) {
log.info("开始构建流程插件");
// 1. 查询所有启用的流程
LambdaQueryWrapper<AiragFlow> queryWrapper = new LambdaQueryWrapper<>();
@@ -85,8 +96,11 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
if (oConvertUtils.isNotEmpty(flow.getDescr())) {
description += " : " + flow.getDescr();
}
// 构建插件请求路径(携带应用上下文参数)
String pluginPath = FlowPluginContent.PLUGIN_REQUEST_URL + flow.getId();
pluginPath = appendContextParams(pluginPath, appId, memoryId);
//构造工具参数
String flowTool = buildParameter(parameter, outParams, flow.getId(), tool.getTools(), validToolName, description);
String flowTool = buildParameter(parameter, outParams, pluginPath, tool.getTools(), validToolName, description);
tool.setTools(flowTool);
toolCount++;
} catch (Exception e) {
@@ -125,17 +139,17 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
*
* @param parameter
* @param outParams
* @param flowId
* @param pluginPath 插件请求路径已包含appId等上下文参数
* @param tools
* @param description
* @param name
*/
private String buildParameter(JSONArray parameter, JSONArray outParams, String flowId, String tools, String name, String description) {
private String buildParameter(JSONArray parameter, JSONArray outParams, String pluginPath, String tools, String name, String description) {
JSONArray paramArray = new JSONArray();
JSONObject parameterObject = new JSONObject();
parameterObject.put(FlowPluginContent.NAME, name);
parameterObject.put(FlowPluginContent.DESCRIPTION, description);
parameterObject.put(FlowPluginContent.PATH, FlowPluginContent.PLUGIN_REQUEST_URL + flowId);
parameterObject.put(FlowPluginContent.PATH, pluginPath);
parameterObject.put(FlowPluginContent.METHOD, FlowPluginContent.POST);
parameterObject.put(FlowPluginContent.ENABLED, true);
parameterObject.put(FlowPluginContent.PARAMETERS, parameter);
@@ -149,6 +163,34 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
return paramArray.toJSONString();
}
/**
* 将应用上下文参数追加到插件请求路径中
*
* @param path 原始路径
* @param appId 应用ID
* @param memoryId 记忆库ID
* @return 追加查询参数后的路径
*/
private String appendContextParams(String path, String appId, String memoryId) {
StringBuilder sb = new StringBuilder(path);
boolean hasParam = false;
if (oConvertUtils.isNotEmpty(appId)) {
sb.append("?appId=").append(urlEncode(appId));
hasParam = true;
}
if (oConvertUtils.isNotEmpty(memoryId)) {
sb.append(hasParam ? "&" : "?").append("memoryId=").append(urlEncode(memoryId));
}
return sb.toString();
}
/**
* URL编码
*/
private String urlEncode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
/**
* 获取参数
*

View File

@@ -308,6 +308,20 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl<AiragKnowledgeDocM
throw new JeecgBootException("请上传zip压缩包");
}
String uploadedZipPath = CommonUtils.uploadLocal(zipFile, bizPath, uploadpath);
//update-begin---wangshuai---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
// 判断知识库是否配置了默认分段策略
boolean knowledgeHasDefaultSegment = false;
AiragKnowledge knowledge = airagKnowledgeMapper.selectById(knowId);
if (knowledge != null && oConvertUtils.isNotEmpty(knowledge.getMetadata())) {
try {
JSONObject kmeta = JSONObject.parseObject(knowledge.getMetadata());
knowledgeHasDefaultSegment = Boolean.TRUE.equals(kmeta.getBoolean(LLMConsts.ENABLE_SEGMENT));
} catch (Exception ignore) {}
}
final boolean useKnowledgeDefault = knowledgeHasDefaultSegment;
//update-end---author:wangshuai ---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
// 解压缩文件
List<AiragKnowledgeDoc> docList = new ArrayList<>();
AtomicInteger fileCount = new AtomicInteger(0);
@@ -338,6 +352,12 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl<AiragKnowledgeDocM
JSONObject metadata = new JSONObject();
metadata.put(LLMConsts.KNOWLEDGE_DOC_METADATA_FILEPATH, relativePath);
metadata.put(LLMConsts.KNOWLEDGE_DOC_METADATA_SOURCES_PATH, sourcesPath);
//update-begin---wangshuai---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
// 知识库有默认分段策略,文档标记使用知识库默认
if (useKnowledgeDefault) {
metadata.put(LLMConsts.USE_KNOWLEDGE_DEFAULT, true);
}
//update-end---wangshuai---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
doc.setMetadata(metadata.toJSONString());
docList.add(doc);
});
@@ -398,6 +418,13 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl<AiragKnowledgeDocM
throw new IOException("解压文件数量超限可能是zip bomb攻击");
}
//update-begin---author:scott ---date:2026-04-16 for【issues/9551】macOS压缩包隐藏文件过滤-----------
if (shouldSkipZipEntry(entry.getName())) {
log.info("跳过压缩包中的隐藏文件: {}", entry.getName());
continue;
}
//update-end---author:scott ---date:2026-04-16 for【issues/9551】macOS压缩包隐藏文件过滤-----------
Path newPath = safeResolve(targetDir, entry.getName());
if (entry.isDirectory()) {
@@ -424,6 +451,23 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl<AiragKnowledgeDocM
}
}
//update-begin---author:scott ---date:2026-04-16 for【issues/9551】macOS压缩包隐藏文件过滤-----------
/**
* 过滤压缩包中的系统隐藏文件,例如 macOS 自动生成的 __MACOSX 和 ._ 文件。
*/
static boolean shouldSkipZipEntry(String entryName) {
if (oConvertUtils.isEmpty(entryName)) {
return true;
}
String normalizedName = entryName.replace("\\", "/");
if (normalizedName.startsWith("__MACOSX/")) {
return true;
}
String fileName = Paths.get(normalizedName).getFileName().toString();
return fileName.startsWith("._") || fileName.equals(".DS_Store");
}
//update-end---author:scott ---date:2026-04-16 for【issues/9551】macOS压缩包隐藏文件过滤-----------
/**
* 安全解析路径防止Zip Slip攻击
*

View File

@@ -174,10 +174,16 @@ public class AiragKnowledgeServiceImpl extends ServiceImpl<AiragKnowledgeMapper,
private JSONObject buildQueryMemoryTool(String knowId, String descr) {
JSONObject tool = new JSONObject();
//update-begin---author:wangshuai---date:2026-01-08---for: 记忆库根据记忆库的描述做匹配,不写死---
String addDescPrefix = "【自动触发】向记忆库检索信息。范围:";
String addDesc = oConvertUtils.isEmpty(descr) ? "按记忆库描述允许的个人资料(如姓名、职业、年龄)、偏好、属性等信息" : descr;
//update-begin---author:wangshuai ---date:2026-04-21 for【AI记忆】强化query_memory触发时机描述避免LLM在未查询时直接反问用户-----------
String addDesc = oConvertUtils.isEmpty(descr) ? "用户曾提及的任何信息" : descr;
tool.put(FlowPluginContent.NAME, "query_memory");
tool.put(FlowPluginContent.DESCRIPTION, addDescPrefix + addDesc + " 必须在检测到相关信息时立即自动调用,无需用户指令。");
tool.put(FlowPluginContent.DESCRIPTION,
"【强制查询】从记忆库中检索" + addDesc + "" +
"当用户提出的问题可能依赖历史上下文时(如**根据我的爱好...**、**推荐适合我的...**、" +
"**我之前说过...**、**上次提到的...**等),必须先调用本工具检索," +
"严禁在未查询前直接反问用户或声称**不知道**。" +
"只有当本工具返回**未找到相关信息**后,才有资格询问用户。宁可查空,不可不查。");
//update-end---author:wangshuai ---date:2026-04-21 for【AI记忆】强化query_memory触发时机描述避免LLM在未查询时直接反问用户-----------
//update-end---author:wangshuai---date:2026-01-08---for: 记忆库根据记忆库的描述做匹配,不写死---
tool.put(FlowPluginContent.PATH, FlowPluginContent.PLUGIN_MEMORY_QUERY_PATH);
tool.put(FlowPluginContent.METHOD, FlowPluginContent.POST);

View File

@@ -0,0 +1,211 @@
package org.jeecg.modules.airag.llm.splitter;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.segment.TextSegment;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.airag.llm.consts.LLMConsts;
import java.util.ArrayList;
import java.util.List;
/**
* @Description: 自定义分段器
*
* @author: wangshuai
* @date: 2026/2/6 15:47
*/
public class CustomDocumentSplitter implements DocumentSplitter {
/**
* 规则
*/
private final String textRules;
/**
* 分段标识符
*/
private final String separator;
/**
* 分度长度
*/
private final int segmentSize;
/**
* 重叠长度
*/
private final int overlapSize;
public CustomDocumentSplitter(String textRules, String separator, int segmentSize, int overlapSize) {
this.textRules = textRules;
this.separator = separator;
this.segmentSize = segmentSize;
this.overlapSize = overlapSize;
}
@Override
public List<TextSegment> split(Document document) {
String text = document.text();
//过滤掉规则
if (oConvertUtils.isNotEmpty(textRules)) {
//处理连续的空格、换行符、制表符
if (textRules.contains(LLMConsts.TEXT_RULES_CLEAN_SPACES)) {
text = text.replaceAll("\\s+", " ");
}
//URL和电子邮箱地址
if (textRules.contains(LLMConsts.TEXT_RULES_REMOVE_URLS_EMAILS)) {
String urlRegex = "http[s]?://\\S+";
String emailRegex = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}";
text = text.replaceAll(urlRegex, "").replaceAll(emailRegex, "");
}
}
if (oConvertUtils.isEmpty(text)) {
return new ArrayList<>();
}
//根据定义的分词进行分割
String[] parts = text.split(java.util.regex.Pattern.quote(separator));
//存放TextSegment的集合
List<TextSegment> segments = new ArrayList<>();
//存放文本的集合
List<String> currentBuffer = new ArrayList<>();
int currentLength = 0;
for (int i = 0; i < parts.length; i++) {
String part = parts[i];
if (oConvertUtils.isEmpty(part)) {
continue;
}
// 如果不是第一部分根据索引判断说明之前有分隔符补到当前part开头
if (i > 0) {
part = separator + part;
}
//文本长度
int partLen = part.length();
// 预计长度 = 当前长度 + 文本长度 (分隔符已包含在part中)
int projectedLen = currentLength + partLen;
//判断分隔长度
if (projectedLen <= segmentSize) {
//分隔长度小于自定义的分割长度
currentBuffer.add(part);
currentLength = projectedLen;
} else {
// 1. 保存当前分段
if (!currentBuffer.isEmpty()) {
flushAndOverlap(segments, currentBuffer, document, true);
// 分隔符已包含在元素中,直接求和
currentLength = currentBuffer.stream().mapToInt(String::length).sum();
}
// 3. 处理当前part
// 检查加上当前part是否超过限制
int newProjectedLen = currentLength + partLen;
if (newProjectedLen <= segmentSize) {
currentBuffer.add(part);
currentLength = newProjectedLen;
} else {
// part太长需要切分
int offset = 0;
//截取的长度小于文本长度,跳出循环
while (offset < partLen) {
// 计算当前分段剩余可用空间
// space = 最大分段长度 - 当前已用长度
int space = segmentSize - currentLength;
if (space <= 0) {
// Buffer满可能是重叠导致的强制刷新
flushAndOverlap(segments, currentBuffer, document, true);
currentLength = currentBuffer.stream().mapToInt(String::length).sum();
// 刷新后重新计算剩余空间
space = segmentSize - currentLength;
// 如果重叠本身就超长即space <= 0则清空Buffer以避免死循环并重置space为整个分段长度
if (space <= 0) {
currentBuffer.clear();
currentLength = 0;
space = segmentSize;
}
}
// 计算本次能截取的长度取剩余空间和剩余part长度的较小值
int take = Math.min(space, partLen - offset);
String chunk = part.substring(offset, offset + take);
currentBuffer.add(chunk);
currentLength += take;
offset += take;
// 如果还没处理完part说明填满了buffer需要flush
if (offset < partLen) {
flushAndOverlap(segments, currentBuffer, document, false);
currentLength = currentBuffer.stream().mapToInt(String::length).sum();
}
}
}
}
}
// 处理剩余部分
if (!currentBuffer.isEmpty()) {
String segmentText = String.join("", currentBuffer).trim();
if (oConvertUtils.isNotEmpty(segmentText)) {
segments.add(TextSegment.from(segmentText, document.metadata()));
}
}
return segments;
}
/**
* 将当前buffer内容保存为segment并处理重叠部分
* @param segments 结果集合
* @param buffer 当前文本buffer
* @param document 原始文档(用于元数据)
*/
private void flushAndOverlap(List<TextSegment> segments, List<String> buffer, Document document, boolean enableOverlap) {
if (buffer.isEmpty()) {
return;
}
// 保存当前分段
String segmentText = String.join("", buffer).trim();
if (oConvertUtils.isEmpty(segmentText)) {
buffer.clear();
return;
}
segments.add(TextSegment.from(segmentText, document.metadata()));
if (!enableOverlap) {
buffer.clear();
return;
}
// 处理重叠 (保留buffer末尾部分)
List<String> newBuffer = new ArrayList<>();
int newLen = 0;
// 倒序遍历查找可保留的末尾部分
for (int j = buffer.size() - 1; j >= 0; j--) {
String p = buffer.get(j);
int pLen = p.length();
//update-begin---author:wangshuai ---date:2026-04-09 for【issue/9418】修复重叠率失效问题当某个part本身超过overlapSize时取其尾部子串保证重叠不为0-----------
if (newLen + pLen <= overlapSize) {
// 整段可以放入重叠区
newBuffer.add(0, p);
newLen += pLen;
} else {
// 剩余可用空间
int remaining = overlapSize - newLen;
if (remaining > 0) {
// 取该元素的尾部子串,保证重叠区不为空
newBuffer.add(0, p.substring(pLen - remaining));
}
// 已填满重叠区,停止
//update-end---author:wangshuai ---date:2026-04-09 for【issue/9418】修复重叠率失效问题当某个part本身超过overlapSize时取其尾部子串保证重叠不为0-----------
break;
}
}
// 更新buffer为仅包含重叠部分
buffer.clear();
buffer.addAll(newBuffer);
}
}

View File

@@ -0,0 +1,91 @@
package org.jeecg.modules.airag.video.controller;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.airag.video.service.IVideoGenerationService;
import org.jeecg.modules.airag.video.vo.VideoGenerateVo;
import org.jeecg.modules.airag.video.vo.VideoTaskResultVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* AI视频生成Controller
*/
@Slf4j
@RestController
@RequestMapping("/airag/video")
public class VideoGenerationController {
@Autowired
private IVideoGenerationService videoGenerationService;
/**
* 提交视频生成任务
*/
@PostMapping("/submit")
public Result<VideoTaskResultVo> submitTask(@RequestBody VideoGenerateVo vo) {
VideoTaskResultVo result = videoGenerationService.submitTask(vo);
if ("FAIL".equals(result.getStatus())) {
return Result.error(result.getMessage());
}
return Result.OK(result);
}
/**
* 查询视频生成任务状态
*/
@GetMapping("/query/{taskId}")
public Result<VideoTaskResultVo> queryTask(@PathVariable String taskId) {
VideoTaskResultVo result = videoGenerationService.queryTask(taskId);
return Result.OK(result);
}
/**
* 为已完成的视频添加AI配音
* 流程:生成旁白文案 → TTS语音合成 → FFmpeg合并视频和音频
*/
@PostMapping("/voiceover")
public Result<VideoTaskResultVo> addVoiceover(@RequestBody VideoGenerateVo vo) {
if (vo.getTaskId() == null || vo.getTaskId().isBlank()) {
return Result.error("taskId不能为空");
}
if (vo.getPrompt() == null || vo.getPrompt().isBlank()) {
return Result.error("prompt不能为空");
}
VideoTaskResultVo result = videoGenerationService.addVoiceover(vo.getTaskId(), vo.getPrompt());
if ("FAIL".equals(result.getStatus())) {
return Result.error(result.getMessage());
}
return Result.OK(result);
}
/**
* 获取预设提示词
*/
@GetMapping("/prompts")
public Result<Map<String, List<String>>> getPresetPrompts() {
return Result.OK(videoGenerationService.getPresetPrompts());
}
/**
* 查询当前用户的视频生成记录
*/
@GetMapping("/listByUser")
public Result<List<JSONObject>> getVideoRecords(@RequestParam String userId) {
List<JSONObject> records = videoGenerationService.getVideoRecords(userId);
return Result.OK(records);
}
/**
* 删除视频生成记录
*/
@DeleteMapping("/deleteVideoRecord")
public Result<String> deleteVideoRecord(@RequestParam String userId, @RequestParam String recordId) {
boolean deleted = videoGenerationService.deleteVideoRecord(userId, recordId);
return deleted ? Result.OK("删除成功") : Result.error("记录不存在");
}
}

View File

@@ -0,0 +1,48 @@
package org.jeecg.modules.airag.video.service;
import com.alibaba.fastjson2.JSONObject;
import com.alibaba.fastjson2.JSONObject;
import org.jeecg.modules.airag.video.vo.VideoGenerateVo;
import org.jeecg.modules.airag.video.vo.VideoTaskResultVo;
import java.util.List;
import java.util.Map;
/**
* AI视频生成服务接口
*/
public interface IVideoGenerationService {
/**
* 提交视频生成任务
*/
VideoTaskResultVo submitTask(VideoGenerateVo vo);
/**
* 查询任务状态
*/
VideoTaskResultVo queryTask(String taskId);
/**
* 为已完成的视频添加AI配音
* @param taskId 视频任务ID
* @param prompt 原始提示词(用于生成旁白)
* @return 包含配音视频URL的结果
*/
VideoTaskResultVo addVoiceover(String taskId, String prompt);
/**
* 获取预设提示词
*/
Map<String, List<String>> getPresetPrompts();
/**
* 查询用户视频生成记录列表
*/
List<JSONObject> getVideoRecords(String userId);
/**
* 删除用户视频生成记录
*/
boolean deleteVideoRecord(String userId, String recordId);
}

View File

@@ -0,0 +1,686 @@
package org.jeecg.modules.airag.video.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.RedisUtil;
import org.springframework.data.redis.core.RedisTemplate;
import org.jeecg.config.AiChatConfig;
import org.jeecg.modules.airag.voice.util.VoiceApiHelper;
import org.jeecg.modules.airag.video.service.IVideoGenerationService;
import org.jeecg.modules.airag.video.vo.VideoGenerateVo;
import org.jeecg.modules.airag.video.vo.VideoTaskResultVo;
import org.jeecg.config.JeecgBaseConfig;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.*;
/**
* AI视频生成服务实现
*/
@Slf4j
@Service
public class VideoGenerationServiceImpl implements IVideoGenerationService {
/** apiHost 从 yml 配置 jeecg.ai-chat.ai-model-video.api-host 读取,不再硬编码 */
private static final String REDIS_KEY_PREFIX = "airag:video:";
private static final Map<String, List<String>> PRESET_PROMPTS = new LinkedHashMap<>();
static {
PRESET_PROMPTS.put("通用演示", List.of(
"一只金毛犬在金色的沙滩上奔跑,海浪轻轻拍打着岸边,阳光明媚,慢动作镜头",
"航拍壮丽的山脉全景,云雾缭绕在山峰之间,镜头缓缓推进",
"樱花树下,花瓣随风飘落,一条小溪静静流淌,春日午后的宁静氛围"
));
PRESET_PROMPTS.put("产品营销", List.of(
"一杯咖啡被缓缓倒入透明玻璃杯中,咖啡与牛奶融合形成美丽的纹理,微距特写",
"一款高端智能手表在旋转展示台上缓缓旋转,灯光打在表面上反射出金属光泽,黑色背景",
"一双运动鞋踩入水洼溅起水花,慢动作特写,动感活力的画面"
));
PRESET_PROMPTS.put("教育培训", List.of(
"地球从太空视角缓缓旋转,可以看到大气层和云层的细节,星空背景",
"一本书的书页被风吹动快速翻动,文字和插图若隐若现,知识流动的意象",
"显微镜下的细胞分裂过程,色彩鲜明的科学可视化风格"
));
PRESET_PROMPTS.put("创意设计", List.of(
"一座未来主义的城市在日落时分,霓虹灯光倒映在雨水的路面上,赛博朋克风格",
"水墨在水中缓缓扩散,形成抽象的山水画意境,中国风艺术效果",
"星空下的极光在天空中舞动,色彩绚烂,延时摄影效果"
));
}
@Autowired
private AiChatConfig aiChatConfig;
@Autowired
private JeecgBaseConfig jeecgBaseConfig;
@Autowired
private VoiceApiHelper voiceApiHelper;
@Resource
private RedisUtil redisUtil;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/** 实际使用的ffmpeg路径优先yml配置其次自动查找 */
private String ffmpegPath;
/** 实际使用的edge-tts路径优先yml配置其次自动查找 */
private String edgeTtsPath;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
@PostConstruct
public void init() {
// 从yml配置读取若为空则自动查找
AiChatConfig.VideoModelConfig videoConfig = aiChatConfig.getAiModelVideo();
String configFfmpeg = videoConfig.getFfmpegPath();
String configEdgeTts = videoConfig.getEdgeTtsPath();
if (configFfmpeg != null && !configFfmpeg.isBlank()) {
this.ffmpegPath = configFfmpeg;
} else {
this.ffmpegPath = findCommand(new String[]{"-version"}, "ffmpeg", "ffmpeg.exe", "C:/tools/ffmpeg/ffmpeg.exe");
}
if (configEdgeTts != null && !configEdgeTts.isBlank()) {
this.edgeTtsPath = configEdgeTts;
} else {
this.edgeTtsPath = findCommand(new String[]{"--version"}, "edge-tts", "" +
"", "D:/ProgramFiles/miniconda3/Scripts/edge-tts.exe");
}
log.info("=== AI视频配音工具检测 ===");
if (ffmpegPath != null) {
log.info(" ffmpeg : 已找到 -> {}", ffmpegPath);
} else {
log.warn(" ffmpeg : 未安装,视频配音功能将不可用");
}
if (edgeTtsPath != null) {
log.info(" edge-tts : 已找到 -> {}", edgeTtsPath);
} else {
log.warn(" edge-tts : 未安装,视频配音功能将不可用");
}
if (isToolsAvailable()) {
log.info(" 视频配音功能: 已启用");
} else {
log.warn(" 视频配音功能: 已禁用(缺少依赖工具,调用配音接口将直接返回无声视频)");
}
log.info("===========================");
}
@Override
public VideoTaskResultVo submitTask(VideoGenerateVo vo) {
AiChatConfig.ModelConfig config = aiChatConfig.getAiModelVideo();
String apiKey = config.getApiKey();
String model = config.getModel();
String apiHost = config.getApiHost();
String baseUrl = apiHost.endsWith("/") ? apiHost.substring(0, apiHost.length() - 1) : apiHost;
JSONObject body = new JSONObject();
body.put("model", model);
body.put("prompt", vo.getPrompt());
//生成质量
body.put("quality", "quality");
// 前端传递 izAiAudio1=使用AI合成音效0=不使用
boolean aiAutoAudio = vo.getIzAiAudio() != null && vo.getIzAiAudio() == 1;
if (model.contains("vidu2")) {
// vidu2系列模型使用 aspect_ratio 参数,将 size 转为宽高比
if (vo.getSize() != null) {
body.put("aspect_ratio", convertSizeToAspectRatio(vo.getSize()));
}
} else {
if (vo.getSize() != null) {
body.put("size", vo.getSize());
}
if (vo.getFps() != null) {
body.put("fps", vo.getFps());
}
if (vo.getDuration() != null) {
body.put("duration", vo.getDuration());
}
if (aiAutoAudio) {
body.put("with_audio", true);
}
}
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/videos/generations"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toJSONString()))
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
log.info("视频生成任务提交响应: status={}, body={}", response.statusCode(), response.body());
if (response.statusCode() != 200) {
VideoTaskResultVo result = new VideoTaskResultVo();
result.setStatus("FAIL");
String errorMsg = "提交任务失败,状态码: " + response.statusCode();
try {
JSONObject errorJson = JSON.parseObject(response.body());
JSONObject errorObj = errorJson.getJSONObject("error");
if (errorObj != null && errorObj.getString("message") != null) {
errorMsg += "" + errorObj.getString("message");
}
} catch (Exception ignored) {
}
result.setMessage(errorMsg);
return result;
}
JSONObject respJson = JSON.parseObject(response.body());
String taskId = respJson.getString("id");
// 缓存prompt到Redis供视频完成后自动生成语音使用
redisUtil.set(REDIS_KEY_PREFIX + "prompt:" + taskId, vo.getPrompt(), 86400);
// 缓存是否AI自动生成音效标记
redisUtil.set(REDIS_KEY_PREFIX + "aiAutoAudio:" + taskId, String.valueOf(aiAutoAudio), 86400);
VideoTaskResultVo result = new VideoTaskResultVo();
result.setTaskId(taskId);
result.setStatus("PROCESSING");
return result;
} catch (Exception e) {
log.error("提交视频生成任务异常", e);
VideoTaskResultVo result = new VideoTaskResultVo();
result.setStatus("FAIL");
result.setMessage("提交任务异常: " + e.getMessage());
return result;
}
}
@Override
public VideoTaskResultVo queryTask(String taskId) {
AiChatConfig.ModelConfig config = aiChatConfig.getAiModelVideo();
String apiKey = config.getApiKey();
String apiHost = config.getApiHost();
String baseUrl = apiHost.endsWith("/") ? apiHost.substring(0, apiHost.length() - 1) : apiHost;
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/async-result/" + taskId))
.header("Authorization", "Bearer " + apiKey)
.GET()
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("查询视频任务HTTP失败: taskId={}, httpStatus={}", taskId, response.statusCode());
VideoTaskResultVo result = new VideoTaskResultVo();
result.setTaskId(taskId);
result.setStatus("FAIL");
result.setMessage("查询任务失败,状态码: " + response.statusCode());
return result;
}
JSONObject respJson = JSON.parseObject(response.body());
String taskStatus = respJson.getString("task_status");
log.info("查询视频任务: taskId={}, 任务状态={}", taskId, taskStatus);
VideoTaskResultVo result = new VideoTaskResultVo();
result.setTaskId(taskId);
if ("SUCCESS".equals(taskStatus)) {
result.setStatus("SUCCESS");
JSONArray videoResult = respJson.getJSONArray("video_result");
if (videoResult != null && !videoResult.isEmpty()) {
JSONObject firstVideo = videoResult.getJSONObject(0);
String remoteVideoUrl = firstVideo.getString("url");
String remoteCoverUrl = firstVideo.getString("cover_image_url");
log.info("视频生成完成: taskId={}, videoUrl={}", taskId, remoteVideoUrl);
// 删除prompt缓存只有删除成功的请求才执行后续处理防止并发轮询重复存储
String promptKey = REDIS_KEY_PREFIX + "prompt:" + taskId;
String aiAutoAudioKey = REDIS_KEY_PREFIX + "aiAutoAudio:" + taskId;
//update-begin---author:wangshuai---date:2026-03-23---for:【QQYUN-14960】【AI生成视频】报错了实际视频已经生成完了---
Object cachedPrompt = redisTemplate.opsForValue().get(promptKey);
if (cachedPrompt != null) {
redisTemplate.delete(promptKey);
}
String prompt = cachedPrompt != null ? cachedPrompt.toString() : null;
// 判断是否采用AI自动生成音效
Object cachedAiAutoAudio = redisTemplate.opsForValue().get(aiAutoAudioKey);
if (cachedAiAutoAudio != null) {
redisTemplate.delete(aiAutoAudioKey);
}
//update-end---author:wangshuai---date:2026-03-23---for:【QQYUN-14960】【AI生成视频】报错了实际视频已经生成完了---
boolean aiAutoAudio = cachedAiAutoAudio != null && "true".equals(cachedAiAutoAudio.toString());
// 下载视频和封面到本地,避免远程链接失效
if (remoteCoverUrl != null && !remoteCoverUrl.isBlank()) {
String localCoverPath = downloadToLocal(remoteCoverUrl, "cover_" + taskId + ".jpg");
result.setCoverUrl(localCoverPath);
}
String localVideoPath = downloadToLocal(remoteVideoUrl, "video_" + taskId + ".mp4");
result.setVideoUrl(localVideoPath);
// 仅在prompt缓存存在时执行一次getAndDelete保证只有一个请求能获取到
if (prompt != null && !prompt.isBlank()) {
if (aiAutoAudio) {
log.info("AI自动生成音效模式跳过autoAddVoiceover: taskId={}", taskId);
} else {
log.info("非AI自动音效模式开始autoAddVoiceover: taskId={}, prompt={}", taskId, prompt);
autoAddVoiceover(taskId, prompt, localVideoPath, result);
}
// 存入Redis记录
saveVideoToRedis(result, prompt);
} else {
log.info("未检测到prompt缓存已处理过或旧任务跳过自动配音: taskId={}", taskId);
}
}
} else if ("FAIL".equals(taskStatus)) {
result.setStatus("FAIL");
result.setMessage(respJson.getString("message"));
log.error("视频生成失败: taskId={}, message={}", taskId, result.getMessage());
} else {
result.setStatus("PROCESSING");
log.info("视频生成中: taskId={}, 等待完成...", taskId);
}
return result;
} catch (Exception e) {
log.error("查询视频任务异常, taskId={}", taskId, e);
VideoTaskResultVo result = new VideoTaskResultVo();
result.setTaskId(taskId);
result.setStatus("FAIL");
result.setMessage("查询任务异常: " + e.getMessage());
return result;
}
}
@Override
public VideoTaskResultVo addVoiceover(String taskId, String prompt) {
// 1. 先查询任务状态获取视频URL
VideoTaskResultVo queryResult = queryTask(taskId);
if (!"SUCCESS".equals(queryResult.getStatus())) {
queryResult.setMessage("视频任务尚未完成,当前状态: " + queryResult.getStatus());
return queryResult;
}
String videoUrl = queryResult.getVideoUrl();
if (videoUrl == null || videoUrl.isBlank()) {
queryResult.setStatus("FAIL");
queryResult.setMessage("视频URL为空");
return queryResult;
}
// 2. 检测依赖工具是否可用,不可用则直接返回无声视频
if (!isToolsAvailable()) {
log.warn("服务器未安装 edge-tts 或 ffmpeg跳过配音直接返回无声视频。" +
"edge-tts={}, ffmpeg={}", edgeTtsPath, ffmpegPath);
queryResult.setMessage("服务器未安装配音依赖edge-tts/ffmpeg返回原始无声视频");
return queryResult;
}
try {
String uploadPath = jeecgBaseConfig.getPath().getUpload();
String bizPath = "ai_video";
Path outputDir = Paths.get(uploadPath, bizPath);
Files.createDirectories(outputDir);
String timestamp = String.valueOf(System.currentTimeMillis());
// 3. 下载无声视频
Path silentVideo = downloadFile(videoUrl, outputDir.resolve("silent_" + timestamp + ".mp4"));
log.info("无声视频已下载: {}", silentVideo);
// 4. 生成旁白文案
String narration = generateNarration(prompt);
log.info("旁白文案: {}", narration);
// 5. TTS生成语音
Path audioPath = outputDir.resolve("voiceover_" + timestamp + ".mp3");
generateTtsAudio(narration, audioPath);
log.info("语音已生成: {}", audioPath);
// 6. FFmpeg合成有声视频
String finalFileName = "video_" + timestamp + ".mp4";
Path finalVideo = outputDir.resolve(finalFileName);
mergeVideoAudio(silentVideo, audioPath, finalVideo);
log.info("合成视频已生成: {}", finalVideo);
// 7. 清理临时文件
Files.deleteIfExists(silentVideo);
Files.deleteIfExists(audioPath);
// 8. 返回结果(使用相对路径,通过静态资源映射访问)
String dbPath = bizPath + "/" + finalFileName;
queryResult.setVoiceoverVideoUrl(dbPath);
queryResult.setNarration(narration);
return queryResult;
} catch (Exception e) {
log.error("添加配音异常, taskId={},降级返回无声视频", taskId, e);
queryResult.setMessage("配音处理异常(已降级返回无声视频): " + e.getMessage());
return queryResult;
}
}
/**
* 检测 edge-tts 和 ffmpeg 是否可用
*/
private boolean isToolsAvailable() {
return edgeTtsPath != null && ffmpegPath != null;
}
@Override
public Map<String, List<String>> getPresetPrompts() {
return PRESET_PROMPTS;
}
@Override
public List<JSONObject> getVideoRecords(String userId) {
String redisKey = REDIS_KEY_PREFIX + userId;
List<Object> list = redisUtil.lGet(redisKey, 0, -1);
if (list == null || list.isEmpty()) {
return Collections.emptyList();
}
return list.stream()
.map(item -> JSONObject.parseObject(item.toString()))
.collect(java.util.stream.Collectors.toList());
}
@Override
public boolean deleteVideoRecord(String userId, String recordId) {
String redisKey = REDIS_KEY_PREFIX + userId;
List<Object> list = redisUtil.lGet(redisKey, 0, -1);
if (list == null || list.isEmpty()) {
return false;
}
for (Object item : list) {
JSONObject obj = JSONObject.parseObject(item.toString());
if (recordId.equals(obj.getString("id"))) {
redisUtil.lRemove(redisKey, 1, item);
return true;
}
}
return false;
}
/**
* 将视频生成记录存入Redis
*
* @param result 视频结果
* @param prompt 用户原始prompt
*/
private void saveVideoToRedis(VideoTaskResultVo result, String prompt) {
try {
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
if (loginUser == null) {
log.warn("未获取到登录用户跳过Redis存储");
return;
}
String redisKey = REDIS_KEY_PREFIX + loginUser.getId();
JSONObject record = new JSONObject();
record.put("id", UUID.randomUUID().toString().replace("-", ""));
record.put("taskId", result.getTaskId());
record.put("videoUrl", result.getVideoUrl());
record.put("coverUrl", result.getCoverUrl());
record.put("status", result.getStatus());
record.put("content", prompt);
record.put("createTime", java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
redisUtil.lSet(redisKey, record.toJSONString());
log.info("视频记录已存入Redis列表: key={}", redisKey);
} catch (Exception e) {
log.warn("视频记录存入Redis失败不影响主流程", e);
}
}
/**
* 将远程文件下载到本地上传目录返回相对路径ai_video/xxx
*/
private String downloadToLocal(String remoteUrl, String fileName) {
try {
String uploadPath = jeecgBaseConfig.getPath().getUpload();
String bizPath = "video";
Path outputDir = Paths.get(uploadPath, bizPath);
Files.createDirectories(outputDir);
Path localFile = outputDir.resolve(fileName);
downloadFile(remoteUrl, localFile);
log.info("远程文件已下载到本地: {} -> {}", remoteUrl, localFile);
return bizPath + "/" + fileName;
} catch (Exception e) {
log.warn("下载远程文件到本地失败返回原始URL: {}", remoteUrl, e);
return remoteUrl;
}
}
/**
* 下载文件到本地
*/
private Path downloadFile(String url, Path outputPath) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.timeout(Duration.ofMinutes(5))
.build();
httpClient.send(request, HttpResponse.BodyHandlers.ofFile(outputPath));
return outputPath;
}
/**
* 调用智谱GLM生成旁白文案
*/
private String generateNarration(String videoPrompt) throws IOException, InterruptedException {
AiChatConfig.ModelConfig config = aiChatConfig.getAiModelVideo();
String apiKey = config.getApiKey();
String apiHost = config.getApiHost();
String baseUrl = apiHost.endsWith("/") ? apiHost.substring(0, apiHost.length() - 1) : apiHost;
JSONObject body = new JSONObject();
body.put("model", "glm-4-flash");
JSONArray messages = new JSONArray();
JSONObject systemMsg = new JSONObject();
systemMsg.put("role", "system");
systemMsg.put("content", "你是一位专业的视频旁白撰写者。根据用户给出的视频画面描述,"
+ "撰写一段简短的旁白配音文案30-50字语言优美、富有感染力适合作为视频解说词。"
+ "只输出旁白文案本身,不要加引号或其他说明。");
JSONObject userMsg = new JSONObject();
userMsg.put("role", "user");
userMsg.put("content", "视频画面:" + videoPrompt);
messages.add(systemMsg);
messages.add(userMsg);
body.put("messages", messages);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/chat/completions"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toJSONString()))
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("旁白生成失败,状态码: " + response.statusCode());
}
JSONObject respJson = JSON.parseObject(response.body());
return respJson.getJSONArray("choices")
.getJSONObject(0)
.getJSONObject("message")
.getString("content")
.trim();
}
/**
* 使用 edge-tts 将文本转为语音
*/
private void generateTtsAudio(String text, Path audioPath) throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder(
edgeTtsPath,
"--voice", "zh-CN-YunyangNeural",
"--text", text,
"--write-media", audioPath.toAbsolutePath().toString()
);
pb.redirectErrorStream(true);
Process process = pb.start();
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
if (exitCode != 0) {
log.error("edge-tts 执行失败: {}", output);
throw new RuntimeException("edge-tts 执行失败,退出码: " + exitCode);
}
}
/**
* 视频生成成功后,自动生成语音并合并(参照 addVoiceover 逻辑)
* 使用 TTS APIVoiceApiHelper生成语音ffmpeg 合并
* 失败时降级返回无声视频,不影响主流程
*
* @param taskId 任务ID
* @param prompt 用户原始prompt
* @param localVideoPath 本地无声视频相对路径(如 video/video_xxx.mp4
* @param result 结果对象,成功时更新 videoUrl 和 narration
*/
private void autoAddVoiceover(String taskId, String prompt, String localVideoPath, VideoTaskResultVo result) {
log.info(">>> autoAddVoiceover 开始: taskId={}, ffmpeg={}, localVideoPath={}", taskId, ffmpegPath, localVideoPath);
if (ffmpegPath == null) {
log.info("ffmpeg不可用跳过自动配音: taskId={}", taskId);
return;
}
try {
String uploadPath = jeecgBaseConfig.getPath().getUpload();
String bizPath = "video";
Path outputDir = Paths.get(uploadPath, bizPath);
Files.createDirectories(outputDir);
String timestamp = String.valueOf(System.currentTimeMillis());
// 1. 生成旁白文案
log.info("自动配音第1步-生成旁白文案: taskId={}", taskId);
String narration = generateNarration(prompt);
log.info("自动配音旁白文案: {}", narration);
// 2. TTS API 生成语音
Path audioPath = outputDir.resolve("auto_voice_" + taskId + "_" + timestamp + ".wav");
log.info("自动配音第2步-TTS生成语音: taskId={}, audioPath={}", taskId, audioPath);
voiceApiHelper.generateAudio(narration, audioPath);
log.info("自动配音语音已生成: {}, 文件存在={}", audioPath, Files.exists(audioPath));
// 3. FFmpeg 合并视频和音频
Path silentVideoFile = outputDir.resolve("video_" + taskId + ".mp4");
log.info("自动配音第3步-FFmpeg合并: silentVideo={} (存在={})", silentVideoFile, Files.exists(silentVideoFile));
String mergedFileName = "video_voiced_" + taskId + "_" + timestamp + ".mp4";
Path mergedVideo = outputDir.resolve(mergedFileName);
mergeVideoAudio(silentVideoFile, audioPath, mergedVideo);
log.info("自动配音合成完成: {}, 文件存在={}", mergedVideo, Files.exists(mergedVideo));
// 4. 更新结果为有声视频
result.setVideoUrl(bizPath + "/" + mergedFileName);
result.setNarration(narration);
// 5. 清理临时音频文件
Files.deleteIfExists(audioPath);
log.info(">>> autoAddVoiceover 完成: taskId={}, videoUrl={}", taskId, result.getVideoUrl());
} catch (Exception e) {
log.error(">>> autoAddVoiceover 失败,降级返回无声视频: taskId={}", taskId, e);
}
}
/**
* 使用 FFmpeg 合并视频和音频
*/
private void mergeVideoAudio(Path videoPath, Path audioPath, Path outputPath) throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder(
ffmpegPath,
"-i", videoPath.toAbsolutePath().toString(),
"-i", audioPath.toAbsolutePath().toString(),
"-c:v", "copy",
"-c:a", "aac",
"-shortest",
"-y",
outputPath.toAbsolutePath().toString()
);
pb.redirectErrorStream(true);
Process process = pb.start();
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
if (exitCode != 0) {
log.error("FFmpeg 合成失败: {}", output);
throw new RuntimeException("FFmpeg 合成失败,退出码: " + exitCode);
}
}
/**
* 将 size如 "1920x1080")转为 vidu2 的 aspect_ratio 格式(如 "16:9"
* 支持的比例16:9、9:16、1:1无法识别时默认 16:9
*/
private String convertSizeToAspectRatio(String size) {
if (size == null || size.isBlank()) {
return "16:9";
}
// 已经是比例格式则直接返回
if (size.matches("\\d+:\\d+")) {
return size;
}
// 解析 WxH 格式
String[] parts = size.toLowerCase().split("x");
if (parts.length == 2) {
try {
int w = Integer.parseInt(parts[0].trim());
int h = Integer.parseInt(parts[1].trim());
if (w == h) {
return "1:1";
} else if (w > h) {
return "16:9";
} else {
return "9:16";
}
} catch (NumberFormatException ignored) {
}
}
return "16:9";
}
/**
* 查找可用的命令路径找不到返回null
*/
private static String findCommand(String[] versionFlag, String... candidates) {
for (String path : candidates) {
try {
List<String> cmd = new ArrayList<>();
cmd.add(path);
cmd.addAll(List.of(versionFlag));
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
p.getInputStream().readAllBytes();
p.waitFor();
if (p.exitValue() == 0) return path;
} catch (Exception ignored) {}
}
return null;
}
}

View File

@@ -0,0 +1,44 @@
package org.jeecg.modules.airag.video.vo;
import lombok.Data;
/**
* AI视频生成请求VO
*/
@Data
public class VideoGenerateVo {
/**
* 视频描述提示词
*/
private String prompt;
/**
* 场景分类(通用演示/产品营销/教育培训/创意设计)
*/
private String category;
/**
* 视频任务ID用于添加配音时传入
*/
private String taskId;
/**
* 视频尺寸,如 "1920x1080"、"720x480"
*/
private String size;
/**
* 视频帧率
*/
private Integer fps;
/**
* 视频时长(秒)
*/
private Integer duration;
/**
* 是否为ai合成音效
*/
private Integer izAiAudio;
}

View File

@@ -0,0 +1,44 @@
package org.jeecg.modules.airag.video.vo;
import lombok.Data;
/**
* AI视频生成任务结果VO
*/
@Data
public class VideoTaskResultVo {
/**
* 任务ID
*/
private String taskId;
/**
* 任务状态: PROCESSING, SUCCESS, FAIL
*/
private String status;
/**
* 视频下载URLSUCCESS时有值
*/
private String videoUrl;
/**
* 视频封面URL如有
*/
private String coverUrl;
/**
* 带配音的视频URL
*/
private String voiceoverVideoUrl;
/**
* 旁白文案
*/
private String narration;
/**
* 错误信息FAIL时有值
*/
private String message;
}

View File

@@ -0,0 +1,91 @@
package org.jeecg.modules.airag.voice.controller;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.airag.voice.service.IVoiceService;
import org.jeecg.modules.airag.voice.vo.VoiceGenerateVo;
import org.jeecg.modules.airag.voice.vo.VoiceResultVo;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.alibaba.fastjson2.JSONObject;
import java.util.List;
/**
* 文生语音控制器
*/
@Slf4j
@RestController
@RequestMapping("/airag/voice")
public class VoiceController {
@Autowired
private IVoiceService voiceService;
/**
* 文本生成语音
*/
@PostMapping("/generate")
public Result<VoiceResultVo> generate(@RequestBody VoiceGenerateVo vo) {
// 参数校验
if (vo.getContent() == null || vo.getContent().isBlank()) {
return Result.error("合成文本不能为空");
}
if (vo.getSpeed() != null && (vo.getSpeed() < 0.25 || vo.getSpeed() > 4.0)) {
return Result.error("倍速范围须在0.25~4.0之间");
}
try {
VoiceResultVo result = voiceService.textToSpeech(vo);
return Result.OK(result);
} catch (Exception e) {
log.error("文生语音失败", e);
return Result.error("语音生成失败: " + e.getMessage());
}
}
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
/**
* 异步提交语音生成任务,立即返回 taskId
*/
@PostMapping("/generateAsync")
public Result<String> generateAsync(@RequestBody VoiceGenerateVo vo) {
if (vo.getContent() == null || vo.getContent().isBlank()) {
return Result.error("合成文本不能为空");
}
if (vo.getSpeed() != null && (vo.getSpeed() < 0.25 || vo.getSpeed() > 4.0)) {
return Result.error("倍速范围须在0.25~4.0之间");
}
String taskId = voiceService.generateAsync(vo);
return Result.OK(taskId);
}
/**
* 查询异步语音任务结果
*/
@GetMapping("/queryTask/{taskId}")
public Result<?> queryVoiceTask(@PathVariable String taskId) {
return voiceService.getVoiceTaskResult(taskId);
}
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
/**
* 查询当前用户的语音生成记录
*/
@GetMapping("/listByUser")
public Result<List<JSONObject>> getVoiceRecords(@RequestParam String userId) {
List<JSONObject> records = voiceService.getVoiceRecords(userId);
return Result.OK(records);
}
/**
* 删除语音生成记录
*/
@DeleteMapping("/deleteVoiceRecord")
public Result<String> deleteVoiceRecord(@RequestParam String userId, @RequestParam String recordId) {
boolean deleted = voiceService.deleteVoiceRecord(userId, recordId);
return deleted ? Result.OK("删除成功") : Result.error("记录不存在");
}
}

View File

@@ -0,0 +1,50 @@
package org.jeecg.modules.airag.voice.service;
import com.alibaba.fastjson2.JSONObject;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.airag.voice.vo.VoiceGenerateVo;
import org.jeecg.modules.airag.voice.vo.VoiceResultVo;
import java.util.List;
/**
* 文生语音服务接口
*/
public interface IVoiceService {
/**
* 文本转语音
* @param vo 请求参数
* @return 生成结果
*/
VoiceResultVo textToSpeech(VoiceGenerateVo vo);
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
/**
* 异步提交语音生成任务,立即返回 taskId
* @param vo 请求参数
* @return taskId
*/
String generateAsync(VoiceGenerateVo vo);
/**
* 查询异步语音任务结果
* @param taskId 任务ID
* @return 结果pending / success / failed
*/
Result<?> getVoiceTaskResult(String taskId);
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
/**
* 查询用户语音生成记录列表
* @return 记录列表
*/
List<JSONObject> getVoiceRecords(String userId);
/**
* 删除用户语音生成记录
* @param userId 用户ID
* @param recordId 记录ID
* @return 是否删除成功
*/
boolean deleteVoiceRecord(String userId, String recordId);
}

View File

@@ -0,0 +1,219 @@
package org.jeecg.modules.airag.voice.service.impl;
import com.alibaba.fastjson2.JSONObject;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.RedisUtil;
import org.jeecg.config.AiChatConfig;
import org.jeecg.config.JeecgBaseConfig;
import org.jeecg.modules.airag.voice.util.VoiceApiHelper;
import org.jeecg.modules.airag.voice.service.IVoiceService;
import org.jeecg.modules.airag.voice.vo.VoiceGenerateVo;
import org.jeecg.modules.airag.voice.vo.VoiceResultVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 文生语音服务实现智谱AI TTS
*/
@Slf4j
@Service
public class VoiceServiceImpl implements IVoiceService {
private static final String REDIS_KEY_PREFIX = "airag:voice:";
private static final String VOICE_TASK_PREFIX = "airag:voice:task:";
private static final long VOICE_TASK_TTL = 3600L;
@Autowired
private AiChatConfig aiChatConfig;
@Autowired
private JeecgBaseConfig jeecgBaseConfig;
@Autowired
private VoiceApiHelper ttsApiHelper;
@Resource
private RedisUtil redisUtil;
@Override
public VoiceResultVo textToSpeech(VoiceGenerateVo vo) {
LoginUser loginUser = null;
try {
loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
} catch (Exception e) {
log.warn("获取登录用户失败", e);
}
return textToSpeechWithUser(vo, loginUser);
}
/**
* 核心 TTS 逻辑,接受显式传入的 loginUser兼容同步调用和异步线程
*/
private VoiceResultVo textToSpeechWithUser(VoiceGenerateVo vo, LoginUser loginUser) {
AiChatConfig.VoiceModelConfig config = aiChatConfig.getAiModelVoice();
// 合并参数前端传值优先未传则取yml默认值
String voice = vo.getVoice() != null ? vo.getVoice() : config.getVoice();
double speed = vo.getSpeed() != null ? vo.getSpeed() : config.getSpeed();
try {
// 准备输出目录
String uploadPath = jeecgBaseConfig.getPath().getUpload();
String bizPath = "voice";
Path outputDir = Paths.get(uploadPath, bizPath);
Files.createDirectories(outputDir);
String fileName = "voice_" + System.currentTimeMillis() + ".wav";
Path audioFile = outputDir.resolve(fileName);
// 调用公共TTS API生成音频
ttsApiHelper.generateAudio(vo.getContent(), audioFile, voice, speed);
// 返回结果
String voiceUrl = bizPath + "/" + fileName;
VoiceResultVo result = new VoiceResultVo();
result.setVoiceUrl(voiceUrl);
result.setFileName(fileName);
// 存入Redis
saveToRedis(vo, fileName, voiceUrl, loginUser);
return result;
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
log.error("语音生成异常", e);
throw new RuntimeException("语音生成异常: " + e.getMessage(), e);
}
}
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
@Override
public String generateAsync(VoiceGenerateVo vo) {
String taskId = UUID.randomUUID().toString().replace("-", "");
String taskKey = VOICE_TASK_PREFIX + taskId;
JSONObject pending = new JSONObject();
pending.put("status", "pending");
redisUtil.set(taskKey, pending.toJSONString(), VOICE_TASK_TTL);
// 在异步线程执行前先获取登录用户,避免子线程中 Shiro 上下文丢失
LoginUser loginUser = null;
try {
loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
} catch (Exception e) {
log.warn("异步语音任务获取登录用户失败", e);
}
final LoginUser capturedUser = loginUser;
CompletableFuture.runAsync(() -> {
JSONObject result = new JSONObject();
try {
VoiceResultVo voiceResult = textToSpeechWithUser(vo, capturedUser);
result.put("status", "success");
result.put("voiceUrl", voiceResult.getVoiceUrl());
result.put("fileName", voiceResult.getFileName());
} catch (Exception e) {
log.error("异步语音生成失败: taskId={}", taskId, e);
result.put("status", "failed");
result.put("message", e.getMessage());
}
redisUtil.set(taskKey, result.toJSONString(), VOICE_TASK_TTL);
});
return taskId;
}
@Override
public Result<?> getVoiceTaskResult(String taskId) {
Object val = redisUtil.get(VOICE_TASK_PREFIX + taskId);
if (val == null) {
return Result.error("任务不存在或已过期");
}
JSONObject task = JSONObject.parseObject(val.toString());
String status = task.getString("status");
if ("success".equals(status)) {
JSONObject data = new JSONObject();
data.put("voiceUrl", task.getString("voiceUrl"));
data.put("fileName", task.getString("fileName"));
return Result.OK(data);
}
if ("failed".equals(status)) {
return Result.error(task.getString("message"));
}
return Result.OK("pending", null);
}
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
@Override
public List<JSONObject> getVoiceRecords(String userId) {
String redisKey = REDIS_KEY_PREFIX + userId;
List<Object> list = redisUtil.lGet(redisKey, 0, -1);
if (list == null || list.isEmpty()) {
return Collections.emptyList();
}
return list.stream()
.map(item -> JSONObject.parseObject(item.toString()))
.collect(java.util.stream.Collectors.toList());
}
@Override
public boolean deleteVoiceRecord(String userId, String recordId) {
String redisKey = REDIS_KEY_PREFIX + userId;
List<Object> list = redisUtil.lGet(redisKey, 0, -1);
if (list == null || list.isEmpty()) {
return false;
}
for (Object item : list) {
String json = item.toString();
JSONObject obj = JSONObject.parseObject(json);
if (recordId.equals(obj.getString("id"))) {
redisUtil.lRemove(redisKey, 1, item);
return true;
}
}
return false;
}
/**
* 将语音生成记录存入Redis
*/
private void saveToRedis(VoiceGenerateVo vo, String fileName, String voiceUrl, LoginUser loginUser) {
try {
if (loginUser == null) {
log.warn("未获取到登录用户跳过Redis存储");
return;
}
String redisKey = REDIS_KEY_PREFIX + loginUser.getId();
JSONObject record = new JSONObject();
record.put("id", UUID.randomUUID().toString().replace("-", ""));
record.put("content", vo.getContent());
record.put("voice", vo.getVoice());
record.put("speed", vo.getSpeed());
record.put("volume", vo.getVolume());
record.put("createTime", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
record.put("fileName", fileName);
record.put("voiceUrl", voiceUrl);
redisUtil.lSet(redisKey, record.toJSONString());
log.info("语音记录已存入Redis列表: key={}", redisKey);
} catch (Exception e) {
log.warn("语音记录存入Redis失败不影响主流程", e);
}
}
}

View File

@@ -0,0 +1,91 @@
package org.jeecg.modules.airag.voice.util;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.config.AiChatConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
/**
* @Description: <p>统一封装 语音 HTTP API 调用逻辑,供语音模块和视频模块复用</p>
*
* @author: wangshuai
* @date: 2026/3/13 16:19
*/
@Slf4j
@Component
public class VoiceApiHelper {
@Autowired
private AiChatConfig aiChatConfig;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
/**
* 调用 TTS API 生成语音文件使用yml默认voice和speed
*
* @param text 要转换的文本
* @param audioPath 音频输出路径
*/
public void generateAudio(String text, Path audioPath) throws IOException, InterruptedException {
AiChatConfig.VoiceModelConfig config = aiChatConfig.getAiModelVoice();
generateAudio(text, audioPath, config.getVoice(), config.getSpeed());
}
/**
* 调用 TTS API 生成语音文件自定义voice和speed
*
* @param text 要转换的文本
* @param audioPath 音频输出路径
* @param voice 声色
* @param speed 语速
*/
public void generateAudio(String text, Path audioPath, String voice, double speed) throws IOException, InterruptedException {
AiChatConfig.VoiceModelConfig config = aiChatConfig.getAiModelVoice();
String apiHost = config.getApiHost();
String url = apiHost.endsWith("/") ? apiHost + "audio/speech" : apiHost + "/audio/speech";
JSONObject body = new JSONObject();
body.put("model", config.getModel());
body.put("input", text);
body.put("voice", voice);
body.put("speed", speed);
body.put("response_format", "wav");
log.info("TTS请求: url={}, model={}, voice={}, speed={}, textLength={}", url, config.getModel(), voice, speed, text.length());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + config.getApiKey())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toJSONString()))
.timeout(Duration.ofSeconds(config.getTimeout()))
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
String errorBody = new String(response.body().readAllBytes());
log.error("TTS API调用失败: status={}, body={}", response.statusCode(), errorBody);
throw new RuntimeException("TTS API调用失败状态码: " + response.statusCode() + "" + errorBody);
}
try (InputStream is = response.body()) {
Files.copy(is, audioPath, StandardCopyOption.REPLACE_EXISTING);
}
log.info("TTS语音已生成: {}", audioPath);
}
}

View File

@@ -0,0 +1,26 @@
package org.jeecg.modules.airag.voice.vo;
import lombok.Data;
/**
* 文生语音请求VO
*/
@Data
public class VoiceGenerateVo {
/**
* 待合成文本(必填)
*/
private String content;
/**
* 声色不传用yml默认值
*/
private String voice;
/**
* 倍速范围0.25~4.0
*/
private Double speed;
/**
* 音量增益(dB)
*/
private Double volume;
}

View File

@@ -0,0 +1,18 @@
package org.jeecg.modules.airag.voice.vo;
import lombok.Data;
/**
* 文生语音响应VO
*/
@Data
public class VoiceResultVo {
/**
* 生成的音频文件相对路径
*/
private String voiceUrl;
/**
* 文件名
*/
private String fileName;
}

View File

@@ -15,8 +15,8 @@ import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.util.AssertUtils;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.airag.wordtpl.dto.WordTplGenDTO;
import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
import org.jeecg.modules.airag.wordtpl.service.IEoaWordTemplateService;
import org.jeecg.modules.airag.wordtpl.entity.AigcWordTemplate;
import org.jeecg.modules.airag.wordtpl.service.IAigcWordTemplateService;
import org.jeecg.modules.airag.wordtpl.utils.WordTplUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -37,12 +37,12 @@ import java.util.Arrays;
* @Version: V1.0
*/
@Tag(name = "word模版管理")
@RestController("eoaWordTemplateController")
@RestController("aigcWordTemplateController")
@RequestMapping("/airag/word")
@Slf4j
public class EoaWordTemplateController extends JeecgController<EoaWordTemplate, IEoaWordTemplateService> {
public class AigcWordTemplateController extends JeecgController<AigcWordTemplate, IAigcWordTemplateService> {
@Autowired
private IEoaWordTemplateService eoaWordTemplateService;
private IAigcWordTemplateService eoaWordTemplateService;
@Autowired
WordTplUtils wordTplUtils;
@@ -58,13 +58,13 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
*/
@Operation(summary = "word模版管理-分页列表查询")
@GetMapping(value = "/list")
public Result<IPage<EoaWordTemplate>> queryPageList(EoaWordTemplate eoaWordTemplate,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<EoaWordTemplate> queryWrapper = QueryGenerator.initQueryWrapper(eoaWordTemplate, req.getParameterMap());
Page<EoaWordTemplate> page = new Page<EoaWordTemplate>(pageNo, pageSize);
IPage<EoaWordTemplate> pageList = eoaWordTemplateService.page(page, queryWrapper);
public Result<IPage<AigcWordTemplate>> queryPageList(AigcWordTemplate eoaWordTemplate,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<AigcWordTemplate> queryWrapper = QueryGenerator.initQueryWrapper(eoaWordTemplate, req.getParameterMap());
Page<AigcWordTemplate> page = new Page<AigcWordTemplate>(pageNo, pageSize);
IPage<AigcWordTemplate> pageList = eoaWordTemplateService.page(page, queryWrapper);
return Result.OK(pageList);
}
@@ -78,10 +78,10 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
@Operation(summary = "word模版管理-添加")
// @RequiresPermissions("wordtpl:template:add")
@PostMapping(value = "/add")
public Result<String> add(@RequestBody EoaWordTemplate eoaWordTemplate) {
public Result<String> add(@RequestBody AigcWordTemplate eoaWordTemplate) {
AssertUtils.assertNotEmpty("参数异常", eoaWordTemplate);
AssertUtils.assertNotEmpty("模版名称不能为空", eoaWordTemplate.getName());
boolean isCodeExists = eoaWordTemplateService.exists(Wrappers.lambdaQuery(EoaWordTemplate.class).eq(EoaWordTemplate::getCode, eoaWordTemplate.getCode()));
boolean isCodeExists = eoaWordTemplateService.exists(Wrappers.lambdaQuery(AigcWordTemplate.class).eq(AigcWordTemplate::getCode, eoaWordTemplate.getCode()));
AssertUtils.assertFalse("模版编码已存在", isCodeExists);
eoaWordTemplateService.save(eoaWordTemplate);
return Result.OK("添加成功!");
@@ -97,7 +97,7 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
@Operation(summary = "word模版管理-编辑")
// @RequiresPermissions("wordtpl:template:edit")
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
public Result<String> edit(@RequestBody EoaWordTemplate eoaWordTemplate) {
public Result<String> edit(@RequestBody AigcWordTemplate eoaWordTemplate) {
AssertUtils.assertNotEmpty("参数异常", eoaWordTemplate);
AssertUtils.assertNotEmpty("模版名称不能为空", eoaWordTemplate.getName());
// 避免编辑时修改编码
@@ -145,8 +145,8 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
//@AutoLog(value = "word模版管理-通过id查询")
@Operation(summary = "word模版管理-通过id查询")
@GetMapping(value = "/queryById")
public Result<EoaWordTemplate> queryById(@RequestParam(name = "id", required = true) String id) {
EoaWordTemplate eoaWordTemplate = eoaWordTemplateService.getById(id);
public Result<AigcWordTemplate> queryById(@RequestParam(name = "id", required = true) String id) {
AigcWordTemplate eoaWordTemplate = eoaWordTemplateService.getById(id);
if (eoaWordTemplate == null) {
return Result.error("未找到对应数据");
}
@@ -164,7 +164,7 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
@GetMapping(value = "/download")
public void downloadTemplate(@RequestParam(name = "id", required = true) String id, HttpServletResponse response) {
AssertUtils.assertNotEmpty("请先选择模版", id);
EoaWordTemplate template = eoaWordTemplateService.getById(id);
AigcWordTemplate template = eoaWordTemplateService.getById(id);
try (ByteArrayOutputStream wordTemplateOut = new ByteArrayOutputStream();
BufferedOutputStream bos = new BufferedOutputStream(response.getOutputStream());) {
wordTplUtils.generateWordTemplate(template, wordTemplateOut);
@@ -195,7 +195,7 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
public Result<?> parseWOrdFile(@RequestParam("file") MultipartFile file) {
try {
InputStream inputStream = file.getInputStream();
EoaWordTemplate eoaWordTemplate = wordTplUtils.parseWordFile(inputStream);
AigcWordTemplate eoaWordTemplate = wordTplUtils.parseWordFile(inputStream);
log.info("解析的模版信息: {}", eoaWordTemplate);
return Result.OK("解析成功", eoaWordTemplate);
} catch (Exception e) {
@@ -214,13 +214,13 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
@PostMapping(value = "/generate/word")
public void generateWord(@RequestBody WordTplGenDTO wordTplGenDTO, HttpServletResponse response) {
AssertUtils.assertNotEmpty("参数异常", wordTplGenDTO);
EoaWordTemplate template ;
AigcWordTemplate template ;
if (oConvertUtils.isNotEmpty(wordTplGenDTO.getTemplateId())) {
template = eoaWordTemplateService.getById(wordTplGenDTO.getTemplateId());
}else{
AssertUtils.assertNotEmpty("请先选择模版", wordTplGenDTO.getTemplateCode());
template = eoaWordTemplateService.getOne(Wrappers.lambdaQuery(EoaWordTemplate.class)
.eq(EoaWordTemplate::getCode, wordTplGenDTO.getTemplateCode()));
template = eoaWordTemplateService.getOne(Wrappers.lambdaQuery(AigcWordTemplate.class)
.eq(AigcWordTemplate::getCode, wordTplGenDTO.getTemplateCode()));
}
AssertUtils.assertNotEmpty("未找到对应的模版", template);

View File

@@ -25,7 +25,7 @@ import java.util.Date;
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@Schema(description = "word模版管理")
public class EoaWordTemplate implements Serializable {
public class AigcWordTemplate implements Serializable {
private static final long serialVersionUID = 1L;
/**

View File

@@ -1,7 +1,7 @@
package org.jeecg.modules.airag.wordtpl.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
import org.jeecg.modules.airag.wordtpl.entity.AigcWordTemplate;
/**
* @Description: word模版管理
@@ -9,6 +9,6 @@ import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
* @Date: 2025-07-04
* @Version: V1.0
*/
public interface EoaWordTemplateMapper extends BaseMapper<EoaWordTemplate> {
public interface AigcWordTemplateMapper extends BaseMapper<AigcWordTemplate> {
}

View File

@@ -1,5 +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.wordtpl.mapper.EoaWordTemplateMapper">
<mapper namespace="org.jeecg.modules.airag.wordtpl.mapper.AigcWordTemplateMapper">
</mapper>

View File

@@ -2,17 +2,17 @@ package org.jeecg.modules.airag.wordtpl.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.airag.wordtpl.dto.WordTplGenDTO;
import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
import org.jeecg.modules.airag.wordtpl.entity.AigcWordTemplate;
import java.io.ByteArrayOutputStream;
/**
/**aigc_word_template
* @Description: word模版管理
* @Author: jeecg-boot
* @Date: 2025-07-04
* @Version: V1.0
*/
public interface IEoaWordTemplateService extends IService<EoaWordTemplate> {
public interface IAigcWordTemplateService extends IService<AigcWordTemplate> {
/**
* 通过模版生成word文档

View File

@@ -8,9 +8,9 @@ import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.system.util.JwtUtil;
import org.jeecg.common.util.AssertUtils;
import org.jeecg.modules.airag.wordtpl.dto.WordTplGenDTO;
import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
import org.jeecg.modules.airag.wordtpl.mapper.EoaWordTemplateMapper;
import org.jeecg.modules.airag.wordtpl.service.IEoaWordTemplateService;
import org.jeecg.modules.airag.wordtpl.entity.AigcWordTemplate;
import org.jeecg.modules.airag.wordtpl.mapper.AigcWordTemplateMapper;
import org.jeecg.modules.airag.wordtpl.service.IAigcWordTemplateService;
import org.jeecg.modules.airag.wordtpl.utils.WordTplUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -25,8 +25,8 @@ import java.util.Map;
* @Version: V1.0
*/
@Slf4j
@Service("eoaWordTemplateService")
public class EoaWordTemplateServiceImpl extends ServiceImpl<EoaWordTemplateMapper, EoaWordTemplate> implements IEoaWordTemplateService {
@Service("aigcWordTemplateService")
public class AigcWordTemplateServiceImpl extends ServiceImpl<AigcWordTemplateMapper, AigcWordTemplate> implements IAigcWordTemplateService {
/**
* 内置的系统变量键列表
@@ -50,7 +50,7 @@ public class EoaWordTemplateServiceImpl extends ServiceImpl<EoaWordTemplateMappe
AssertUtils.assertNotEmpty("模版ID不能为空", wordTplGenDTO.getTemplateId());
String templateId = wordTplGenDTO.getTemplateId();
// 生成word模版 date:2025/7/10
EoaWordTemplate template = getById(templateId);
AigcWordTemplate template = getById(templateId);
ByteArrayOutputStream wordTemplateOut = new ByteArrayOutputStream();
wordTplUtils.generateWordTemplate(template, wordTemplateOut);
//根据word模版和数据生成word文件

View File

@@ -12,7 +12,7 @@ import org.jeecg.common.util.CommonUtils;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.airag.wordtpl.consts.WordTitleEnum;
import org.jeecg.modules.airag.wordtpl.dto.*;
import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
import org.jeecg.modules.airag.wordtpl.entity.AigcWordTemplate;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@@ -57,7 +57,7 @@ public class WordTplUtils {
* @author chenrui
* @date 2025/7/9 11:14
*/
public void generateWordTemplate(EoaWordTemplate template, ByteArrayOutputStream outputStream) {
public void generateWordTemplate(AigcWordTemplate template, ByteArrayOutputStream outputStream) {
AssertUtils.assertNotEmpty("模版数据不能为空", template);
XWPFDocument doc = new XWPFDocument();
@@ -100,7 +100,7 @@ public class WordTplUtils {
* @author chenrui
* @date 2025/7/10 17:52
*/
private static void renderHeaderAndFooter(EoaWordTemplate template, XWPFDocument doc) {
private static void renderHeaderAndFooter(AigcWordTemplate template, XWPFDocument doc) {
//页眉
JSONArray header = JSON.parseArray(template.getHeader());
if (oConvertUtils.isObjectNotEmpty(header)) {
@@ -172,7 +172,7 @@ public class WordTplUtils {
* @author chenrui
* @date 2025/7/4 14:00
*/
private void renderDocumentBody(XWPFDocument doc, EoaWordTemplate template) {
private void renderDocumentBody(XWPFDocument doc, AigcWordTemplate template) {
// TODO author: chenrui for:整理图表???? date:2025/7/4
@@ -337,9 +337,9 @@ public class WordTplUtils {
}
}
public EoaWordTemplate parseWordFile(InputStream wordFileIs) throws Exception {
public AigcWordTemplate parseWordFile(InputStream wordFileIs) throws Exception {
AssertUtils.assertNotEmpty("请上传word文档", wordFileIs);
EoaWordTemplate template = new EoaWordTemplate();
AigcWordTemplate template = new AigcWordTemplate();
XWPFDocument xwpfDocument = new XWPFDocument(wordFileIs);
CTSectPr sectPr = xwpfDocument.getDocument().getBody().getSectPr();
if (sectPr != null) {
@@ -969,7 +969,7 @@ public class WordTplUtils {
public static void main(String[] args) {
EoaWordTemplate template = new EoaWordTemplate();
AigcWordTemplate template = new AigcWordTemplate();
template.setHeight(1123);
template.setWidth(794);
template.setPaperDirection("vertical");

View File

@@ -709,12 +709,20 @@ public class WordUtil {
throw new JeecgBootException(e);
}
} else {
//update-begin---author:liusq ---date:2026-03-30 for[issues/9429]【安全漏洞】修复WordUtil.addImage路径遍历漏洞(CWE-22)-----------
String uploadPath = SpringContextUtils.getApplicationContext()
.getEnvironment()
.getProperty("jeecg.path.upload", "");
// 将本地图片读取到 InputStream
String filePath = uploadPath + File.separator + imageUrl;
in = new FileInputStream(filePath);
// 路径遍历校验规范化后确保文件在uploadPath目录内
File uploadDir = new File(uploadPath).getCanonicalFile();
File targetFile = new File(filePath).getCanonicalFile();
if (!targetFile.toPath().startsWith(uploadDir.toPath())) {
throw new JeecgBootException("非法文件路径,禁止访问上传目录之外的文件: " + imageUrl);
}
in = new FileInputStream(targetFile);
//update-end---author:liusq ---date:2026-03-30 for[issues/9429]【安全漏洞】修复WordUtil.addImage路径遍历漏洞(CWE-22)-----------
}
XWPFRun run = paragraph.createRun();