refactor: 重构MeetingDetail页面,优化会议详情和智能分析展示

- 重构 `MeetingDetail` 组件,优化会议详情和智能分析的展示
- 增加关键词、全文概要、章节速览、发言总结、要点回顾和待办事项的展示逻辑
- 优化音频播放器和进度条功能
- 更新表单验证和数据处理逻辑
- 修复部分样式和布局问题
dev_na
chenhao 2026-03-27 10:30:48 +08:00
parent 4ee7a620b9
commit 12c79cdf26
9 changed files with 1976 additions and 264 deletions

View File

@ -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")

View File

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

View File

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

View File

@ -193,6 +193,7 @@ public class MeetingDomainSupport {
}
if (includeSummary) {
vo.setSummaryContent(meetingSummaryFileService.loadSummaryContent(meeting));
vo.setAnalysis(meetingSummaryFileService.loadSummaryAnalysis(meeting));
}
}
}

View File

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

View File

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

View File

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

View File

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