feat: 添加热词组支持并优化相关逻辑

- 在 `RealtimeMeetingResumeConfig` 和 `MeetingVO` 中添加 `hotWordGroupId` 和 `hotWordGroupName` 字段
- 更新 `MeetingCommandServiceImpl` 以设置 `hotWordGroupId` 到 `resumeConfig`
- 在 `MeetingDomainSupport` 中添加方法以解析和填充有效的热词组信息
- 更新前端 `meeting.ts` 和 `MeetingDetail.tsx` 以支持热词组显示
dev_na
chenhao 2026-07-01 09:42:31 +08:00
parent 202ce057fd
commit 69e6eff9d9
6 changed files with 137 additions and 0 deletions

View File

@ -75,6 +75,12 @@ public class MeetingVO {
@Schema(description = "总结模板ID")
private Long promptId;
@Schema(description = "最终生效热词组ID")
private Long hotWordGroupId;
@Schema(description = "最终生效热词组名称")
private String hotWordGroupName;
@Schema(description = "是否启用 AI 目录")
private Boolean aiCatalogEnabled;

View File

@ -27,6 +27,8 @@ public class RealtimeMeetingResumeConfig {
private Boolean saveAudio;
@Schema(description = "热词列表")
private List<Map<String, Object>> hotwords;
@Schema(description = "最终生效热词组ID")
private Long hotWordGroupId;
@Schema(description = "腾讯说话人上下文 ID")
private String speakerContextId;
}

View File

@ -1512,6 +1512,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
resumeConfig.setEnableTextRefine(runtimeProfile.getResolvedEnableTextRefine());
resumeConfig.setSaveAudio(runtimeProfile.getResolvedSaveAudio());
resumeConfig.setHotwords(resolveRealtimeHotwords(runtimeProfile.getResolvedHotWords(), runtimeProfile.getResolvedHotWordGroupId()));
resumeConfig.setHotWordGroupId(runtimeProfile.getResolvedHotWordGroupId());
return resumeConfig;
}

View File

