1.AI助手模版、样式修改成谢提供的对话模版(有特效)。
2.点击AI分析后,对话弹出类似商品链接引入,用户点击确认后进行分析。 3.AI分析对话新增提示词编辑,根据组件名称:模块编号进行获取提示词进行模块分析。 4.商品大盘首页字体调整,数据卡片中的字体调小一点,文字描述超长不显示,鼠标放上去显示全量。
This commit is contained in:
4
sql/mysql/ai_image_chat_menu.sql
Normal file
4
sql/mysql/ai_image_chat_menu.sql
Normal file
@@ -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');
|
||||||
24
sql/mysql/ydoyun_ai_module_prompt.sql
Normal file
24
sql/mysql/ydoyun_ai_module_prompt.sql
Normal file
@@ -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 - 商品明细列表
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Boolean> 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<List<AiModulePromptRespVO>> listByComponent(@RequestParam("componentName") String componentName) {
|
||||||
|
List<AiModulePromptDO> 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<Map<String, String>> getPromptMapByComponent(@RequestParam("componentName") String componentName) {
|
||||||
|
return success(aiModulePromptService.getPromptMapByComponent(componentName));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<AiModulePromptDO> {
|
||||||
|
|
||||||
|
default AiModulePromptDO selectByModuleKey(String moduleKey) {
|
||||||
|
return selectOne(AiModulePromptDO::getModuleKey, moduleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
default List<AiModulePromptDO> selectListByComponentName(String componentName) {
|
||||||
|
return selectList(AiModulePromptDO::getComponentName, componentName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Map<String, String>> files = new ArrayList<>();
|
||||||
|
if (file != null && !file.isEmpty()) {
|
||||||
|
String uploadFileId = uploadImageToDify(file.getBytes(), file.getOriginalFilename(), user);
|
||||||
|
if (uploadFileId != null) {
|
||||||
|
Map<String, String> 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<Object, Object> objectObjectHashMap = new HashMap<>();
|
||||||
|
objectObjectHashMap.put("pic", files);
|
||||||
|
if (taskDesc != null && !taskDesc.trim().isEmpty()) {
|
||||||
|
objectObjectHashMap.put("task_desc", taskDesc.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> 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<String, Object> chunk = new HashMap<String, Object>();
|
||||||
|
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<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
|
body.add("file", new ByteArrayResource(imageBytes) {
|
||||||
|
@Override
|
||||||
|
public String getFilename() {
|
||||||
|
return "image." + fileExt;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
body.add("user", user);
|
||||||
|
|
||||||
|
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||||
|
ResponseEntity<String> 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<String, Object> 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<String, Object> errData = new HashMap<String, Object>();
|
||||||
|
errData.put("error", msg);
|
||||||
|
writeSseData(out, errData);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> getPromptMapByComponent(String componentName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按组件名称查询所有模块提示词列表
|
||||||
|
*/
|
||||||
|
List<AiModulePromptDO> getListByComponent(String componentName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 moduleKey 查询
|
||||||
|
*/
|
||||||
|
AiModulePromptDO getByModuleKey(String moduleKey);
|
||||||
|
}
|
||||||
@@ -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<String, String> getPromptMapByComponent(String componentName) {
|
||||||
|
List<AiModulePromptDO> list = aiModulePromptMapper.selectListByComponentName(componentName);
|
||||||
|
Map<String, String> 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<AiModulePromptDO> getListByComponent(String componentName) {
|
||||||
|
return aiModulePromptMapper.selectListByComponentName(componentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiModulePromptDO getByModuleKey(String moduleKey) {
|
||||||
|
return aiModulePromptMapper.selectByModuleKey(moduleKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -363,4 +363,11 @@ ydoyun:
|
|||||||
base-url: http://118.253.178.8:5001/v1/chat-messages
|
base-url: http://118.253.178.8:5001/v1/chat-messages
|
||||||
api-key1: app-a6GWULbcg8zkv8g3VVLPARPz
|
api-key1: app-a6GWULbcg8zkv8g3VVLPARPz
|
||||||
api-key2: app-xc2JwxgwqTRZWybnkAgyLZQV
|
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user