1.AI助手模版、样式修改成谢提供的对话模版(有特效)。

2.点击AI分析后,对话弹出类似商品链接引入,用户点击确认后进行分析。
3.AI分析对话新增提示词编辑,根据组件名称:模块编号进行获取提示词进行模块分析。
4.商品大盘首页字体调整,数据卡片中的字体调小一点,文字描述超长不显示,鼠标放上去显示全量。
This commit is contained in:
2026-03-20 08:41:45 +08:00
parent 0bfd22ccca
commit 5bdcdf205a
14 changed files with 628 additions and 0 deletions

View File

@@ -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);
}
}
}
}

View File

@@ -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;
}

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}