From 5bdcdf205a19832334165eec3fcbc2d9958f3e91 Mon Sep 17 00:00:00 2001 From: ouhaolan Date: Fri, 20 Mar 2026 08:41:45 +0800 Subject: [PATCH] =?UTF-8?q?1.AI=E5=8A=A9=E6=89=8B=E6=A8=A1=E7=89=88?= =?UTF-8?q?=E3=80=81=E6=A0=B7=E5=BC=8F=E4=BF=AE=E6=94=B9=E6=88=90=E8=B0=A2?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E7=9A=84=E5=AF=B9=E8=AF=9D=E6=A8=A1=E7=89=88?= =?UTF-8?q?=EF=BC=88=E6=9C=89=E7=89=B9=E6=95=88=EF=BC=89=E3=80=82=202.?= =?UTF-8?q?=E7=82=B9=E5=87=BBAI=E5=88=86=E6=9E=90=E5=90=8E=EF=BC=8C?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=BC=B9=E5=87=BA=E7=B1=BB=E4=BC=BC=E5=95=86?= =?UTF-8?q?=E5=93=81=E9=93=BE=E6=8E=A5=E5=BC=95=E5=85=A5=EF=BC=8C=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=82=B9=E5=87=BB=E7=A1=AE=E8=AE=A4=E5=90=8E=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E5=88=86=E6=9E=90=E3=80=82=203.AI=E5=88=86=E6=9E=90?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=96=B0=E5=A2=9E=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E7=BC=96=E8=BE=91=EF=BC=8C=E6=A0=B9=E6=8D=AE=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=EF=BC=9A=E6=A8=A1=E5=9D=97=E7=BC=96=E5=8F=B7?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E8=8E=B7=E5=8F=96=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E6=A8=A1=E5=9D=97=E5=88=86=E6=9E=90=E3=80=82?= =?UTF-8?q?=204.=E5=95=86=E5=93=81=E5=A4=A7=E7=9B=98=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E8=B0=83=E6=95=B4=EF=BC=8C=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E4=B8=AD=E7=9A=84=E5=AD=97=E4=BD=93=E8=B0=83?= =?UTF-8?q?=E5=B0=8F=E4=B8=80=E7=82=B9=EF=BC=8C=E6=96=87=E5=AD=97=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E8=B6=85=E9=95=BF=E4=B8=8D=E6=98=BE=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E9=BC=A0=E6=A0=87=E6=94=BE=E4=B8=8A=E5=8E=BB=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/mysql/ai_image_chat_menu.sql | 4 + sql/mysql/ydoyun_ai_module_prompt.sql | 24 ++ .../admin/aichat/AiChatController.java | 66 ++++++ .../admin/aichat/vo/AiChatStreamReqVO.java | 18 ++ .../AiModulePromptController.java | 57 +++++ .../vo/AiModulePromptRespVO.java | 26 +++ .../vo/AiModulePromptSaveReqVO.java | 29 +++ .../aimoduleprompt/AiModulePromptDO.java | 34 +++ .../aimoduleprompt/AiModulePromptMapper.java | 24 ++ .../service/aichat/DifyAiChatService.java | 20 ++ .../service/aichat/DifyAiChatServiceImpl.java | 218 ++++++++++++++++++ .../aimoduleprompt/AiModulePromptService.java | 36 +++ .../AiModulePromptServiceImpl.java | 65 ++++++ .../src/main/resources/application.yaml | 7 + 14 files changed, 628 insertions(+) create mode 100644 sql/mysql/ai_image_chat_menu.sql create mode 100644 sql/mysql/ydoyun_ai_module_prompt.sql create mode 100644 yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aichat/AiChatController.java create mode 100644 yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aichat/vo/AiChatStreamReqVO.java create mode 100644 yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/AiModulePromptController.java create mode 100644 yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/vo/AiModulePromptRespVO.java create mode 100644 yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/vo/AiModulePromptSaveReqVO.java create mode 100644 yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aimoduleprompt/AiModulePromptDO.java create mode 100644 yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aimoduleprompt/AiModulePromptMapper.java create mode 100644 yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatService.java create mode 100644 yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatServiceImpl.java create mode 100644 yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aimoduleprompt/AiModulePromptService.java create mode 100644 yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aimoduleprompt/AiModulePromptServiceImpl.java diff --git a/sql/mysql/ai_image_chat_menu.sql b/sql/mysql/ai_image_chat_menu.sql new file mode 100644 index 0000000..21b7fd2 --- /dev/null +++ b/sql/mysql/ai_image_chat_menu.sql @@ -0,0 +1,4 @@ +-- AI 图片对话菜单(插入到 AI 大模型 2758 下) +-- 执行前请确认 id 5100 未被占用,若冲突可改为其他值 +INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) +VALUES (5100, 'AI 图片对话', '', 2, 0, 2758, 'image-chat', 'ep:chat-dot-round', 'ydoyun/aichat/index.vue', 'AiImageChat', 0, '1', '1', '1', '1', NOW(), '1', NOW(), '0'); diff --git a/sql/mysql/ydoyun_ai_module_prompt.sql b/sql/mysql/ydoyun_ai_module_prompt.sql new file mode 100644 index 0000000..a1e6c94 --- /dev/null +++ b/sql/mysql/ydoyun_ai_module_prompt.sql @@ -0,0 +1,24 @@ +-- AI 模块提示词配置表:存储各组件模块的 AI 分析提示词,key 为 组件名称:模块编号 +CREATE TABLE IF NOT EXISTS `ydoyun_ai_module_prompt` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `component_name` varchar(64) NOT NULL COMMENT '组件名称,如 ProductDashboard', + `module_name` varchar(128) NOT NULL COMMENT '模块名称,如 KPI指标、供应商表现', + `module_key` varchar(128) NOT NULL COMMENT '组件:模块编号,如 ProductDashboard:kpi', + `prompt` text COMMENT 'AI 提示词,用于定义任务并传入 Dify 分析', + `creator` varchar(64) DEFAULT '' NULL COMMENT '创建者', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间', + `updater` varchar(64) DEFAULT '' NULL COMMENT '更新者', + `update_time` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) DEFAULT b'0' NOT NULL COMMENT '是否删除', + `tenant_id` bigint DEFAULT 0 NULL COMMENT '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tenant_module_key` (`tenant_id`, `module_key`) COMMENT '租户内组件:模块编号唯一', + KEY `idx_component_name` (`component_name`) COMMENT '按组件名称查询', + KEY `idx_tenant_id` (`tenant_id`) COMMENT '按租户查询' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 模块提示词配置表'; + +-- ProductDashboard 可分析模块说明(供 AI 定义任务时列举): +-- ProductDashboard:kpi - KPI 指标卡片 +-- ProductDashboard:supplier - 供应商表现 (Top 10) +-- ProductDashboard:pie - 中类销售排名 Top 5 +-- ProductDashboard:productList - 商品明细列表 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 new file mode 100644 index 0000000..db728bc --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aichat/AiChatController.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.ydoyun.controller.admin.aichat; + +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.ydoyun.service.aichat.DifyAiChatService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +/** + * AI 对话(流式响应) + * 前端直接上传图片文件,后端上传到 Dify 后对话 + * 使用登录用户 ID 作为 Dify user 参数,实现各用户对话隔离 + */ +@Tag(name = "管理后台 - AI 图片对话") +@Slf4j +@RestController +@RequestMapping("/ydoyun/ai-chat") +@RequiredArgsConstructor +public class AiChatController { + + private final DifyAiChatService difyAiChatService; + + @Operation(summary = "流式图片对话") + @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public void streamChat( + @RequestParam("query") String query, + @RequestParam(value = "file", required = false) MultipartFile file, + @RequestParam(value = "task_desc", required = false) String taskDesc, + HttpServletResponse response + ) { + String user = "system"; + try { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + if (userId != null) { + user = "user_" + userId; + } + } catch (Exception e) { + log.debug("获取登录用户失败,使用默认 user: {}", e.getMessage()); + } + 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); + } catch (Exception e) { + log.error("AI 图片对话流式响应异常", e); + try { + String err = "data: {\"error\":\"" + e.getMessage().replace("\"", "\\\"") + "\"}\n\n"; + response.getOutputStream().write(err.getBytes(StandardCharsets.UTF_8)); + response.getOutputStream().flush(); + } catch (Exception ex) { + log.warn("写入错误响应失败", ex); + } + } + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aichat/vo/AiChatStreamReqVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aichat/vo/AiChatStreamReqVO.java new file mode 100644 index 0000000..6d1ba8e --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aichat/vo/AiChatStreamReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.ydoyun.controller.admin.aichat.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Schema(description = "AI 对话流式请求") +@Data +public class AiChatStreamReqVO { + + @Schema(description = "用户输入/问题", requiredMode = Schema.RequiredMode.REQUIRED, example = "你好") + @NotBlank(message = "问题不能为空") + private String query; + + @Schema(description = "图片 URL(上传或粘贴后获取的完整访问地址)") + private String pic; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/AiModulePromptController.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/AiModulePromptController.java new file mode 100644 index 0000000..e001c8c --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/AiModulePromptController.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.ydoyun.controller.admin.aimoduleprompt; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ydoyun.controller.admin.aimoduleprompt.vo.AiModulePromptRespVO; +import cn.iocoder.yudao.module.ydoyun.controller.admin.aimoduleprompt.vo.AiModulePromptSaveReqVO; +import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aimoduleprompt.AiModulePromptDO; +import cn.iocoder.yudao.module.ydoyun.service.aimoduleprompt.AiModulePromptService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * AI 模块提示词配置 Controller + * + * @author 衣朵云 + */ +@Tag(name = "管理后台 - AI 模块提示词") +@RestController +@RequestMapping("/ydoyun/ai-module-prompt") +@Validated +public class AiModulePromptController { + + @Resource + private AiModulePromptService aiModulePromptService; + + @PostMapping("/save") + @Operation(summary = "保存提示词") + public CommonResult savePrompt(@Valid @RequestBody AiModulePromptSaveReqVO reqVO) { + aiModulePromptService.savePrompt(reqVO); + return success(true); + } + + @GetMapping("/list-by-component") + @Operation(summary = "按组件名称查询提示词列表") + @Parameter(name = "componentName", description = "组件名称", required = true, example = "ProductDashboard") + public CommonResult> listByComponent(@RequestParam("componentName") String componentName) { + List list = aiModulePromptService.getListByComponent(componentName); + return success(BeanUtils.toBean(list, AiModulePromptRespVO.class)); + } + + @GetMapping("/map-by-component") + @Operation(summary = "按组件名称查询提示词映射(moduleKey -> prompt)") + @Parameter(name = "componentName", description = "组件名称", required = true, example = "ProductDashboard") + public CommonResult> getPromptMapByComponent(@RequestParam("componentName") String componentName) { + return success(aiModulePromptService.getPromptMapByComponent(componentName)); + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/vo/AiModulePromptRespVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/vo/AiModulePromptRespVO.java new file mode 100644 index 0000000..e865d3b --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/vo/AiModulePromptRespVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.ydoyun.controller.admin.aimoduleprompt.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI 模块提示词 Response VO") +@Data +public class AiModulePromptRespVO { + + @Schema(description = "主键ID", example = "1") + private Long id; + @Schema(description = "组件名称", example = "ProductDashboard") + private String componentName; + @Schema(description = "模块名称", example = "KPI指标") + private String moduleName; + @Schema(description = "组件:模块编号", example = "ProductDashboard:kpi") + private String moduleKey; + @Schema(description = "AI 提示词") + private String prompt; + @Schema(description = "创建时间") + private LocalDateTime createTime; + @Schema(description = "更新时间") + private LocalDateTime updateTime; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/vo/AiModulePromptSaveReqVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/vo/AiModulePromptSaveReqVO.java new file mode 100644 index 0000000..1d8cab5 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aimoduleprompt/vo/AiModulePromptSaveReqVO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.ydoyun.controller.admin.aimoduleprompt.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Schema(description = "管理后台 - AI 模块提示词保存 Request VO") +@Data +public class AiModulePromptSaveReqVO { + + @Schema(description = "主键ID,更新时必填", example = "1") + private Long id; + + @Schema(description = "组件名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "ProductDashboard") + @NotBlank(message = "组件名称不能为空") + private String componentName; + + @Schema(description = "模块名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "KPI指标") + @NotBlank(message = "模块名称不能为空") + private String moduleName; + + @Schema(description = "组件:模块编号,唯一键", requiredMode = Schema.RequiredMode.REQUIRED, example = "ProductDashboard:kpi") + @NotBlank(message = "模块编号不能为空") + private String moduleKey; + + @Schema(description = "AI 提示词") + private String prompt; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aimoduleprompt/AiModulePromptDO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aimoduleprompt/AiModulePromptDO.java new file mode 100644 index 0000000..8b3ea42 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aimoduleprompt/AiModulePromptDO.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.ydoyun.dal.dataobject.aimoduleprompt; + +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import lombok.*; + +/** + * AI 模块提示词配置 DO + * + * @author 衣朵云 + */ +@TableName("ydoyun_ai_module_prompt") +@KeySequence("ydoyun_ai_module_prompt_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiModulePromptDO extends TenantBaseDO { + + @TableId + private Long id; + /** 组件名称,如 ProductDashboard */ + private String componentName; + /** 模块名称,如 KPI指标、供应商表现 */ + private String moduleName; + /** 组件:模块编号,唯一键,如 ProductDashboard:kpi */ + private String moduleKey; + /** AI 提示词 */ + private String prompt; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aimoduleprompt/AiModulePromptMapper.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aimoduleprompt/AiModulePromptMapper.java new file mode 100644 index 0000000..3c93796 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aimoduleprompt/AiModulePromptMapper.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.ydoyun.dal.mysql.aimoduleprompt; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aimoduleprompt.AiModulePromptDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI 模块提示词配置 Mapper + * + * @author 衣朵云 + */ +@Mapper +public interface AiModulePromptMapper extends BaseMapperX { + + default AiModulePromptDO selectByModuleKey(String moduleKey) { + return selectOne(AiModulePromptDO::getModuleKey, moduleKey); + } + + default List selectListByComponentName(String componentName) { + return selectList(AiModulePromptDO::getComponentName, componentName); + } +} 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 new file mode 100644 index 0000000..733682f --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatService.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.ydoyun.service.aichat; + +import org.springframework.web.multipart.MultipartFile; + +/** + * Dify AI 对话服务(流式响应) + */ +public interface DifyAiChatService { + + /** + * 流式发送对话请求,逐字返回 AI 响应 + * + * @param query 用户问题 + * @param file 图片文件(可选,前端直接上传) + * @param taskDesc 任务描述(可选,传给 Dify task_desc 做 AI 分析) + * @param user Dify 用户标识,用于对话隔离(不同用户对话互不污染) + * @param output 输出流,用于写入 SSE 格式的响应 + */ + void streamChat(String query, MultipartFile file, String taskDesc, String user, 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 new file mode 100644 index 0000000..2501a60 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aichat/DifyAiChatServiceImpl.java @@ -0,0 +1,218 @@ +package cn.iocoder.yudao.module.ydoyun.service.aichat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.web.multipart.MultipartFile; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * Dify AI 对话服务实现(流式响应) + * 前端直接上传图片文件,后端上传到 Dify 后对话 + */ +@Slf4j +@Service +public class DifyAiChatServiceImpl implements DifyAiChatService { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Value("${ydoyun.dify.ai-chat.base-url:http://118.253.178.8:8888/v1}") + private String baseUrl; + + @Value("${ydoyun.dify.ai-chat.api-key:app-uch2HPKpicnPgNpJCnQJTehq}") + private String apiKey; + + private final RestTemplate restTemplate = new RestTemplate(); + + @Override + public void streamChat(String query, MultipartFile file, String taskDesc, String user, java.io.OutputStream output) throws Exception { + // 构建请求体 + String base = baseUrl.replaceAll("/$", ""); + String chatUrl = base + "/chat-messages"; + + List> files = new ArrayList<>(); + if (file != null && !file.isEmpty()) { + String uploadFileId = uploadImageToDify(file.getBytes(), file.getOriginalFilename(), user); + if (uploadFileId != null) { + Map fileObj = new HashMap<>(); + fileObj.put("type", "image"); + fileObj.put("transfer_method", "local_file"); + fileObj.put("upload_file_id", uploadFileId); + files.add(fileObj); + } else { + log.warn("图片上传到 Dify 失败,将不携带图片继续请求"); + } + } + HashMap objectObjectHashMap = new HashMap<>(); + objectObjectHashMap.put("pic", files); + if (taskDesc != null && !taskDesc.trim().isEmpty()) { + objectObjectHashMap.put("task_desc", taskDesc.trim()); + } + + Map requestBody = new HashMap<>(); + requestBody.put("query", query); + requestBody.put("inputs", objectObjectHashMap); + if (!files.isEmpty()) { + requestBody.put("files", files); + } + requestBody.put("response_mode", "streaming"); + requestBody.put("user", user); + + String jsonBody = OBJECT_MAPPER.writeValueAsString(requestBody); + log.info("Dify 请求体:{}", jsonBody); + + HttpURLConnection conn = null; + try { + URL url = new URL(chatUrl); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Authorization", "Bearer " + apiKey); + conn.setConnectTimeout(30000); + conn.setReadTimeout(300000); // 5 分钟 + + try (OutputStream os = conn.getOutputStream()) { + os.write(jsonBody.getBytes(StandardCharsets.UTF_8)); + } + + int code = conn.getResponseCode(); + if (code != 200) { + String err = readStream(conn.getErrorStream()); + log.error("Dify 请求失败: {} {}", code, err); + writeSseError(output, "Dify 请求失败: " + code); + return; + } + + // 3. 读取 Dify SSE 流并转发给前端 + int chunkCount = 0; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("data: ")) { + String data = line.substring(6).trim(); + if ("[DONE]".equals(data)) break; + 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")); + } + // 兼容 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++; + } + } + if ("message_end".equals(event)) { + log.info("[Dify] 流式结束, 共转发 {} 个 chunk", chunkCount); + break; + } + if (node.has("code")) { + String errMsg = node.path("message").asText("未知错误"); + log.warn("[Dify] 流式响应错误: code={}, message={}", node.path("code").asText(), errMsg); + writeSseError(output, errMsg); + break; + } + } catch (Exception e) { + log.debug("解析 SSE 行: {}", e.getMessage()); + } + } + } + } + } finally { + if (conn != null) conn.disconnect(); + } + } + + /** + * 上传图片到 Dify,返回 upload_file_id + */ + private String uploadImageToDify(byte[] imageBytes, String originalFilename, String user) { + try { + String ext = "png"; + if (originalFilename != null) { + int dot = originalFilename.lastIndexOf('.'); + if (dot >= 0 && dot < originalFilename.length() - 1) { + ext = originalFilename.substring(dot + 1).toLowerCase(); + } + } + if (!ext.matches("png|jpg|jpeg|webp|gif")) ext = "png"; + + String uploadUrl = baseUrl.replaceAll("/$", "") + "/files/upload"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + headers.set("Authorization", "Bearer " + apiKey); + + final String fileExt = ext; + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", new ByteArrayResource(imageBytes) { + @Override + public String getFilename() { + return "image." + fileExt; + } + }); + body.add("user", user); + + HttpEntity> entity = new HttpEntity<>(body, headers); + ResponseEntity resp = restTemplate.postForEntity(uploadUrl, entity, String.class); + // Dify 上传成功返回 201 CREATED,非 200 OK + if ((resp.getStatusCode() == HttpStatus.OK || resp.getStatusCode() == HttpStatus.CREATED) && resp.getBody() != null) { + JsonNode node = OBJECT_MAPPER.readTree(resp.getBody()); + JsonNode idNode = node.has("id") ? node.get("id") : (node.has("data") && node.get("data").has("id") ? node.get("data").get("id") : null); + if (idNode != null) { + return idNode.asText(); + } + log.warn("[Dify] 上传响应无 id 字段: {}", resp.getBody()); + } else { + log.warn("[Dify] 上传失败: status={}, body={}", resp.getStatusCode(), resp.getBody()); + } + } catch (Exception e) { + log.error("[Dify] 上传图片到 Dify 异常", e); + } + return null; + } + + private String readStream(InputStream is) throws IOException { + if (is == null) return ""; + try (BufferedReader r = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = r.readLine()) != null) sb.append(line); + return sb.toString(); + } + } + + private void writeSseData(OutputStream out, Map data) throws IOException { + try { + String json = OBJECT_MAPPER.writeValueAsString(data); + out.write(("data: " + json + "\n\n").getBytes(StandardCharsets.UTF_8)); + out.flush(); + } catch (Exception e) { + log.debug("写入 SSE: {}", e.getMessage()); + } + } + + private void writeSseError(OutputStream out, String msg) throws IOException { + Map errData = new HashMap(); + errData.put("error", msg); + writeSseData(out, errData); + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aimoduleprompt/AiModulePromptService.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aimoduleprompt/AiModulePromptService.java new file mode 100644 index 0000000..95a1049 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aimoduleprompt/AiModulePromptService.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.ydoyun.service.aimoduleprompt; + +import cn.iocoder.yudao.module.ydoyun.controller.admin.aimoduleprompt.vo.AiModulePromptSaveReqVO; +import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aimoduleprompt.AiModulePromptDO; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +/** + * AI 模块提示词配置 Service + * + * @author 衣朵云 + */ +public interface AiModulePromptService { + + /** + * 保存提示词(按 moduleKey 存在则更新,否则新增) + */ + void savePrompt(@Valid AiModulePromptSaveReqVO reqVO); + + /** + * 按组件名称查询所有模块提示词,返回 moduleKey -> prompt 映射 + */ + Map getPromptMapByComponent(String componentName); + + /** + * 按组件名称查询所有模块提示词列表 + */ + List getListByComponent(String componentName); + + /** + * 按 moduleKey 查询 + */ + AiModulePromptDO getByModuleKey(String moduleKey); +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aimoduleprompt/AiModulePromptServiceImpl.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aimoduleprompt/AiModulePromptServiceImpl.java new file mode 100644 index 0000000..670f762 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aimoduleprompt/AiModulePromptServiceImpl.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.ydoyun.service.aimoduleprompt; + +import cn.iocoder.yudao.module.ydoyun.controller.admin.aimoduleprompt.vo.AiModulePromptSaveReqVO; +import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aimoduleprompt.AiModulePromptDO; +import cn.iocoder.yudao.module.ydoyun.dal.mysql.aimoduleprompt.AiModulePromptMapper; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * AI 模块提示词配置 Service 实现类 + * + * @author 衣朵云 + */ +@Service +@Validated +public class AiModulePromptServiceImpl implements AiModulePromptService { + + @Resource + private AiModulePromptMapper aiModulePromptMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public void savePrompt(AiModulePromptSaveReqVO reqVO) { + AiModulePromptDO existing = aiModulePromptMapper.selectByModuleKey(reqVO.getModuleKey()); + AiModulePromptDO entity = BeanUtils.toBean(reqVO, AiModulePromptDO.class); + if (existing != null) { + entity.setId(existing.getId()); + aiModulePromptMapper.updateById(entity); + } else { + aiModulePromptMapper.insert(entity); + } + } + + @Override + public Map getPromptMapByComponent(String componentName) { + List list = aiModulePromptMapper.selectListByComponentName(componentName); + Map map = new HashMap<>(); + if (list != null) { + for (AiModulePromptDO d : list) { + if (d.getPrompt() != null && !d.getPrompt().isEmpty()) { + map.put(d.getModuleKey(), d.getPrompt()); + } + } + } + return map; + } + + @Override + public List getListByComponent(String componentName) { + return aiModulePromptMapper.selectListByComponentName(componentName); + } + + @Override + public AiModulePromptDO getByModuleKey(String moduleKey) { + return aiModulePromptMapper.selectByModuleKey(moduleKey); + } +} diff --git a/yudao-server/src/main/resources/application.yaml b/yudao-server/src/main/resources/application.yaml index a0661cc..707c188 100644 --- a/yudao-server/src/main/resources/application.yaml +++ b/yudao-server/src/main/resources/application.yaml @@ -363,4 +363,11 @@ ydoyun: base-url: http://118.253.178.8:5001/v1/chat-messages api-key1: app-a6GWULbcg8zkv8g3VVLPARPz api-key2: app-xc2JwxgwqTRZWybnkAgyLZQV + # AI 图片对话(独立应用,流式响应) + ai-chat: + base-url: http://118.253.178.8:5001/v1 + api-key: app-uch2HPKpicnPgNpJCnQJTehq + pic-param: pic # Dify 应用中的图片参数名 + pic-format: object # object=单对象 | array=数组 | id=仅 upload_file_id 字符串,若 AI 看不到图可切换尝试 + image-payload-mode: both # both=同时传 inputs+files | inputs=只传 inputs.pic | files=只传顶层 files