@ -5,12 +5,15 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWordGroup;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.event.MeetingCreatedEvent;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordGroupService;
import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.unisbase.entity.SysUser;
@ -57,6 +60,8 @@ public class MeetingDomainSupport {
private final ApplicationEventPublisher eventPublisher;
private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingPlaybackAudioResolver meetingPlaybackAudioResolver;
private final HotWordGroupService hotWordGroupService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final SysParamService sysParamService;
@Value("${unisbase.app.upload-path}")
@ -441,9 +446,42 @@ public class MeetingDomainSupport {
vo.setSummaryContent(meetingSummaryFileService.loadSummaryContent(meeting));
vo.setAnalysis(meetingSummaryFileService.loadSummaryAnalysis(meeting));
vo.setLastUserPrompt(resolveLastSummaryUserPrompt(meeting));
fillEffectiveHotWordGroup(meeting, vo);
}
}
private void fillEffectiveHotWordGroup(Meeting meeting, com.imeeting.dto.biz.MeetingVO vo) {
Long hotWordGroupId = resolveEffectiveHotWordGroupId(meeting);
vo.setHotWordGroupId(hotWordGroupId);
if (hotWordGroupId == null) {
return;
}
HotWordGroup hotWordGroup = hotWordGroupService.getById(hotWordGroupId);
if (hotWordGroup != null) {
vo.setHotWordGroupName(hotWordGroup.getGroupName());
}
}
private Long resolveEffectiveHotWordGroupId(Meeting meeting) {
AiTask latestAsrTask = resolveLatestAsrTask(meeting);
Long asrHotWordGroupId = longValue(
latestAsrTask == null || latestAsrTask.getTaskConfig() == null
? null
: latestAsrTask.getTaskConfig().get("hotWordGroupId")
);
if (asrHotWordGroupId != null) {
return asrHotWordGroupId;
}
if (!MeetingConstants.TYPE_REALTIME.equalsIgnoreCase(meeting.getMeetingType())) {
return null;
}
var sessionStatus = realtimeMeetingSessionStateService.getStatus(meeting.getId());
if (sessionStatus == null || sessionStatus.getResumeConfig() == null) {
return null;
}
return sessionStatus.getResumeConfig().getHotWordGroupId();
}
private String resolveLastSummaryUserPrompt(Meeting meeting) {
AiTask latestSummaryTask = resolveLatestSummaryTask(meeting);
if (latestSummaryTask == null || latestSummaryTask.getTaskConfig() == null) {
@ -477,6 +515,17 @@ public class MeetingDomainSupport {
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meeting.getId())
.eq(AiTask::getTaskType, "SUMMARY")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
private AiTask resolveLatestAsrTask(Meeting meeting) {
if (meeting == null || meeting.getId() == null) {
return null;
}
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meeting.getId())
.eq(AiTask::getTaskType, "ASR")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
@ -517,6 +566,17 @@ public class MeetingDomainSupport {
return normalized.isEmpty() ? null : normalized;
}
private Long longValue(Object value) {
if (value == null) {
return null;
}
try {
return Long.parseLong(String.valueOf(value).trim());
} catch (Exception ignored) {
return null;
}
}
private String normalizeSummaryDetailLevel(String summaryDetailLevel) {
if (summaryDetailLevel == null || summaryDetailLevel.isBlank()) {
return MeetingConstants.SUMMARY_DETAIL_STANDARD;

View File

@ -36,6 +36,8 @@ export interface MeetingVO {
summaryDetailLevel?: SummaryDetailLevel;
summaryModelId: number;
promptId?: number;
hotWordGroupId?: number;
hotWordGroupName?: string;
aiCatalogEnabled?: boolean;
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
audioSaveMessage?: string;

View File

@ -1389,6 +1389,35 @@ const MeetingDetail: React.FC = () => {
const meetingId = meeting?.id ?? (id ? Number(id) : NaN);
return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId, previewAccessPassword);
}, [meetingShareBaseUrl, meeting?.id, id, previewAccessPassword]);
const summaryModelDisplayName = useMemo(() => {
const matchedModel = llmModels.find((item) => item.id === meeting?.summaryModelId);
if (matchedModel?.modelName) {
return matchedModel.modelName;
}
if (meeting?.summaryModelId) {
return `模型 #${meeting.summaryModelId}`;
}
return '未配置';
}, [llmModels, meeting?.summaryModelId]);
const promptDisplayName = useMemo(() => {
const matchedPrompt = prompts.find((item) => item.id === meeting?.promptId);
if (matchedPrompt?.templateName) {
return matchedPrompt.templateName;
}
if (meeting?.promptId) {
return `模板 #${meeting.promptId}`;
}
return '未配置';
}, [meeting?.promptId, prompts]);
const hotWordGroupDisplayName = useMemo(() => {
if (meeting?.hotWordGroupName?.trim()) {
return meeting.hotWordGroupName.trim();
}
if (meeting?.hotWordGroupId) {
return `热词组 #${meeting.hotWordGroupId}`;
}
return '未使用';
}, [meeting?.hotWordGroupId, meeting?.hotWordGroupName]);
const isOwner = useMemo(() => {
if (!meeting) return false;
@ -2392,6 +2421,20 @@ const MeetingDetail: React.FC = () => {
</span>
<span>{meeting.participants || '未指定'}</span>
</div>
<div className="meeting-detail-config-row">
<span className="meeting-detail-config-item">
<Text type="secondary"></Text>
<strong>{summaryModelDisplayName}</strong>
</span>
<span className="meeting-detail-config-item">
<Text type="secondary"></Text>
<strong>{promptDisplayName}</strong>
</span>
<span className="meeting-detail-config-item">
<Text type="secondary"></Text>
<strong>{hotWordGroupDisplayName}</strong>
</span>
</div>
</div>
</div>
)}
@ -3053,6 +3096,29 @@ const MeetingDetail: React.FC = () => {
color: #6e7695;
font-size: 13px;
}
.meeting-detail-config-row {
display: flex;
flex-wrap: wrap;
gap: 10px 12px;
margin-top: 2px;
}
.meeting-detail-config-item {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(244, 246, 255, 0.92);
border: 1px solid rgba(227, 232, 247, 0.95);
color: #58627f;
font-size: 13px;
line-height: 1;
}
.meeting-detail-config-item strong {
color: #313a5a;
font-weight: 700;
}
.meeting-detail-workspace {
flex: 1;
min-height: 0;