From 7a2603e1f998f7ff19d561f00698eff26cdb17b2 Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Fri, 12 Jun 2026 10:19:52 +0800 Subject: [PATCH] =?UTF-8?q?AI=E5=AE=A1=E6=A0=B8=E6=96=B0=E5=A2=9E=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E7=8B=AC=E7=AB=8B=E6=B5=81=E5=BC=8F=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=EF=BC=9A=E5=B7=A6=E4=BE=A7=E5=AE=9E=E6=97=B6=E8=BE=93=E5=87=BA?= =?UTF-8?q?+=E5=8F=B3=E4=BE=A7=E6=96=87=E6=A1=A3=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解决上传后长时间无反馈的问题——改为流式(SSE)边生成边展示。 后端: - MiMoClient.chatStream:HttpURLConnection 读 SSE,分别回调 reasoning(思考) 与 content(正文) 增量;支持多模态(扫描件PDF) - IOaAiReviewService.analyzeStream + 实现:同步校验/解析文档后,异步线程调用 流式接口,通过 SseEmitter 推送 {type:reasoning|content|done|error}, 结束后落库(含结论解析、摘要、原件OSS留存);createBy 显式回填(异步线程无登录上下文) · 抽出 prepare()/persist() 复用,analyze() 与 analyzeStream() 共用 - Controller 新增 POST /oa/aiReview/analyzeStream(multipart→text/event-stream) 前端: - 新增独立二级页面 views/oa/aiReview/add.vue(路由 /hint/aiReview/add): · 顶部:类型/岗位/选文件/开始审核 · 左侧:用原生 fetch 读流,实时渲染——思考过程(可折叠)+正文 Markdown 实时输出 · 右侧:选中文件即用本地 objectURL 预览(PDF 内嵌 iframe,Word 占位提示) · 完成后显示匹配度/风险标签 + 查看详情 - 列表页「新增审核」由弹窗改为跳转该页面,移除弹窗相关逻辑 - router 增加 /hint/aiReview/add 隐藏路由 Co-Authored-By: Claude Fable 5 --- .../com/ruoyi/oa/aireview/MiMoClient.java | 107 +++++++ .../oa/controller/OaAiReviewController.java | 12 + .../ruoyi/oa/service/IOaAiReviewService.java | 6 + .../service/impl/OaAiReviewServiceImpl.java | 172 +++++++--- ruoyi-ui/src/router/index.js | 6 + ruoyi-ui/src/views/oa/aiReview/add.vue | 297 ++++++++++++++++++ ruoyi-ui/src/views/oa/aiReview/index.vue | 100 +----- 7 files changed, 568 insertions(+), 132 deletions(-) create mode 100644 ruoyi-ui/src/views/oa/aiReview/add.vue diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java index d5bf6cc..616dcf1 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java @@ -14,7 +14,15 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import javax.annotation.PostConstruct; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.function.BiConsumer; /** * 小米 MiMo 大模型调用客户端(OpenAI 兼容 /chat/completions)。 @@ -80,6 +88,105 @@ public class MiMoClient { return send(body); } + /** + * 流式对话(SSE)。一边接收一边回调,返回拼接好的最终答案 content。 + * + * @param imageDataUris 多模态图片(可空) + * @param onDelta 回调:kind = "reasoning"(思考) / "content"(正文),text = 增量片段 + */ + public String chatStream(String systemPrompt, String userText, List imageDataUris, + BiConsumer onDelta) { + if (StringUtils.isBlank(props.getApiKey())) { + throw new ServiceException("未配置 MiMo API Key(application.yml: mimo.api-key)"); + } + ObjectNode body = baseBody(); + body.put("stream", true); + ArrayNode messages = body.putArray("messages"); + if (StringUtils.isNotBlank(systemPrompt)) { + messages.addObject().put("role", "system").put("content", systemPrompt); + } + boolean multimodal = imageDataUris != null && !imageDataUris.isEmpty(); + if (multimodal) { + ObjectNode userMsg = messages.addObject(); + userMsg.put("role", "user"); + ArrayNode content = userMsg.putArray("content"); + content.addObject().put("type", "text").put("text", userText); + for (String uri : imageDataUris) { + if (StringUtils.isBlank(uri)) continue; + ObjectNode img = content.addObject(); + img.put("type", "image_url"); + img.putObject("image_url").put("url", uri); + } + } else { + messages.addObject().put("role", "user").put("content", userText); + } + + HttpURLConnection conn = null; + try { + URL url = new URL(props.getBaseUrl() + "/chat/completions"); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout((props.getTimeout() == null ? 180 : props.getTimeout()) * 1000); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "text/event-stream"); + conn.setRequestProperty("api-key", props.getApiKey()); + conn.setRequestProperty("Authorization", "Bearer " + props.getApiKey()); + try (OutputStream os = conn.getOutputStream()) { + os.write(json.writeValueAsString(body).getBytes(StandardCharsets.UTF_8)); + } + int code = conn.getResponseCode(); + if (code >= 400) { + String err = readAll(conn.getErrorStream()); + throw new ServiceException("AI 服务返回 " + code + ":" + StringUtils.substring(err, 0, 300)); + } + StringBuilder full = new StringBuilder(); + try (BufferedReader r = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + if (line.isEmpty() || !line.startsWith("data:")) continue; + String payload = line.substring(5).trim(); + if ("[DONE]".equals(payload)) break; + JsonNode delta = json.readTree(payload).path("choices").path(0).path("delta"); + JsonNode rc = delta.get("reasoning_content"); + if (rc != null && !rc.isNull() && !rc.asText().isEmpty()) { + onDelta.accept("reasoning", rc.asText()); + } + JsonNode c = delta.get("content"); + if (c != null && !c.isNull() && !c.asText().isEmpty()) { + full.append(c.asText()); + onDelta.accept("content", c.asText()); + } + } + } + if (full.length() == 0) { + throw new ServiceException("AI 未返回有效内容(文档过长或额度不足)"); + } + return full.toString(); + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("调用 MiMo 流式接口失败", e); + throw new ServiceException("AI 服务调用失败:" + e.getMessage()); + } finally { + if (conn != null) conn.disconnect(); + } + } + + private String readAll(InputStream in) { + if (in == null) return ""; + try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String l; + while ((l = r.readLine()) != null) sb.append(l); + return sb.toString(); + } catch (Exception e) { + return ""; + } + } + private ObjectNode baseBody() { ObjectNode body = json.createObjectNode(); body.put("model", props.getModel()); diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java index bb501d6..eb53e6b 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java @@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @@ -40,6 +41,17 @@ public class OaAiReviewController extends BaseController { return R.ok("审核完成", service.analyze(file, reviewType, position)); } + /** + * 上传并流式审核(SSE:边生成边推送,结束后落库) + */ + @Log(title = "AI审核", businessType = BusinessType.OTHER) + @PostMapping(value = "/analyzeStream", consumes = "multipart/form-data", produces = "text/event-stream;charset=UTF-8") + public SseEmitter analyzeStream(@RequestParam("file") MultipartFile file, + @RequestParam("reviewType") String reviewType, + @RequestParam(value = "position", required = false) String position) { + return service.analyzeStream(file, reviewType, position); + } + @GetMapping("/list") public TableDataInfo list(OaAiReviewBo bo, PageQuery pageQuery) { return service.queryPageList(bo, pageQuery); diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java index cd31fde..dbd02bc 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java @@ -5,6 +5,7 @@ import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.oa.domain.bo.OaAiReviewBo; import com.ruoyi.oa.domain.vo.OaAiReviewVo; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.Collection; @@ -19,6 +20,11 @@ public interface IOaAiReviewService { */ OaAiReviewVo analyze(MultipartFile file, String reviewType, String position); + /** + * 流式审核:边生成边推送(SSE),结束后落库 + */ + SseEmitter analyzeStream(MultipartFile file, String reviewType, String position); + TableDataInfo queryPageList(OaAiReviewBo bo, PageQuery pageQuery); OaAiReviewVo queryById(Long id); diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java index 14c2005..808d897 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.common.core.domain.PageQuery; import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.helper.LoginHelper; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.oa.aireview.DocumentParseUtil; import com.ruoyi.oa.aireview.MiMoClient; @@ -21,9 +22,14 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.io.File; +import java.nio.file.Files; import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -52,6 +58,66 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { @Override public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position) { + Prepared p = prepare(file, reviewType, position); + String result = p.images != null + ? miMoClient.chatMultimodal(p.system, p.userText, p.images) + : miMoClient.chatText(p.system, p.userText); + // 留存原件 + SysOssVo oss = uploadQuietly(file); + OaAiReview entity = persist(p, result, oss, currentUsername()); + return baseMapper.selectVoById(entity.getId()); + } + + @Override + public SseEmitter analyzeStream(MultipartFile file, String reviewType, String position) { + // 同步校验 + 解析(出错可立即以普通异常返回),耗时的大模型调用放到异步线程 + Prepared p = prepare(file, reviewType, position); + String username = currentUsername(); + long timeoutMs = ((miMoProps.getTimeout() == null ? 180 : miMoProps.getTimeout()) + 60) * 1000L; + SseEmitter emitter = new SseEmitter(timeoutMs); + + Thread worker = new Thread(() -> { + try { + String result = miMoClient.chatStream(p.system, p.userText, p.images, (kind, chunk) -> { + try { + Map ev = new HashMap<>(); + ev.put("type", kind); // reasoning / content + ev.put("c", chunk); + emitter.send(ev); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + SysOssVo oss = uploadQuietly(p.fileName, p.bytes); + OaAiReview entity = persist(p, result, oss, username); + + Map done = new HashMap<>(); + done.put("type", "done"); + done.put("id", entity.getId()); + done.put("matchScore", entity.getMatchScore()); + done.put("riskLevel", entity.getRiskLevel()); + emitter.send(done); + emitter.complete(); + } catch (Exception e) { + log.error("流式审核失败", e); + try { + Map err = new HashMap<>(); + err.put("type", "error"); + Throwable cause = e instanceof RuntimeException && e.getCause() != null ? e.getCause() : e; + err.put("msg", cause.getMessage()); + emitter.send(err); + } catch (Exception ignored) {} + emitter.complete(); + } + }); + worker.setDaemon(true); + worker.setName("ai-review-stream"); + worker.start(); + return emitter; + } + + /** 校验 + 读取 + 解析 + 构建提示词 */ + private Prepared prepare(MultipartFile file, String reviewType, String position) { if (file == null || file.isEmpty()) { throw new ServiceException("请上传文件"); } @@ -66,7 +132,6 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { if (!(lower.endsWith(".pdf") || lower.endsWith(".doc") || lower.endsWith(".docx"))) { throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件"); } - byte[] bytes; try { bytes = file.getBytes(); @@ -74,63 +139,100 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService { throw new ServiceException("读取文件失败"); } - // 1. 提取文字 - String text = DocumentParseUtil.extractText(fileName, bytes); - text = truncate(text); + Prepared p = new Prepared(); + p.reviewType = reviewType; + p.position = position; + p.fileName = fileName; + p.bytes = bytes; + p.system = "contract".equals(reviewType) ? contractSystemPrompt() : resumeSystemPrompt(position); - // 2. 构建提示词 - String system = "contract".equals(reviewType) ? contractSystemPrompt() : resumeSystemPrompt(position); - - // 3. 调用大模型(文字优先;扫描版 PDF 走多模态) - String result; + String text = truncate(DocumentParseUtil.extractText(fileName, bytes)); if (StringUtils.length(text) >= MIN_TEXT_LEN) { - String userText = "contract".equals(reviewType) + p.userText = "contract".equals(reviewType) ? "以下是待审核的合同全文:\n\n" + text : "以下是待评估的简历内容:" + (StringUtils.isNotBlank(position) ? "(目标岗位:" + position + ")" : "") + "\n\n" + text; - result = miMoClient.chatText(system, userText); + p.images = null; } else if (DocumentParseUtil.isPdf(fileName)) { - List images = DocumentParseUtil.renderPdfImages(bytes, miMoProps.getMaxImagePages()); - String userText = "contract".equals(reviewType) + p.images = DocumentParseUtil.renderPdfImages(bytes, miMoProps.getMaxImagePages()); + p.userText = "contract".equals(reviewType) ? "请审核以下图片中的合同(扫描件),逐页通读后给出审核意见。" : "请评估以下图片中的简历(扫描件)。" + (StringUtils.isNotBlank(position) ? "目标岗位:" + position + "。" : ""); - result = miMoClient.chatMultimodal(system, userText, images); } else { throw new ServiceException("未能从该 Word 文件中提取到文字内容,请确认文件未加密或改用 PDF"); } + return p; + } - // 4. 留存原件(失败不阻塞主流程) - Long ossId = null; - String fileUrl = null; - try { - SysOssVo oss = ossService.upload(file, 0L); - if (oss != null) { - ossId = oss.getOssId(); - fileUrl = oss.getUrl(); - } - } catch (Exception e) { - log.warn("AI审核原件留存失败:{}", fileName, e); - } - - // 5. 落库 + /** 落库(结论解析、摘要、创建人) */ + private OaAiReview persist(Prepared p, String result, SysOssVo oss, String username) { OaAiReview entity = new OaAiReview(); - entity.setReviewType(reviewType); - entity.setFileName(fileName); - entity.setOssId(ossId); - entity.setFileUrl(fileUrl); - entity.setPosition(position); + entity.setReviewType(p.reviewType); + entity.setFileName(p.fileName); + if (oss != null) { + entity.setOssId(oss.getOssId()); + entity.setFileUrl(oss.getUrl()); + } + entity.setPosition(p.position); entity.setResultMd(result); entity.setSummary(buildSummary(result)); entity.setModel(miMoProps.getModel()); - if ("resume".equals(reviewType)) { + if ("resume".equals(p.reviewType)) { entity.setMatchScore(parseInt(SCORE_PATTERN, result, 100)); } else { entity.setRiskLevel(parseStr(RISK_PATTERN, result)); } + if (StringUtils.isNotBlank(username)) { + entity.setCreateBy(username); + } baseMapper.insert(entity); + return entity; + } - return baseMapper.selectVoById(entity.getId()); + private String currentUsername() { + try { + return LoginHelper.getLoginUser() != null ? LoginHelper.getLoginUser().getUsername() : null; + } catch (Exception e) { + return null; + } + } + + private SysOssVo uploadQuietly(MultipartFile file) { + try { + return ossService.upload(file, 0L); + } catch (Exception e) { + log.warn("AI审核原件留存失败:{}", file.getOriginalFilename(), e); + return null; + } + } + + private SysOssVo uploadQuietly(String fileName, byte[] bytes) { + File tmp = null; + try { + String suffix = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf('.')) : ""; + tmp = File.createTempFile("aireview", suffix); + Files.write(tmp.toPath(), bytes); + return ossService.upload(tmp, fileName, 0L); + } catch (Exception e) { + log.warn("AI审核原件留存失败:{}", fileName, e); + return null; + } finally { + if (tmp != null && tmp.exists()) { + try { tmp.delete(); } catch (Exception ignored) {} + } + } + } + + /** 解析后的待审核数据 */ + private static class Prepared { + String reviewType; + String position; + String fileName; + byte[] bytes; + String system; + String userText; + List images; } private String truncate(String text) { diff --git a/ruoyi-ui/src/router/index.js b/ruoyi-ui/src/router/index.js index a72563d..d4bee6f 100644 --- a/ruoyi-ui/src/router/index.js +++ b/ruoyi-ui/src/router/index.js @@ -177,6 +177,12 @@ export const constantRoutes = [ name: "editMeetingMinutes", meta: { title: "编辑会议纪要", activeMenu: "/hint/meeting" }, }, + { + path: "aiReview/add", + component: () => import("@/views/oa/aiReview/add"), + name: "aiReviewAdd", + meta: { title: "新增审核", activeMenu: "/hint/aiReview" }, + }, { path: "aiReview/detail/:id(\\d+)", component: () => import("@/views/oa/aiReview/detail"), diff --git a/ruoyi-ui/src/views/oa/aiReview/add.vue b/ruoyi-ui/src/views/oa/aiReview/add.vue new file mode 100644 index 0000000..d1745d4 --- /dev/null +++ b/ruoyi-ui/src/views/oa/aiReview/add.vue @@ -0,0 +1,297 @@ +