diff --git a/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/config/JmReportConfiguration.java b/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/config/JmReportConfiguration.java index 7f259cf..269f523 100644 --- a/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/config/JmReportConfiguration.java +++ b/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/config/JmReportConfiguration.java @@ -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(); - } - } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/dto/AdminUserRespDTO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/dto/AdminUserRespDTO.java index d86f3e5..f02c61c 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/dto/AdminUserRespDTO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/dto/AdminUserRespDTO.java @@ -17,6 +17,10 @@ public class AdminUserRespDTO { * 用户ID */ private Long id; + /** + * 用户账号 + */ + private String username; /** * 用户昵称 */ diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantRespVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantRespVO.java index dd91812..68e2934 100755 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantRespVO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantRespVO.java @@ -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; diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantSaveReqVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantSaveReqVO.java index b7012e0..6d21b36 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantSaveReqVO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantSaveReqVO.java @@ -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") diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/tenant/TenantDO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/tenant/TenantDO.java index b75a602..34f9491 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/tenant/TenantDO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/tenant/TenantDO.java @@ -85,5 +85,9 @@ public class TenantDO extends BaseDO { * 账号数量 */ private Integer accountCount; + /** + * 租户提示词 + */ + private String tenantPrompt; } diff --git a/yudao-module-system/src/test/resources/sql/create_tables.sql b/yudao-module-system/src/test/resources/sql/create_tables.sql index 124b307..e507209 100644 --- a/yudao-module-system/src/test/resources/sql/create_tables.sql +++ b/yudao-module-system/src/test/resources/sql/create_tables.sql @@ -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 '', diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aichat/AiChatController.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aichat/AiChatController.java index db728bc..b947552 100644 --- a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aichat/AiChatController.java +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aichat/AiChatController.java @@ -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 { diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatService.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatService.java index 733682f..664ab8b 100644 --- a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatService.java +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatService.java @@ -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; } diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatServiceImpl.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatServiceImpl.java index 2501a60..b610ef1 100644 --- a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatServiceImpl.java +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatServiceImpl.java @@ -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 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 流并转发给前端(仅转发实际回答内容,过滤 和 非 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 chunk = new HashMap(); - 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 chunk = new HashMap(); + 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 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) ... 块 2) 仅保留 <|begin_of_box|>...<|end_of_box|> 之间的内容 + * 若没有 box 标记,则返回 之后的内容 + */ + private String extractDisplayAnswer(String raw) { + if (raw == null || raw.isEmpty()) return ""; + String s = raw; + // 1. 移除 ... 块(含标签) + int thinkStart = s.indexOf(""); + if (thinkStart >= 0) { + int thinkEnd = s.indexOf("", thinkStart); + if (thinkEnd >= 0) { + s = s.substring(0, thinkStart) + s.substring(thinkEnd + 8); + } else { + // 未闭合,不展示 + 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 则 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; + } } diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/reportpage/ReportPageService.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/reportpage/ReportPageService.java index 9ca48e1..4210c7e 100644 --- a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/reportpage/ReportPageService.java +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/reportpage/ReportPageService.java @@ -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);