1.加入ai对话框 模块日报功能
This commit is contained in:
@@ -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.permission.PermissionCommonApi;
|
||||
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.system.api.permission.PermissionApi;
|
||||
import org.jeecg.modules.jmreport.api.JmReportTokenServiceI;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
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);
|
||||
}
|
||||
|
||||
@Bean // 暂时注释:可以按需实现后打开
|
||||
@Primary
|
||||
public JmOnlDragExternalServiceImpl jmOnlDragExternalService2() {
|
||||
return new JmOnlDragExternalServiceImpl();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ public class AdminUserRespDTO {
|
||||
* 用户ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 用户账号
|
||||
*/
|
||||
private String username;
|
||||
/**
|
||||
* 用户昵称
|
||||
*/
|
||||
|
||||
@@ -49,6 +49,9 @@ public class TenantRespVO {
|
||||
@Schema(description = "账号数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
private Integer accountCount;
|
||||
|
||||
@Schema(description = "租户提示词", example = "")
|
||||
private String tenantPrompt;
|
||||
|
||||
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@ExcelProperty("创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@@ -50,6 +50,9 @@ public class TenantSaveReqVO {
|
||||
@NotNull(message = "账号数量不能为空")
|
||||
private Integer accountCount;
|
||||
|
||||
@Schema(description = "租户提示词", example = "")
|
||||
private String tenantPrompt;
|
||||
|
||||
// ========== 仅【创建】时,需要传递的字段 ==========
|
||||
|
||||
@Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "yudao")
|
||||
|
||||
@@ -85,5 +85,9 @@ public class TenantDO extends BaseDO {
|
||||
* 账号数量
|
||||
*/
|
||||
private Integer accountCount;
|
||||
/**
|
||||
* 租户提示词
|
||||
*/
|
||||
private String tenantPrompt;
|
||||
|
||||
}
|
||||
|
||||
@@ -394,6 +394,7 @@ CREATE TABLE IF NOT EXISTS "system_tenant" (
|
||||
"package_id" bigint NOT NULL,
|
||||
"expire_time" timestamp NOT NULL,
|
||||
"account_count" int NOT NULL,
|
||||
"tenant_prompt" varchar(2000) DEFAULT '',
|
||||
"creator" varchar(64) DEFAULT '',
|
||||
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" varchar(64) DEFAULT '',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -14,7 +14,8 @@ public interface DifyAiChatService {
|
||||
* @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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
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", answer);
|
||||
chunk.put("text", toForward);
|
||||
writeSseData(output, chunk);
|
||||
chunkCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user