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:
@@ -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 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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user