refactor: 重构MeetingDetail页面,优化会议详情和智能分析展示
- 重构 `MeetingDetail` 组件,优化会议详情和智能分析的展示 - 增加关键词、全文概要、章节速览、发言总结、要点回顾和待办事项的展示逻辑 - 优化音频播放器和进度条功能 - 更新表单验证和数据处理逻辑 - 修复部分样式和布局问题dev_na
parent
4ee7a620b9
commit
12c79cdf26
|
|
@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
|
|||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class MeetingVO {
|
||||
|
|
@ -22,6 +23,7 @@ public class MeetingVO {
|
|||
private String audioUrl;
|
||||
private Integer duration;
|
||||
private String summaryContent;
|
||||
private Map<String, Object> analysis;
|
||||
private Integer status;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
|
|
|
|||
|
|
@ -3,12 +3,21 @@ package com.imeeting.service.biz;
|
|||
import com.imeeting.entity.biz.Meeting;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
public interface MeetingSummaryFileService {
|
||||
Path requireSummarySourcePath(Meeting meeting);
|
||||
|
||||
String loadSummaryContent(Meeting meeting);
|
||||
|
||||
Map<String, Object> loadSummaryAnalysis(Meeting meeting);
|
||||
|
||||
Map<String, Object> parseSummaryBundle(String rawContent);
|
||||
|
||||
Map<String, Object> parseSummaryAnalysis(String rawContent);
|
||||
|
||||
String buildSummaryMarkdown(Map<String, Object> analysis);
|
||||
|
||||
void updateSummaryContent(Meeting meeting, String summaryContent);
|
||||
|
||||
String stripFrontMatter(String markdown);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
|||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.AiTaskService;
|
||||
import com.imeeting.service.biz.HotWordService;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
|
||||
import com.unisbase.entity.SysUser;
|
||||
import com.unisbase.mapper.SysUserMapper;
|
||||
|
|
@ -55,6 +56,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
private final SysUserMapper sysUserMapper;
|
||||
private final HotWordService hotWordService;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||
|
||||
@Value("${unisbase.app.server-base-url}")
|
||||
private String serverBaseUrl;
|
||||
|
|
@ -197,7 +199,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
String status = data.path("status").asText();
|
||||
|
||||
if ("completed".equalsIgnoreCase(status)) {
|
||||
resultNode = data.path("result");
|
||||
resultNode = extractAsrResultNode(data);
|
||||
updateAiTaskSuccess(taskRecord, statusNode);
|
||||
break;
|
||||
} else if ("failed".equalsIgnoreCase(status)) {
|
||||
|
|
@ -205,7 +207,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
throw new RuntimeException("ASR引擎处理失败: " + data.path("message").asText());
|
||||
} else {
|
||||
int currentPercent = data.path("percentage").asInt();
|
||||
int eta = data.path("eta").asInt(0);
|
||||
int eta = data.path("eta_seconds").asInt(statusNode.path("eta_seconds").asInt(data.path("eta").asInt(0)));
|
||||
updateProgress(meeting.getId(), (int)(currentPercent * 0.85), data.path("message").asText(), eta);
|
||||
|
||||
if (currentPercent > 0 && currentPercent == lastPercent) {
|
||||
|
|
@ -276,22 +278,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
MeetingTranscript mt = new MeetingTranscript();
|
||||
mt.setMeetingId(meeting.getId());
|
||||
|
||||
JsonNode spkNode = seg.path("speaker");
|
||||
String spkId = spkNode.path("user_id").asText("spk_0");
|
||||
String spkName = spkNode.path("name").asText(spkId);
|
||||
|
||||
if (spkId.matches("\\d+")) {
|
||||
SysUser user = sysUserMapper.selectById(Long.parseLong(spkId));
|
||||
if (user != null) spkName = user.getDisplayName() != null ? user.getDisplayName() : user.getUsername();
|
||||
}
|
||||
String spkId = extractSpeakerId(seg);
|
||||
String spkName = resolveTranscriptSpeakerName(seg, spkId);
|
||||
|
||||
mt.setSpeakerId(spkId);
|
||||
mt.setSpeakerName(spkName);
|
||||
mt.setContent(seg.path("text").asText());
|
||||
if (seg.has("timestamp")) {
|
||||
mt.setStartTime(seg.path("timestamp").path(0).asInt());
|
||||
mt.setEndTime(seg.path("timestamp").path(1).asInt());
|
||||
}
|
||||
mt.setContent(seg.path("text").asText(""));
|
||||
fillTranscriptTime(mt, seg);
|
||||
mt.setSortOrder(order++);
|
||||
transcriptMapper.insert(mt);
|
||||
sb.append(mt.getSpeakerName()).append(": ").append(mt.getContent()).append("\n");
|
||||
|
|
@ -300,6 +293,100 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
return sb.toString();
|
||||
}
|
||||
|
||||
private JsonNode extractAsrResultNode(JsonNode data) {
|
||||
JsonNode resultNode = data.path("result");
|
||||
if (!resultNode.isMissingNode() && !resultNode.isNull()) {
|
||||
return resultNode;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private String extractSpeakerId(JsonNode seg) {
|
||||
String speakerId = seg.path("speaker_id").asText("");
|
||||
if (speakerId == null || speakerId.isBlank()) {
|
||||
JsonNode speakerNode = seg.path("speaker");
|
||||
speakerId = speakerNode.path("user_id").asText("");
|
||||
if (speakerId == null || speakerId.isBlank()) {
|
||||
speakerId = speakerNode.path("id").asText("");
|
||||
}
|
||||
}
|
||||
if (speakerId == null || speakerId.isBlank()) {
|
||||
speakerId = seg.path("user_id").asText("");
|
||||
}
|
||||
if (speakerId == null || speakerId.isBlank()) {
|
||||
return "spk_0";
|
||||
}
|
||||
return speakerId.trim();
|
||||
}
|
||||
|
||||
private String resolveTranscriptSpeakerName(JsonNode seg, String speakerId) {
|
||||
String speakerName = seg.path("speaker_name").asText("");
|
||||
if (speakerName == null || speakerName.isBlank()) {
|
||||
JsonNode speakerNode = seg.path("speaker");
|
||||
speakerName = speakerNode.path("name").asText("");
|
||||
if (speakerName == null || speakerName.isBlank()) {
|
||||
speakerName = speakerNode.path("speaker_name").asText("");
|
||||
}
|
||||
}
|
||||
|
||||
String userId = seg.path("user_id").asText("");
|
||||
if (userId == null || userId.isBlank()) {
|
||||
userId = seg.path("speaker").path("user_id").asText("");
|
||||
}
|
||||
String resolvedUserName = resolveUserName(userId);
|
||||
if (resolvedUserName != null) {
|
||||
return resolvedUserName;
|
||||
}
|
||||
|
||||
if (speakerId != null && speakerId.matches("\\d+")) {
|
||||
String resolvedSpeakerName = resolveUserName(speakerId);
|
||||
if (resolvedSpeakerName != null) {
|
||||
return resolvedSpeakerName;
|
||||
}
|
||||
}
|
||||
|
||||
if (speakerName == null || speakerName.isBlank()) {
|
||||
return speakerId;
|
||||
}
|
||||
return speakerName.trim();
|
||||
}
|
||||
|
||||
private String resolveUserName(String userId) {
|
||||
if (userId == null || userId.isBlank() || !userId.matches("\\d+")) {
|
||||
return null;
|
||||
}
|
||||
SysUser user = sysUserMapper.selectById(Long.parseLong(userId));
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
return user.getDisplayName() != null ? user.getDisplayName() : user.getUsername();
|
||||
}
|
||||
|
||||
private void fillTranscriptTime(MeetingTranscript transcript, JsonNode seg) {
|
||||
JsonNode timestamp = seg.path("timestamp");
|
||||
if (timestamp.isArray() && timestamp.size() >= 2) {
|
||||
transcript.setStartTime(timestamp.path(0).asInt());
|
||||
transcript.setEndTime(timestamp.path(1).asInt());
|
||||
return;
|
||||
}
|
||||
|
||||
Integer startTime = readSecondsAsMillis(seg.get("start"));
|
||||
Integer endTime = readSecondsAsMillis(seg.get("end"));
|
||||
if (startTime != null) {
|
||||
transcript.setStartTime(startTime);
|
||||
}
|
||||
if (endTime != null) {
|
||||
transcript.setEndTime(endTime);
|
||||
}
|
||||
}
|
||||
|
||||
private Integer readSecondsAsMillis(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull() || !node.isNumber()) {
|
||||
return null;
|
||||
}
|
||||
return (int) Math.round(node.asDouble() * 1000D);
|
||||
}
|
||||
|
||||
private void processSummaryTask(Meeting meeting, String asrText, AiTask taskRecord) throws Exception {
|
||||
updateMeetingStatus(meeting.getId(), 2);
|
||||
updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0);
|
||||
|
|
@ -319,8 +406,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
req.put("model", llmModel.getModelCode());
|
||||
req.put("temperature", llmModel.getTemperature());
|
||||
req.put("messages", List.of(
|
||||
Map.of("role", "system", "content", promptContent),
|
||||
Map.of("role", "user", "content", "请总结以下会议内容:\n" + asrText)
|
||||
Map.of("role", "system", "content", buildSummarySystemPrompt(promptContent)),
|
||||
Map.of("role", "user", "content", buildSummaryUserPrompt(meeting, asrText))
|
||||
));
|
||||
|
||||
taskRecord.setRequestData(req);
|
||||
|
|
@ -344,6 +431,17 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
|
||||
if (response.statusCode() == 200 && respNode.has("choices")) {
|
||||
String content = sanitizeSummaryContent(respNode.path("choices").path(0).path("message").path("content").asText());
|
||||
Map<String, Object> summaryBundle = meetingSummaryFileService.parseSummaryBundle(content);
|
||||
String markdownContent = summaryBundle != null
|
||||
? String.valueOf(summaryBundle.getOrDefault("summaryContent", ""))
|
||||
: content;
|
||||
if (markdownContent == null || markdownContent.isBlank()) {
|
||||
markdownContent = content;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> normalizedAnalysis = summaryBundle != null
|
||||
? (Map<String, Object>) summaryBundle.get("analysis")
|
||||
: meetingSummaryFileService.parseSummaryAnalysis(content);
|
||||
|
||||
// Save to File
|
||||
String timestamp = java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
|
||||
|
|
@ -353,14 +451,24 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
Files.createDirectories(targetDir);
|
||||
Path filePath = targetDir.resolve(fileName);
|
||||
|
||||
String frontMatter = "---\n" +
|
||||
"生成时间: " + LocalDateTime.now() + "\n" +
|
||||
"使用模型: " + llmModel.getModelName() + "\n" +
|
||||
"---\n\n";
|
||||
Files.writeString(filePath, frontMatter + content, StandardCharsets.UTF_8);
|
||||
Files.writeString(filePath, markdownContent, StandardCharsets.UTF_8);
|
||||
|
||||
taskRecord.setResultFilePath("meetings/" + meeting.getId() + "/summaries/" + fileName);
|
||||
if (summaryBundle != null || normalizedAnalysis != null) {
|
||||
Map<String, Object> responseData = objectMapper.convertValue(respNode, Map.class);
|
||||
if (summaryBundle != null) {
|
||||
responseData.put("summaryBundle", summaryBundle);
|
||||
}
|
||||
if (normalizedAnalysis != null) {
|
||||
responseData.put("normalizedAnalysis", normalizedAnalysis);
|
||||
}
|
||||
taskRecord.setResponseData(responseData);
|
||||
taskRecord.setStatus(2);
|
||||
taskRecord.setCompletedAt(LocalDateTime.now());
|
||||
this.updateById(taskRecord);
|
||||
} else {
|
||||
updateAiTaskSuccess(taskRecord, respNode);
|
||||
}
|
||||
|
||||
meeting.setLatestSummaryTaskId(taskRecord.getId());
|
||||
meeting.setStatus(3);
|
||||
|
|
@ -442,6 +550,88 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
return normalized.substring(firstLineEnd + 1, lastFence).trim();
|
||||
}
|
||||
|
||||
private String buildSummarySystemPrompt(String promptContent) {
|
||||
String basePrompt = (promptContent == null || promptContent.isBlank())
|
||||
? "你是一个擅长总结会议、提炼章节、聚合发言人观点和整理待办事项的中文助手。"
|
||||
: promptContent.trim();
|
||||
return String.join("\n\n",
|
||||
"你是一个擅长总结会议、提炼章节、聚合发言人观点和整理待办事项的中文助手。",
|
||||
"对于 summaryContent,你必须严格遵循以下会议总结模板/风格要求,不要被后续结构化字段说明覆盖:\n" + basePrompt,
|
||||
"如果模板中已经定义了标题层级、章节顺序、栏目名称、语气、措辞风格、段落组织方式,你必须保持一致。",
|
||||
"summaryContent 不允许退化成关键词罗列、JSON 翻译或结构化字段拼接,必须是一篇可直接阅读、可直接导出的正式会议纪要正文。",
|
||||
"analysis 是附加产物,只服务于页面结构化展示;analysis 不能改变 summaryContent 的模板风格和写法。",
|
||||
"你需要一次性返回一个 JSON 对象,其中同时包含原会议纪要正文和结构化 analysis 结果。",
|
||||
"最终只返回 JSON,不要输出 markdown 代码围栏、解释、前后缀或额外说明。"
|
||||
);
|
||||
}
|
||||
|
||||
private String buildSummaryUserPrompt(Meeting meeting, String asrText) {
|
||||
String participants = meeting.getParticipants() == null || meeting.getParticipants().isBlank()
|
||||
? "未填写"
|
||||
: meeting.getParticipants();
|
||||
Integer durationMs = resolveMeetingDuration(meeting.getId());
|
||||
String durationText = durationMs == null || durationMs <= 0 ? "未知" : formatDuration(durationMs);
|
||||
String meetingTime = meeting.getMeetingTime() == null ? "未知" : meeting.getMeetingTime().toString();
|
||||
|
||||
return String.join("\n",
|
||||
"请基于以下会议转写,一次性生成会议纪要正文和结构化分析结果。",
|
||||
"会议基础信息:",
|
||||
"标题:" + (meeting.getTitle() == null || meeting.getTitle().isBlank() ? "未命名会议" : meeting.getTitle()),
|
||||
"会议时间:" + meetingTime,
|
||||
"参会人员:" + participants,
|
||||
"会议时长:" + durationText,
|
||||
"返回 JSON,字段结构固定如下:",
|
||||
"{",
|
||||
" \"summaryContent\": \"原会议纪要正文,使用 markdown,保持自然完整的纪要写法,而不是关键词列表拼接\",",
|
||||
" \"analysis\": {",
|
||||
" \"overview\": \"基于整场会议内容生成的中文全文概要,控制在300字内,需尽量覆盖完整讨论内容\",",
|
||||
" \"keywords\": [\"关键词1\", \"关键词2\"],",
|
||||
" \"chapters\": [{\"time\":\"00:00\",\"title\":\"章节标题\",\"summary\":\"章节摘要\"}],",
|
||||
" \"speakerSummaries\": [{\"speaker\":\"发言人 1\",\"summary\":\"该发言人在整场会议中的主要观点总结\"}],",
|
||||
" \"keyPoints\": [{\"title\":\"重点问题或结论\",\"summary\":\"具体说明\",\"speaker\":\"发言人 1\",\"time\":\"00:00\"}],",
|
||||
" \"todos\": [\"待办事项1\", \"待办事项2\"]",
|
||||
" }",
|
||||
"}",
|
||||
"要求:",
|
||||
"1. summaryContent 必须是完整会议纪要正文,优先遵循提示词模板中的标题层级、章节顺序、栏目名称、措辞风格和段落组织方式。",
|
||||
"2. 如果模板里已经定义了固定标题或固定分节顺序,summaryContent 必须严格复用,不要自行改写成别的结构。",
|
||||
"3. summaryContent 必须保持自然完整、适合阅读和导出,不能写成关键词清单,也不能直接把 analysis 内容原样展开。",
|
||||
"4. analysis 是给页面结构化展示使用的单独结果。",
|
||||
"5. analysis.overview 必须基于所有会话内容,控制在 300 字内。",
|
||||
"6. analysis.speakerSummaries 必须按发言人聚合,每个发言人只出现一次。",
|
||||
"7. analysis.chapters 按时间顺序输出,time 使用 mm:ss 或 hh:mm:ss。",
|
||||
"8. analysis.keyPoints 提炼关键问题、决定、结论或争议点。",
|
||||
"9. analysis.todos 尽量写成可执行动作;没有就返回空数组。",
|
||||
"10. 只返回 JSON。",
|
||||
"",
|
||||
"会议转写如下:",
|
||||
asrText
|
||||
);
|
||||
}
|
||||
|
||||
private Integer resolveMeetingDuration(Long meetingId) {
|
||||
MeetingTranscript latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper<MeetingTranscript>()
|
||||
.eq(MeetingTranscript::getMeetingId, meetingId)
|
||||
.isNotNull(MeetingTranscript::getEndTime)
|
||||
.orderByDesc(MeetingTranscript::getEndTime)
|
||||
.last("LIMIT 1"));
|
||||
if (latestTranscript != null && latestTranscript.getEndTime() != null && latestTranscript.getEndTime() > 0) {
|
||||
return latestTranscript.getEndTime();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String formatDuration(int durationMs) {
|
||||
int totalSeconds = Math.max(durationMs / 1000, 0);
|
||||
int hours = totalSeconds / 3600;
|
||||
int minutes = (totalSeconds % 3600) / 60;
|
||||
int seconds = totalSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return String.format("%02d:%02d:%02d", hours, minutes, seconds);
|
||||
}
|
||||
return String.format("%02d:%02d", minutes, seconds);
|
||||
}
|
||||
|
||||
private void updateMeetingStatus(Long id, int status) {
|
||||
Meeting m = new Meeting(); m.setId(id); m.setStatus(status); meetingMapper.updateById(m);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ public class MeetingDomainSupport {
|
|||
}
|
||||
if (includeSummary) {
|
||||
vo.setSummaryContent(meetingSummaryFileService.loadSummaryContent(meeting));
|
||||
vo.setAnalysis(meetingSummaryFileService.loadSummaryAnalysis(meeting));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.entity.biz.AiTask;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.service.biz.AiTaskService;
|
||||
import com.imeeting.mapper.biz.AiTaskMapper;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
|
@ -14,12 +16,18 @@ import java.nio.file.Files;
|
|||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService {
|
||||
|
||||
private final AiTaskService aiTaskService;
|
||||
private final AiTaskMapper aiTaskMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${unisbase.app.upload-path}")
|
||||
private String uploadPath;
|
||||
|
|
@ -52,6 +60,163 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> loadSummaryAnalysis(Meeting meeting) {
|
||||
try {
|
||||
AiTask summaryTask = findLatestSummaryTask(meeting);
|
||||
if (summaryTask == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object bundle = summaryTask.getResponseData() != null ? summaryTask.getResponseData().get("summaryBundle") : null;
|
||||
if (bundle instanceof Map<?, ?> bundleMap) {
|
||||
Object analysis = bundleMap.get("analysis");
|
||||
if (analysis instanceof Map<?, ?> analysisMap) {
|
||||
return objectMapper.convertValue(analysisMap, new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
}
|
||||
|
||||
Object normalized = summaryTask.getResponseData() != null ? summaryTask.getResponseData().get("normalizedAnalysis") : null;
|
||||
if (normalized instanceof Map<?, ?> map) {
|
||||
return objectMapper.convertValue(map, new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
|
||||
Object content = extractSummaryContent(summaryTask);
|
||||
if (content instanceof String text) {
|
||||
Map<String, Object> parsedBundle = parseSummaryBundle(text);
|
||||
if (parsedBundle != null && parsedBundle.get("analysis") instanceof Map<?, ?> analysisMap) {
|
||||
return objectMapper.convertValue(analysisMap, new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
return parseSummaryAnalysis(text);
|
||||
}
|
||||
return null;
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException("Load summary analysis failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> parseSummaryBundle(String rawContent) {
|
||||
if (rawContent == null || rawContent.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> parsed = tryParseJson(rawContent.trim());
|
||||
if (parsed == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean hasSummary = parsed.containsKey("summaryContent");
|
||||
boolean hasAnalysis = parsed.containsKey("analysis");
|
||||
if (!hasSummary && !hasAnalysis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object> bundle = new LinkedHashMap<>();
|
||||
bundle.put("summaryContent", normalizeSummaryMarkdown(asText(parsed.get("summaryContent"))));
|
||||
|
||||
Object analysisValue = parsed.get("analysis");
|
||||
if (analysisValue instanceof Map<?, ?> analysisMap) {
|
||||
Map<String, Object> analysis = objectMapper.convertValue(analysisMap, new TypeReference<Map<String, Object>>() {});
|
||||
bundle.put("analysis", parseSummaryAnalysisFromMap(analysis));
|
||||
} else {
|
||||
bundle.put("analysis", null);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> parseSummaryAnalysis(String rawContent) {
|
||||
if (rawContent == null || rawContent.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> parsed = tryParseJson(rawContent.trim());
|
||||
if (parsed == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.get("analysis") instanceof Map<?, ?> analysis) {
|
||||
parsed = objectMapper.convertValue(analysis, new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
|
||||
return parseSummaryAnalysisFromMap(parsed);
|
||||
}
|
||||
|
||||
private Map<String, Object> parseSummaryAnalysisFromMap(Map<String, Object> parsed) {
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
normalized.put("overview", clipText(asText(parsed.get("overview")), 500));
|
||||
normalized.put("keywords", normalizeStringList(parsed.get("keywords")));
|
||||
normalized.put("chapters", normalizeChapterList(parsed.get("chapters")));
|
||||
normalized.put("speakerSummaries", normalizeSpeakerSummaries(parsed.get("speakerSummaries")));
|
||||
normalized.put("keyPoints", normalizeKeyPoints(parsed.get("keyPoints")));
|
||||
List<String> todos = normalizeStringList(parsed.containsKey("todos") ? parsed.get("todos") : parsed.get("actionItems"));
|
||||
normalized.put("todos", todos);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String buildSummaryMarkdown(Map<String, Object> analysis) {
|
||||
if (analysis == null || analysis.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
appendSection(builder, "全文概要", List.of(asText(analysis.get("overview"))));
|
||||
appendSection(builder, "关键词", normalizeStringList(analysis.get("keywords")));
|
||||
|
||||
List<Map<String, Object>> chapters = toMapList(analysis.get("chapters"));
|
||||
if (!chapters.isEmpty()) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (Map<String, Object> item : chapters) {
|
||||
String prefix = asText(item.get("time"));
|
||||
String title = asText(item.get("title"));
|
||||
String summary = asText(item.get("summary"));
|
||||
lines.add((prefix.isBlank() ? "" : prefix + " ") + title + (summary.isBlank() ? "" : ":" + summary));
|
||||
}
|
||||
appendSection(builder, "章节速览", lines);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> speakerSummaries = toMapList(analysis.get("speakerSummaries"));
|
||||
if (!speakerSummaries.isEmpty()) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (Map<String, Object> item : speakerSummaries) {
|
||||
lines.add(asText(item.get("speaker")) + ":" + asText(item.get("summary")));
|
||||
}
|
||||
appendSection(builder, "发言总结", lines);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> keyPoints = toMapList(analysis.get("keyPoints"));
|
||||
if (!keyPoints.isEmpty()) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (Map<String, Object> item : keyPoints) {
|
||||
StringBuilder line = new StringBuilder(asText(item.get("title")));
|
||||
String summary = asText(item.get("summary"));
|
||||
if (!summary.isBlank()) {
|
||||
line.append(":").append(summary);
|
||||
}
|
||||
String speaker = asText(item.get("speaker"));
|
||||
String time = asText(item.get("time"));
|
||||
if (!speaker.isBlank() || !time.isBlank()) {
|
||||
line.append("(");
|
||||
if (!speaker.isBlank()) {
|
||||
line.append(speaker);
|
||||
}
|
||||
if (!speaker.isBlank() && !time.isBlank()) {
|
||||
line.append(" / ");
|
||||
}
|
||||
if (!time.isBlank()) {
|
||||
line.append(time);
|
||||
}
|
||||
line.append(")");
|
||||
}
|
||||
lines.add(line.toString());
|
||||
}
|
||||
appendSection(builder, "要点回顾", lines);
|
||||
}
|
||||
|
||||
appendSection(builder, "待办事项", normalizeStringList(analysis.get("todos")));
|
||||
return builder.toString().trim();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateSummaryContent(Meeting meeting, String summaryContent) {
|
||||
AiTask summaryTask = findLatestSummaryTask(meeting);
|
||||
|
|
@ -97,10 +262,10 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService
|
|||
private AiTask findLatestSummaryTask(Meeting meeting) {
|
||||
AiTask summaryTask = null;
|
||||
if (meeting.getLatestSummaryTaskId() != null) {
|
||||
summaryTask = aiTaskService.getById(meeting.getLatestSummaryTaskId());
|
||||
summaryTask = aiTaskMapper.selectById(meeting.getLatestSummaryTaskId());
|
||||
}
|
||||
if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) {
|
||||
summaryTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||
summaryTask = aiTaskMapper.selectOne(new LambdaQueryWrapper<AiTask>()
|
||||
.eq(AiTask::getMeetingId, meeting.getId())
|
||||
.eq(AiTask::getTaskType, "SUMMARY")
|
||||
.eq(AiTask::getStatus, 2)
|
||||
|
|
@ -111,6 +276,165 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService
|
|||
return summaryTask;
|
||||
}
|
||||
|
||||
private Object extractSummaryContent(AiTask task) {
|
||||
if (task.getResponseData() == null) {
|
||||
return null;
|
||||
}
|
||||
Object choices = task.getResponseData().get("choices");
|
||||
if (choices instanceof List<?> choiceList && !choiceList.isEmpty()) {
|
||||
Object first = choiceList.get(0);
|
||||
if (first instanceof Map<?, ?> firstMap) {
|
||||
Object message = firstMap.get("message");
|
||||
if (message instanceof Map<?, ?> messageMap) {
|
||||
return messageMap.get("content");
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, Object> tryParseJson(String text) {
|
||||
Map<String, Object> parsed = tryReadMap(text);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
int fenceStart = text.indexOf("```");
|
||||
if (fenceStart >= 0) {
|
||||
int firstBreak = text.indexOf('\n', fenceStart);
|
||||
int lastFence = text.lastIndexOf("```");
|
||||
if (firstBreak > fenceStart && lastFence > firstBreak) {
|
||||
parsed = tryReadMap(text.substring(firstBreak + 1, lastFence).trim());
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int start = text.indexOf('{');
|
||||
int end = text.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) {
|
||||
return tryReadMap(text.substring(start, end + 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, Object> tryReadMap(String text) {
|
||||
try {
|
||||
return objectMapper.readValue(text, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> normalizeStringList(Object value) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (value instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String text = asText(item);
|
||||
if (!text.isBlank() && !result.contains(text)) {
|
||||
result.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> normalizeChapterList(Object value) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> item : toMapList(value)) {
|
||||
String title = asText(item.get("title"));
|
||||
String summary = asText(item.get("summary"));
|
||||
if (title.isBlank() && summary.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
normalized.put("time", asText(item.get("time")));
|
||||
normalized.put("title", title);
|
||||
normalized.put("summary", summary);
|
||||
result.add(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> normalizeSpeakerSummaries(Object value) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> item : toMapList(value)) {
|
||||
String speaker = asText(item.get("speaker"));
|
||||
String summary = asText(item.get("summary"));
|
||||
if (speaker.isBlank() && summary.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
normalized.put("speaker", speaker);
|
||||
normalized.put("summary", summary);
|
||||
result.add(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> normalizeKeyPoints(Object value) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> item : toMapList(value)) {
|
||||
String title = asText(item.get("title"));
|
||||
String summary = asText(item.get("summary"));
|
||||
if (title.isBlank() && summary.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
normalized.put("title", title);
|
||||
normalized.put("summary", summary);
|
||||
normalized.put("speaker", asText(item.get("speaker")));
|
||||
normalized.put("time", asText(item.get("time")));
|
||||
result.add(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> toMapList(Object value) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
if (value instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> map) {
|
||||
result.add(objectMapper.convertValue(map, new TypeReference<Map<String, Object>>() {}));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String asText(Object value) {
|
||||
return value == null ? "" : String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
private String clipText(String text, int limit) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
String normalized = text.replaceAll("\\s+", " ").trim();
|
||||
if (normalized.length() <= limit) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(0, limit).trim() + "...";
|
||||
}
|
||||
|
||||
private void appendSection(StringBuilder builder, String title, List<String> lines) {
|
||||
List<String> normalized = lines.stream()
|
||||
.map(this::asText)
|
||||
.filter(item -> !item.isBlank())
|
||||
.collect(Collectors.toList());
|
||||
if (normalized.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append("\n\n");
|
||||
}
|
||||
builder.append("## ").append(title).append("\n\n");
|
||||
for (String line : normalized) {
|
||||
builder.append("- ").append(line).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
private String extractFrontMatter(String markdown, Meeting meeting, AiTask summaryTask) {
|
||||
if (markdown != null && markdown.startsWith("---")) {
|
||||
int second = markdown.indexOf("\n---", 3);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@ export interface MeetingVO {
|
|||
tags: string;
|
||||
audioUrl: string;
|
||||
summaryContent: string;
|
||||
analysis?: {
|
||||
overview?: string;
|
||||
keywords?: string[];
|
||||
chapters?: Array<{ time?: string; title?: string; summary?: string }>;
|
||||
speakerSummaries?: Array<{ speaker?: string; summary?: string }>;
|
||||
keyPoints?: Array<{ title?: string; summary?: string; speaker?: string; time?: string }>;
|
||||
todos?: string[];
|
||||
};
|
||||
status: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,154 @@
|
|||
.home-right-visual {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 1200px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&__glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(50px);
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
animation: pulseGlow 6s ease-in-out infinite alternate;
|
||||
|
||||
&--main {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
background: rgba(103, 103, 244, 0.18);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: rgba(165, 214, 255, 0.25);
|
||||
top: 15%;
|
||||
right: 15%;
|
||||
animation-delay: -2s;
|
||||
}
|
||||
}
|
||||
|
||||
&__soundwave {
|
||||
position: relative;
|
||||
width: 380px;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
z-index: 2;
|
||||
|
||||
.home-right-visual__bar {
|
||||
width: 5px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(165, 214, 255, 0.95) 0%, rgba(103, 103, 244, 0.95) 100%);
|
||||
border-radius: 999px;
|
||||
transform-origin: center;
|
||||
/* The base transform uses the envelope to shape the bell curve */
|
||||
transform: scaleY(calc(0.05 + 0.1 * var(--envelope)));
|
||||
animation: soundwave-bounce var(--duration) ease-in-out infinite alternate;
|
||||
/* Sweep delay creates the wave-like motion */
|
||||
animation-delay: calc(-0.1s * var(--index));
|
||||
box-shadow: 0 0 12px rgba(103, 103, 244, 0.25);
|
||||
will-change: transform;
|
||||
}
|
||||
}
|
||||
|
||||
&__drop {
|
||||
position: absolute;
|
||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.8) 0%, rgba(240, 240, 255, 0.3) 100%);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(103, 103, 244, 0.15),
|
||||
inset 0 0 20px rgba(255, 255, 255, 0.95),
|
||||
inset 4px 4px 10px rgba(255, 255, 255, 0.6);
|
||||
animation: floatDrop 6s ease-in-out infinite;
|
||||
transform-style: preserve-3d;
|
||||
overflow: hidden;
|
||||
|
||||
&-inner {
|
||||
position: absolute;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
top: -25%;
|
||||
left: -25%;
|
||||
background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.8) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
&--1 {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
top: 10%;
|
||||
right: 12%;
|
||||
animation-duration: 7s;
|
||||
animation-name: floatDrop1;
|
||||
}
|
||||
|
||||
&--2 {
|
||||
width: 120px;
|
||||
height: 110px;
|
||||
bottom: -10%;
|
||||
left: -5%;
|
||||
animation-duration: 9s;
|
||||
animation-delay: -4s;
|
||||
animation-name: floatDrop3;
|
||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.6) 0%, rgba(180, 180, 255, 0.25) 100%);
|
||||
}
|
||||
|
||||
&--3 {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
top: -5%;
|
||||
left: 35%;
|
||||
animation-duration: 8s;
|
||||
animation-delay: -3s;
|
||||
animation-name: floatDrop5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes soundwave-bounce {
|
||||
0% {
|
||||
transform: scaleY(calc(0.05 + 0.1 * var(--envelope)));
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(calc(0.1 + 0.85 * var(--envelope)));
|
||||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0% { transform: translate(-50%, -50%) scale(0.85); opacity: 0.5; }
|
||||
100% { transform: translate(-50%, -50%) scale(1.15); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes floatDrop1 {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); border-radius: 40% 60% 70% 30% / 40% 50% 60% 50%; }
|
||||
33% { transform: translateY(-22px) rotate(12deg); border-radius: 50% 50% 60% 40% / 50% 40% 70% 40%; }
|
||||
66% { transform: translateY(12px) rotate(-8deg); border-radius: 30% 70% 50% 50% / 30% 60% 40% 60%; }
|
||||
}
|
||||
|
||||
@keyframes floatDrop3 {
|
||||
0%, 100% { transform: translateY(0) scale(1); border-radius: 50% 50% 30% 70% / 60% 40% 60% 40%; }
|
||||
50% { transform: translateY(-28px) scale(1.02); border-radius: 30% 70% 50% 50% / 40% 60% 40% 60%; }
|
||||
}
|
||||
|
||||
@keyframes floatDrop5 {
|
||||
0%, 100% { transform: translateY(0) scale(1) rotate(0deg); border-radius: 45% 55% 65% 35% / 45% 45% 55% 55%; }
|
||||
50% { transform: translateY(20px) scale(0.95) rotate(-10deg); border-radius: 55% 45% 35% 65% / 55% 55% 45% 45%; }
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import React from "react";
|
||||
import "./RightVisual.less";
|
||||
|
||||
export default function RightVisual() {
|
||||
const BAR_COUNT = 48;
|
||||
|
||||
// Calculate a Gaussian envelope for the soundwave so the center is tallest
|
||||
const getEnvelope = (i: number) => {
|
||||
const center = BAR_COUNT / 2;
|
||||
const x = (i - center) / (center * 0.8);
|
||||
// Gaussian bell curve
|
||||
const envelope = Math.exp(-Math.pow(x, 2));
|
||||
return Math.max(0.05, envelope);
|
||||
};
|
||||
|
||||
// Deterministic pseudo-random duration for a more organic, less rigid feel
|
||||
const getDuration = (i: number) => {
|
||||
return 0.9 + (Math.sin(i * 76543) * 0.5 + 0.5) * 0.6; // between 0.9s and 1.5s
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home-right-visual" aria-hidden="true">
|
||||
{/* Soundwave Animation */}
|
||||
<div className="home-right-visual__soundwave">
|
||||
{Array.from({ length: BAR_COUNT }).map((_, i) => {
|
||||
const envelope = getEnvelope(i);
|
||||
const duration = getDuration(i);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="home-right-visual__bar"
|
||||
style={{
|
||||
"--index": i,
|
||||
"--envelope": envelope,
|
||||
"--duration": `${duration}s`,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Floating Glass Droplets for ambient feel */}
|
||||
<div className="home-right-visual__drop home-right-visual__drop--1">
|
||||
<div className="home-right-visual__drop-inner" />
|
||||
</div>
|
||||
<div className="home-right-visual__drop home-right-visual__drop--2">
|
||||
<div className="home-right-visual__drop-inner" />
|
||||
</div>
|
||||
<div className="home-right-visual__drop home-right-visual__drop--3">
|
||||
<div className="home-right-visual__drop-inner" />
|
||||
</div>
|
||||
|
||||
{/* Light Glare / Glows */}
|
||||
<div className="home-right-visual__glow home-right-visual__glow--main" />
|
||||
<div className="home-right-visual__glow home-right-visual__glow--secondary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue