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

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

View File

@@ -10,11 +10,12 @@ public interface DifyAiChatService {
/**
* 流式发送对话请求,逐字返回 AI 响应
*
* @param query 用户问题
* @param file 图片文件(可选,前端直接上传)
* @param taskDesc 任务描述(可选,传给 Dify task_desc 做 AI 分析)
* @param user Dify 用户标识,用于对话隔离(不同用户对话互不污染)
* @param output 输出流,用于写入 SSE 格式的响应
* @param query 用户问题
* @param file 图片文件(可选,前端直接上传)
* @param taskDesc 任务描述(可选,传给 Dify task_desc 做 AI 分析)
* @param user Dify 用户标识,用于对话隔离(不同用户对话互不污染)
* @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;
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.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
@@ -12,6 +16,7 @@ import org.springframework.web.client.RestTemplate;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
@@ -34,10 +39,13 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
@Value("${ydoyun.dify.ai-chat.api-key:app-uch2HPKpicnPgNpJCnQJTehq}")
private String apiKey;
@Resource
private TenantService tenantService;
private final RestTemplate restTemplate = new RestTemplate();
@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 chatUrl = base + "/chat-messages";
@@ -60,6 +68,14 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
if (taskDesc != null && !taskDesc.trim().isEmpty()) {
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<>();
requestBody.put("query", query);
@@ -69,6 +85,12 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
}
requestBody.put("response_mode", "streaming");
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);
log.info("Dify 请求体:{}", jsonBody);
@@ -96,8 +118,11 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
return;
}
// 3. 读取 Dify SSE 流并转发给前端
int chunkCount = 0;
// 3. 读取 Dify SSE 流并转发给前端(仅转发实际回答内容,过滤 <think> 和 非 box 内容)
StringBuilder fullBuffer = new StringBuilder();
int lastForwardedLen = 0;
boolean boxStarted = false;
String returnedConversationId = null;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
@@ -108,21 +133,42 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
try {
JsonNode node = OBJECT_MAPPER.readTree(data);
String event = node.path("event").asText("");
if (chunkCount < 3) {
log.info("[Dify] SSE event: event={}, hasAnswer={}", event, node.has("answer"));
// 提取 conversation_id 用于多轮对话Dify 在 message / message_end 等事件中返回)
if (node.has("conversation_id")) {
String cid = node.get("conversation_id").asText();
if (StringUtils.isNotBlank(cid)) {
returnedConversationId = cid;
}
}
// 兼容 message / agent_message 事件,均含 answer 增量
if (node.has("answer")) {
String answer = node.get("answer").asText();
if (answer != null && !answer.isEmpty()) {
Map<String, Object> chunk = new HashMap<String, Object>();
chunk.put("text", answer);
writeSseData(output, chunk);
chunkCount++;
fullBuffer.append(answer);
String bufStr = fullBuffer.toString();
// 当 box 标记首次出现时,重置转发位置,避免之前发出的残缺前缀占位导致 box 内容被截断
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)) {
log.info("[Dify] 流式结束, 共转发 {} 个 chunk", chunkCount);
log.info("[Dify] 流式结束, conversation_id={}, 原始数据={}", returnedConversationId, data);
break;
}
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 {
if (conn != null) conn.disconnect();
}
@@ -215,4 +270,55 @@ public class DifyAiChatServiceImpl implements DifyAiChatService {
errData.put("error", msg);
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.security.core.LoginUser;
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.service.user.AdminUserService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
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.reportpage.vo.ProcedureRequestVO;
@@ -40,7 +40,7 @@ public class ReportPageService {
private final DiffHttpClient diffHttpClient;
private final StringRedisTemplate redisTemplate;
private final AiDailyReportService aiDailyReportService;
private final AdminUserService userService;
private final AdminUserApi adminUserApi;
@Resource
private ReportMapper reportMapper;
@@ -284,8 +284,11 @@ public class ReportPageService {
public Object executeTable(Long reportId, String tableName) {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
AdminUserDO user = userService.getUser(loginUser.getId());
String username = user.getUsername();
AdminUserRespDTO user = adminUserApi.getUser(loginUser.getId());
String username = user != null ? user.getUsername() : null;
if (username == null) {
username = "";
}
ReportDatabaseDO reportDatabase =
reportMapper.selectReportDatabaseByReportId(reportId);