1.加入ai对话框 模块日报功能

This commit is contained in:
2026-03-25 08:40:33 +08:00
parent 5bdcdf205a
commit 6b22839ca1
10 changed files with 149 additions and 31 deletions

View File

@@ -3,14 +3,11 @@ package cn.iocoder.yudao.module.report.framework.jmreport.config;
import cn.iocoder.yudao.framework.common.biz.system.oauth2.OAuth2TokenCommonApi; import cn.iocoder.yudao.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi; import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi;
import cn.iocoder.yudao.framework.security.config.SecurityProperties; import cn.iocoder.yudao.framework.security.config.SecurityProperties;
import cn.iocoder.yudao.module.report.framework.jmreport.core.service.JmOnlDragExternalServiceImpl;
import cn.iocoder.yudao.module.report.framework.jmreport.core.service.JmReportTokenServiceImpl; import cn.iocoder.yudao.module.report.framework.jmreport.core.service.JmReportTokenServiceImpl;
import cn.iocoder.yudao.module.system.api.permission.PermissionApi;
import org.jeecg.modules.jmreport.api.JmReportTokenServiceI; import org.jeecg.modules.jmreport.api.JmReportTokenServiceI;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/** /**
* 积木报表的配置类 * 积木报表的配置类
@@ -28,10 +25,4 @@ public class JmReportConfiguration {
return new JmReportTokenServiceImpl(oAuth2TokenApi, permissionApi, securityProperties); return new JmReportTokenServiceImpl(oAuth2TokenApi, permissionApi, securityProperties);
} }
@Bean // 暂时注释:可以按需实现后打开
@Primary
public JmOnlDragExternalServiceImpl jmOnlDragExternalService2() {
return new JmOnlDragExternalServiceImpl();
}
} }

View File

@@ -17,6 +17,10 @@ public class AdminUserRespDTO {
* 用户ID * 用户ID
*/ */
private Long id; private Long id;
/**
* 用户账号
*/
private String username;
/** /**
* 用户昵称 * 用户昵称
*/ */

View File

@@ -49,6 +49,9 @@ public class TenantRespVO {
@Schema(description = "账号数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") @Schema(description = "账号数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Integer accountCount; private Integer accountCount;
@Schema(description = "租户提示词", example = "")
private String tenantPrompt;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
@ExcelProperty("创建时间") @ExcelProperty("创建时间")
private LocalDateTime createTime; private LocalDateTime createTime;

View File

@@ -50,6 +50,9 @@ public class TenantSaveReqVO {
@NotNull(message = "账号数量不能为空") @NotNull(message = "账号数量不能为空")
private Integer accountCount; private Integer accountCount;
@Schema(description = "租户提示词", example = "")
private String tenantPrompt;
// ========== 仅【创建】时,需要传递的字段 ========== // ========== 仅【创建】时,需要传递的字段 ==========
@Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "yudao") @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "yudao")

View File

@@ -85,5 +85,9 @@ public class TenantDO extends BaseDO {
* 账号数量 * 账号数量
*/ */
private Integer accountCount; private Integer accountCount;
/**
* 租户提示词
*/
private String tenantPrompt;
} }

View File

