AI审核新增改为独立流式页面:左侧实时输出+右侧文档预览

解决上传后长时间无反馈的问题——改为流式(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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:19:52 +08:00
parent d46754ede8
commit 7a2603e1f9
7 changed files with 568 additions and 132 deletions

View File

@@ -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<String> imageDataUris,
BiConsumer<String, String> onDelta) {
if (StringUtils.isBlank(props.getApiKey())) {
throw new ServiceException("未配置 MiMo API Keyapplication.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());

View File

@@ -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<OaAiReviewVo> list(OaAiReviewBo bo, PageQuery pageQuery) {
return service.queryPageList(bo, pageQuery);

View File

@@ -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<OaAiReviewVo> queryPageList(OaAiReviewBo bo, PageQuery pageQuery);
OaAiReviewVo queryById(Long id);

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String> 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<String> images;
}
private String truncate(String text) {