@@ -394,6 +394,7 @@ CREATE TABLE IF NOT EXISTS "system_tenant" (
"package_id" bigint NOT NULL, "package_id" bigint NOT NULL,
"expire_time" timestamp NOT NULL, "expire_time" timestamp NOT NULL,
"account_count" int NOT NULL, "account_count" int NOT NULL,
"tenant_prompt" varchar(2000) DEFAULT '',
"creator" varchar(64) DEFAULT '', "creator" varchar(64) DEFAULT '',
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" varchar(64) DEFAULT '', "updater" varchar(64) DEFAULT '',

View File

@@ -34,6 +34,7 @@ public class AiChatController {
@RequestParam("query") String query, @RequestParam("query") String query,
@RequestParam(value = "file", required = false) MultipartFile file, @RequestParam(value = "file", required = false) MultipartFile file,
@RequestParam(value = "task_desc", required = false) String taskDesc, @RequestParam(value = "task_desc", required = false) String taskDesc,
@RequestParam(value = "conversation_id", required = false) String conversationId,
HttpServletResponse response HttpServletResponse response
) { ) {
String user = "system"; String user = "system";
@@ -45,13 +46,14 @@ public class AiChatController {
} catch (Exception e) { } catch (Exception e) {
log.debug("获取登录用户失败,使用默认 user: {}", e.getMessage()); log.debug("获取登录用户失败,使用默认 user: {}", e.getMessage());
} }
log.info("[AiChat] 收到请求 query={}, conversation_id={}", query, conversationId);
response.setContentType("text/event-stream;charset=UTF-8"); response.setContentType("text/event-stream;charset=UTF-8");
response.setHeader("Cache-Control", "no-cache"); response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive"); response.setHeader("Connection", "keep-alive");
response.setHeader("X-Accel-Buffering", "no"); response.setHeader("X-Accel-Buffering", "no");
try (OutputStream out = response.getOutputStream()) { try (OutputStream out = response.getOutputStream()) {
difyAiChatService.streamChat(query, file, taskDesc, user, out); difyAiChatService.streamChat(query, file, taskDesc, user, conversationId, out);
} catch (Exception e) { } catch (Exception e) {
log.error("AI 图片对话流式响应异常", e); log.error("AI 图片对话流式响应异常", e);
try { try {

View File

@@ -10,11 +10,12 @@ public interface DifyAiChatService {
/** /**
* 流式发送对话请求,逐字返回 AI 响应 * 流式发送对话请求,逐字返回 AI 响应
* *
* @param query 用户问题 * @param query 用户问题
* @param file 图片文件(可选,前端直接上传) * @param file 图片文件(可选,前端直接上传)
* @param taskDesc 任务描述(可选,传给 Dify task_desc 做 AI 分析) * @param taskDesc 任务描述(可选,传给 Dify task_desc 做 AI 分析)
* @param user Dify 用户标识,用于对话隔离(不同用户对话互不污染) * @param user Dify 用户标识,用于对话隔离(不同用户对话互不污染)
* @param output 输出流,用于写入 SSE 格式的响应 * @param conversationId 会话 ID可选续接多轮对话首次不传后续传入 Dify 返回的 conversation_id
* @param output 输出流,用于写入 SSE 格式的响应
*/ */
void streamChat(String query, MultipartFile file, String taskDesc, String user, java.io.OutputStream output) throws Exception; void streamChat(String query, MultipartFile file, String taskDesc, String user, String conversationId, java.io.OutputStream output) throws Exception;
} }

View File

@@ -1,8 +1,12 @@
package cn.iocoder.yudao.module.ydoyun.service.aichat; package cn.iocoder.yudao.module.ydoyun.service.aichat;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.system.dal.dataobject.tenant.TenantDO;
import cn.iocoder.yudao.module.system.service.tenant.TenantService;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*; import org.springframework.http.*;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -12,6 +16,7 @@ import org.springframework.web.client.RestTemplate;
import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ByteArrayResource;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.*; import java.io.*;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
@@ -34,10 +39,13 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
@Value("${ydoyun.dify.ai-chat.api-key:app-uch2HPKpicnPgNpJCnQJTehq}") @Value("${ydoyun.dify.ai-chat.api-key:app-uch2HPKpicnPgNpJCnQJTehq}")
private String apiKey; private String apiKey;
@Resource
private TenantService tenantService;
private final RestTemplate restTemplate = new RestTemplate(); private final RestTemplate restTemplate = new RestTemplate();
@Override @Override
public void streamChat(String query, MultipartFile file, String taskDesc, String user, java.io.OutputStream output) throws Exception { public void streamChat(String query, MultipartFile file, String taskDesc, String user, String conversationId, java.io.OutputStream output) throws Exception {
// 构建请求体 // 构建请求体
String base = baseUrl.replaceAll("/$", ""); String base = baseUrl.replaceAll("/$", "");
String chatUrl = base + "/chat-messages"; String chatUrl = base + "/chat-messages";
@@ -60,6 +68,14 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
if (taskDesc != null && !taskDesc.trim().isEmpty()) { if (taskDesc != null && !taskDesc.trim().isEmpty()) {
objectObjectHashMap.put("task_desc", taskDesc.trim()); objectObjectHashMap.put("task_desc", taskDesc.trim());
} }
// 获取当前租户的 tenant_prompt 一并传入 Dify
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId != null) {
TenantDO tenant = tenantService.getTenant(tenantId);
if (tenant != null && StringUtils.isNotBlank(tenant.getTenantPrompt())) {
objectObjectHashMap.put("tenant_prompt", tenant.getTenantPrompt().trim());
}
}
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("query", query); requestBody.put("query", query);
@@ -69,6 +85,12 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
} }
requestBody.put("response_mode", "streaming"); requestBody.put("response_mode", "streaming");
requestBody.put("user", user); requestBody.put("user", user);
if (StringUtils.isNotBlank(conversationId)) {
requestBody.put("conversation_id", conversationId.trim());
log.info("[Dify] 续接多轮对话conversation_id={}", conversationId.trim());
} else {
log.info("[Dify] 首次对话,未传 conversation_id");
}
String jsonBody = OBJECT_MAPPER.writeValueAsString(requestBody); String jsonBody = OBJECT_MAPPER.writeValueAsString(requestBody);
log.info("Dify 请求体:{}", jsonBody); log.info("Dify 请求体:{}", jsonBody);
@@ -96,8 +118,11 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
return; return;
} }
// 3. 读取 Dify SSE 流并转发给前端 // 3. 读取 Dify SSE 流并转发给前端(仅转发实际回答内容,过滤 <think> 和 非 box 内容)
int chunkCount = 0; StringBuilder fullBuffer = new StringBuilder();
int lastForwardedLen = 0;
boolean boxStarted = false;
String returnedConversationId = null;
try (BufferedReader reader = new BufferedReader( try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line; String line;
@@ -108,21 +133,42 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
try { try {
JsonNode node = OBJECT_MAPPER.readTree(data); JsonNode node = OBJECT_MAPPER.readTree(data);
String event = node.path("event").asText(""); String event = node.path("event").asText("");
if (chunkCount < 3) { // 提取 conversation_id 用于多轮对话Dify 在 message / message_end 等事件中返回)
log.info("[Dify] SSE event: event={}, hasAnswer={}", event, node.has("answer")); if (node.has("conversation_id")) {
String cid = node.get("conversation_id").asText();
if (StringUtils.isNotBlank(cid)) {
returnedConversationId = cid;
}
} }
// 兼容 message / agent_message 事件,均含 answer 增量 // 兼容 message / agent_message 事件,均含 answer 增量
if (node.has("answer")) { if (node.has("answer")) {
String answer = node.get("answer").asText(); String answer = node.get("answer").asText();
if (answer != null && !answer.isEmpty()) { if (answer != null && !answer.isEmpty()) {
Map<String, Object> chunk = new HashMap<String, Object>(); fullBuffer.append(answer);
chunk.put("text", answer); String bufStr = fullBuffer.toString();
writeSseData(output, chunk); // 当 box 标记首次出现时,重置转发位置,避免之前发出的残缺前缀占位导致 box 内容被截断
chunkCount++; boolean nowBoxStarted = bufStr.contains("<|begin_of_box|>");
if (nowBoxStarted && !boxStarted) {
boxStarted = true;
lastForwardedLen = 0;
}
String displayText = extractDisplayAnswer(bufStr);
// 移除末尾可能是 <|begin_of_box|> 残缺前缀的部分,避免把不完整的 token 发给前端
String safeText = removeTrailingPartialToken(displayText, "<|begin_of_box|>");
safeText = removeTrailingPartialToken(safeText, "<|end_of_box|>");
if (safeText.length() > lastForwardedLen) {
String toForward = safeText.substring(lastForwardedLen);
lastForwardedLen = safeText.length();
if (!toForward.isEmpty()) {
Map<String, Object> chunk = new HashMap<String, Object>();
chunk.put("text", toForward);
writeSseData(output, chunk);
}
}
} }
} }
if ("message_end".equals(event)) { if ("message_end".equals(event)) {
log.info("[Dify] 流式结束, 共转发 {} 个 chunk", chunkCount); log.info("[Dify] 流式结束, conversation_id={}, 原始数据={}", returnedConversationId, data);
break; break;
} }
if (node.has("code")) { if (node.has("code")) {
@@ -137,6 +183,15 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
} }
} }
} }
// 将 conversation_id 返回给前端,用于后续多轮对话
if (returnedConversationId != null) {
log.info("[Dify] 返回 conversation_id 给前端: {}", returnedConversationId);
Map<String, Object> convChunk = new HashMap<>();
convChunk.put("conversation_id", returnedConversationId);
writeSseData(output, convChunk);
} else {
log.warn("[Dify] Dify 响应中未包含 conversation_id多轮对话将无法续接");
}
} finally { } finally {
if (conn != null) conn.disconnect(); if (conn != null) conn.disconnect();
} }
@@ -215,4 +270,55 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
errData.put("error", msg); errData.put("error", msg);
writeSseData(out, errData); writeSseData(out, errData);
} }
/**
* 从 Dify 原始输出中提取需要展示给用户的回答内容。
* 过滤1) <think>...</think> 块 2) 仅保留 <|begin_of_box|>...<|end_of_box|> 之间的内容
* 若没有 box 标记,则返回 </think> 之后的内容
*/
private String extractDisplayAnswer(String raw) {
if (raw == null || raw.isEmpty()) return "";
String s = raw;
// 1. 移除 <think>...</think> 块(含标签)
int thinkStart = s.indexOf("<think>");
if (thinkStart >= 0) {
int thinkEnd = s.indexOf("</think>", thinkStart);
if (thinkEnd >= 0) {
s = s.substring(0, thinkStart) + s.substring(thinkEnd + 8);
} else {
// </think> 未闭合,不展示
return "";
}
}
// 2. 优先取 <|begin_of_box|>...<|end_of_box|> 之间的内容
String begin = "<|begin_of_box|>";
String end = "<|end_of_box|>";
int boxStart = s.indexOf(begin);
if (boxStart >= 0) {
int boxEnd = s.indexOf(end, boxStart);
if (boxEnd >= 0) {
return s.substring(boxStart + begin.length(), boxEnd).trim();
}
// 只有 begin 未闭合,返回 begin 之后的内容
return s.substring(boxStart + begin.length()).trim();
}
// 3. 无 box 时,返回 </think> 之后的内容(若已移除 think 则 s 已是后续内容)
return s.trim();
}
/**
* 若字符串末尾是 token 的某个前缀(即 token 正在逐字符累积中),则将其去除。
* 例如s="Hello <|beg", token="<|begin_of_box|>" → 返回 "Hello "
* 避免把尚未完整的特殊标记字符发给前端占用转发位置。
*/
private String removeTrailingPartialToken(String s, String token) {
if (s == null || s.isEmpty() || token == null || token.isEmpty()) return s;
int maxLen = Math.min(s.length(), token.length() - 1);
for (int len = maxLen; len > 0; len--) {
if (s.endsWith(token.substring(0, len))) {
return s.substring(0, s.length() - len);
}
}
return s;
}
} }

View File

@@ -3,8 +3,8 @@ package cn.iocoder.yudao.module.ydoyun.service.reportpage;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.security.core.LoginUser; import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.service.user.AdminUserService; import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import cn.iocoder.yudao.module.ydoyun.config.ProcedureHttpClient; import cn.iocoder.yudao.module.ydoyun.config.ProcedureHttpClient;
import cn.iocoder.yudao.module.ydoyun.controller.admin.reportdatabase.vo.ReportDatabaseRespVO; import cn.iocoder.yudao.module.ydoyun.controller.admin.reportdatabase.vo.ReportDatabaseRespVO;
import cn.iocoder.yudao.module.ydoyun.controller.admin.reportpage.vo.ProcedureRequestVO; import cn.iocoder.yudao.module.ydoyun.controller.admin.reportpage.vo.ProcedureRequestVO;
@@ -40,7 +40,7 @@ public class ReportPageService {
private final DiffHttpClient diffHttpClient; private final DiffHttpClient diffHttpClient;
private final StringRedisTemplate redisTemplate; private final StringRedisTemplate redisTemplate;
private final AiDailyReportService aiDailyReportService; private final AiDailyReportService aiDailyReportService;
private final AdminUserService userService; private final AdminUserApi adminUserApi;
@Resource @Resource
private ReportMapper reportMapper; private ReportMapper reportMapper;
@@ -284,8 +284,11 @@ public class ReportPageService {
public Object executeTable(Long reportId, String tableName) { public Object executeTable(Long reportId, String tableName) {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
AdminUserDO user = userService.getUser(loginUser.getId()); AdminUserRespDTO user = adminUserApi.getUser(loginUser.getId());
String username = user.getUsername(); String username = user != null ? user.getUsername() : null;
if (username == null) {
username = "";
}
ReportDatabaseDO reportDatabase = ReportDatabaseDO reportDatabase =
reportMapper.selectReportDatabaseByReportId(reportId); reportMapper.selectReportDatabaseByReportId(reportId);