diff --git a/backend/src/main/java/com/imeeting/common/SysParamKeys.java b/backend/src/main/java/com/imeeting/common/SysParamKeys.java index 6c6cb55..fcdc4d0 100644 --- a/backend/src/main/java/com/imeeting/common/SysParamKeys.java +++ b/backend/src/main/java/com/imeeting/common/SysParamKeys.java @@ -10,6 +10,10 @@ public final class SysParamKeys { public static final String CAPTCHA_ENABLED = "security.captcha.enabled"; /** AI 会议总结使用的系统提示词。 */ public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt"; + /** 是否启用 AI 目录。 */ + public static final String MEETING_AI_CATALOG_ENABLED = "meeting.ai_catalog.enabled"; + /** 会议总结派发模式:PARALLEL / SERIAL。 */ + public static final String MEETING_SUMMARY_DISPATCH_MODE = "meeting.summary.dispatch_mode"; /** 离线会议音频上传大小上限,单位 MB。 */ public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb"; /** 是否允许创建离线会议。 */ diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index 8ebcdaa..7a8d354 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -212,6 +212,7 @@ public class MeetingController { MeetingCreateConfigVO vo = new MeetingCreateConfigVO(); vo.setOfflineEnabled(resolveBooleanParam(SysParamKeys.MEETING_CREATE_OFFLINE_ENABLED, true)); vo.setRealtimeEnabled(resolveBooleanParam(SysParamKeys.MEETING_CREATE_REALTIME_ENABLED, true)); + vo.setAiCatalogEnabled(resolveBooleanParam(SysParamKeys.MEETING_AI_CATALOG_ENABLED, false)); vo.setOfflineAudioMaxSizeMb(resolveLongParam(SysParamKeys.MEETING_OFFLINE_AUDIO_MAX_SIZE_MB, 1024L)); return ApiResponse.ok(vo); } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingCreateConfigVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingCreateConfigVO.java index cf7d4f5..fa8ef22 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingCreateConfigVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingCreateConfigVO.java @@ -13,6 +13,9 @@ public class MeetingCreateConfigVO { @Schema(description = "是否启用实时会议") private Boolean realtimeEnabled; + @Schema(description = "是否启用 AI 目录") + private Boolean aiCatalogEnabled; + @Schema(description = "离线音频上传大小上限,单位 MB") private Long offlineAudioMaxSizeMb; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java index 66c89c5..e6426b9 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -75,6 +75,9 @@ public class MeetingVO { @Schema(description = "总结模板ID") private Long promptId; + @Schema(description = "是否启用 AI 目录") + private Boolean aiCatalogEnabled; + @Schema(description = "音频保存状态") private String audioSaveStatus; diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index c43a309..e056711 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.common.SysParamKeys; import com.imeeting.common.MeetingProgressStage; import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.MeetingSummarySource; @@ -66,6 +67,8 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private static final Duration ASR_SUBMIT_REQUEST_TIMEOUT = Duration.ofSeconds(30); private static final Duration ASR_QUERY_REQUEST_TIMEOUT = Duration.ofSeconds(30); + private static final String DISPATCH_MODE_PARALLEL = "PARALLEL"; + private static final String DISPATCH_MODE_SERIAL = "SERIAL"; private final MeetingMapper meetingMapper; private final MeetingTranscriptMapper transcriptMapper; @@ -288,8 +291,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme sumTask == null ? null : sumTask.getId()); meetingProgressService.markStage(meetingId, asrTask, 1, MeetingProgressStage.ASR_COMPLETED, 80, "转写完成,准备生成总结", 0); scheduleQueuedAsrTasks(); - self.dispatchChapterTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); - self.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + dispatchPostAsrTasks(meeting, chapterTask, sumTask); return; } if (sumTask != null && canExecuteTask(sumTask)) { @@ -343,8 +345,20 @@ public class AiTaskServiceImpl extends ServiceImpl impleme return; } executeChapterFlow(meeting, chapterTask); + if (shouldRunSummaryAfterChapter(meeting, chapterTask)) { + AiTask summaryTask = findLatestTask(meetingId, "SUMMARY"); + if (summaryTask != null && canExecuteTask(summaryTask)) { + self.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + return; + } + } reconcileMeetingStatus(meetingId); - androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.COMPLETED.getCode()); + androidMeetingPushService.pushMeetingStatusChanged( + meetingId, + MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.FAILED) + ? UnifiedMeetingStatusStage.FAILED_SUMMARIZING.getCode() + : UnifiedMeetingStatusStage.COMPLETED.getCode() + ); log.info("[CHAPTER-FLOW] 章节任务流程结束: meetingId={}, chapterTaskId={}, costMs={}", meetingId, chapterTask.getId(), System.currentTimeMillis() - startMillis); } @@ -1178,7 +1192,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme log.info("[SUMMARY-EXEC] 已获取总结锁,开始构建总结来源: meetingId={}, sumTaskId={}", meeting.getId(), sumTask == null ? null : sumTask.getId()); try { - MeetingSummarySource summarySource = buildRawTranscriptSummarySource(meeting); + MeetingSummarySource summarySource = buildSummarySourceForExecution(meeting, sumTask); if (summarySource.getText() == null || summarySource.getText().isBlank()) { log.warn("[SUMMARY-EXEC] 无转录内容,无法生成总结: meetingId={}, sumTaskId={}", meeting.getId(), sumTask == null ? null : sumTask.getId()); @@ -1208,6 +1222,85 @@ public class AiTaskServiceImpl extends ServiceImpl impleme .build(); } + private MeetingSummarySource buildSummarySourceForExecution(Meeting meeting, AiTask sumTask) { + if (shouldUseChapterBackedSummarySource()) { + return meetingTranscriptChapterService.resolveSummarySource(meeting, sumTask); + } + return buildRawTranscriptSummarySource(meeting); + } + + private void dispatchPostAsrTasks(Meeting meeting, AiTask chapterTask, AiTask summaryTask) { + if (meeting == null) { + return; + } + if (!resolveAiCatalogEnabled()) { + if (summaryTask != null && canExecuteTask(summaryTask)) { + self.dispatchSummaryTask(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId()); + } + return; + } + if (isSerialDispatchMode()) { + if (chapterTask != null && canExecuteTask(chapterTask)) { + self.dispatchChapterTask(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId()); + } else { + log.warn("[ASR-FLOW] 串行模式下缺少可执行章节任务,跳过总结派发: meetingId={}, chapterTaskId={}, chapterStatus={}", + meeting.getId(), + chapterTask == null ? null : chapterTask.getId(), + chapterTask == null ? null : chapterTask.getStatus()); + } + return; + } + if (chapterTask != null && canExecuteTask(chapterTask)) { + self.dispatchChapterTask(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId()); + } + if (summaryTask != null && canExecuteTask(summaryTask)) { + self.dispatchSummaryTask(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId()); + } + } + + private boolean shouldRunSummaryAfterChapter(Meeting meeting, AiTask chapterTask) { + return meeting != null + && resolveAiCatalogEnabled() + && isSerialDispatchMode() + && isTaskCompleted(chapterTask) + && !MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.FAILED); + } + + private boolean shouldUseChapterBackedSummarySource() { + return resolveAiCatalogEnabled() && isSerialDispatchMode(); + } + + private boolean resolveAiCatalogEnabled() { + if (sysParamService == null) { + return false; + } + String rawValue = sysParamService.getCachedParamValue(SysParamKeys.MEETING_AI_CATALOG_ENABLED, "false"); + if (rawValue == null || rawValue.isBlank()) { + return false; + } + String normalized = rawValue.trim().toLowerCase(); + return "1".equals(normalized) + || "true".equals(normalized) + || "yes".equals(normalized) + || "on".equals(normalized); + } + + private boolean isSerialDispatchMode() { + return DISPATCH_MODE_SERIAL.equals(resolveSummaryDispatchMode()); + } + + private String resolveSummaryDispatchMode() { + if (sysParamService == null) { + return DISPATCH_MODE_PARALLEL; + } + String rawValue = sysParamService.getCachedParamValue(SysParamKeys.MEETING_SUMMARY_DISPATCH_MODE, DISPATCH_MODE_PARALLEL); + if (rawValue == null || rawValue.isBlank()) { + return DISPATCH_MODE_PARALLEL; + } + String normalized = rawValue.trim().toUpperCase(Locale.ROOT); + return DISPATCH_MODE_SERIAL.equals(normalized) ? DISPATCH_MODE_SERIAL : DISPATCH_MODE_PARALLEL; + } + private AiTask findLatestTask(Long meetingId, String taskType) { return this.getOne(new LambdaQueryWrapper() .eq(AiTask::getMeetingId, meetingId) diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index 95631b8..19bc0c7 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.common.MeetingConstants; import com.imeeting.common.RedisKeys; +import com.imeeting.common.SysParamKeys; import com.imeeting.dto.android.AndroidPendingMeetingDraft; import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; @@ -48,6 +49,7 @@ import com.imeeting.support.redis.MeetingAsrPermitCache; import com.imeeting.support.redis.MeetingLockCache; import com.unisbase.common.exception.BusinessException; import com.unisbase.common.exception.ErrorCodeEnum; +import com.unisbase.service.SysParamService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -90,6 +92,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { private final AndroidPendingMeetingDraftService androidPendingMeetingDraftService; private final MeetingLockCache meetingLockCache; private final MeetingAsrPermitCache meetingAsrPermitCache; + private final SysParamService sysParamService; @Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}") private String summaryOrchestrationMode; @@ -115,7 +118,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { AndroidPushMessageService androidPushMessageService, AndroidPendingMeetingDraftService androidPendingMeetingDraftService, MeetingLockCache meetingLockCache, - MeetingAsrPermitCache meetingAsrPermitCache) { + MeetingAsrPermitCache meetingAsrPermitCache, + SysParamService sysParamService) { this.meetingService = meetingService; this.aiTaskService = aiTaskService; this.hotWordService = hotWordService; @@ -137,6 +141,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { this.androidPendingMeetingDraftService = androidPendingMeetingDraftService; this.meetingLockCache = meetingLockCache; this.meetingAsrPermitCache = meetingAsrPermitCache; + this.sysParamService = sysParamService; } @Override @@ -179,7 +184,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { aiTaskService.save(asrTask); Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId(); - meetingDomainSupport.createChapterTask( + createChapterTaskIfEnabled( meeting.getId(), runtimeProfile.getResolvedSummaryModelId(), chapterModelId, @@ -231,7 +236,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { hostUserId, hostName, runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId(), summaryDetailLevel, 0); meetingService.save(meeting); Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId(); - meetingDomainSupport.createChapterTask( + createChapterTaskIfEnabled( meeting.getId(), runtimeProfile.getResolvedSummaryModelId(), chapterModelId, @@ -497,10 +502,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { realtimeMeetingSessionStateService.clear(meetingId); meeting.setStatus(MeetingStatusEnum.SUMMARIZING.getCode()); meetingService.updateById(meeting); - updateMeetingProgress(meetingId, 85, "正在生成 AI 目录与总结...", 0); + updateMeetingProgress(meetingId, resolveAiCatalogEnabled() ? 85 : 90, resolveAiCatalogEnabled() ? "正在生成 AI 目录与总结..." : "正在生成会议总结...", 0); meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl()); - aiTaskService.dispatchChapterTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); - aiTaskService.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + if (!resolveAiCatalogEnabled()) { + aiTaskService.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + } else if (isParallelDispatchMode()) { + aiTaskService.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + aiTaskService.dispatchChapterTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + } else { + aiTaskService.dispatchChapterTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + } } @Override @@ -786,6 +797,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { @Transactional(rollbackFor = Exception.class) public MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command) { ensureExternalSummaryModeEnabled(); + ensureAiCatalogEnabled(); if (command == null || command.getMeetingId() == null) { throw new RuntimeException("缺少会议ID,无法导入章节"); } @@ -806,6 +818,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { .last("LIMIT 1")); if (latestChapterTask == null) { + ensureAiCatalogEnabled(); Long summaryModelId = resolveSummaryModelId(command, latestSummaryTask); Long chapterModelId = resolveChapterModelId(command, latestSummaryTask, summaryModelId); Long promptId = resolvePromptId(command, latestSummaryTask); @@ -1139,7 +1152,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { .eq(AiTask::getTaskType, "CHAPTER") .orderByDesc(AiTask::getId) .last("LIMIT 1")); - if (chapterTask == null) { + if (resolveAiCatalogEnabled() && chapterTask == null) { chapterTask = meetingDomainSupport.createChapterTask( meetingId, effectiveSummaryModelId, @@ -1148,7 +1161,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { effectiveUserPrompt, effectiveSummaryDetailLevel ); - } else { + } else if (resolveAiCatalogEnabled()) { resetAiTask(chapterTask, meetingSummaryPromptAssembler.buildTaskConfig( effectiveSummaryModelId, effectiveChapterModelId, @@ -1204,6 +1217,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { if (!Integer.valueOf(3).equals(summaryTask.getStatus())) { throw new RuntimeException("当前总结环节未失败,无需重试"); } + if (resolveAiCatalogEnabled() && isSerialDispatchMode()) { + AiTask chapterTask = aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, "CHAPTER") + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + if (chapterTask == null || !Integer.valueOf(2).equals(chapterTask.getStatus())) { + throw new RuntimeException("串行模式下缺少成功的 AI 目录产物,无法重试总结"); + } + } Long effectiveSummaryModelId = resolveMeetingSummaryModelId(meeting, summaryTask); resetAiTask(summaryTask, buildSummaryTaskConfigForRetry( summaryTask, @@ -1226,6 +1249,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { @Override @Transactional(rollbackFor = Exception.class) public void retryChapter(Long meetingId) { + ensureAiCatalogEnabled(); Meeting meeting = meetingService.getById(meetingId); if (meeting == null) { throw new RuntimeException("会议不存在"); @@ -1267,6 +1291,25 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { dispatchChapterTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId()); } + private AiTask createChapterTaskIfEnabled(Long meetingId, + Long summaryModelId, + Long chapterModelId, + Long promptId, + String userPrompt, + String summaryDetailLevel) { + if (!resolveAiCatalogEnabled()) { + return null; + } + return meetingDomainSupport.createChapterTask( + meetingId, + summaryModelId, + chapterModelId, + promptId, + userPrompt, + summaryDetailLevel + ); + } + private void clearLegacyDispatchState(Long meetingId) { if (meetingId == null) { return; @@ -1605,4 +1648,45 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { } return MeetingConstants.SUMMARY_DETAIL_STANDARD; } + + private void ensureAiCatalogEnabled() { + if (!resolveAiCatalogEnabled()) { + throw new RuntimeException("AI目录功能未开启"); + } + } + + private boolean resolveAiCatalogEnabled() { + if (sysParamService == null) { + return false; + } + String rawValue = sysParamService.getCachedParamValue(SysParamKeys.MEETING_AI_CATALOG_ENABLED, "false"); + if (rawValue == null || rawValue.isBlank()) { + return false; + } + String normalized = rawValue.trim().toLowerCase(); + return "1".equals(normalized) + || "true".equals(normalized) + || "yes".equals(normalized) + || "on".equals(normalized); + } + + private boolean isSerialDispatchMode() { + return "SERIAL".equals(resolveSummaryDispatchMode()); + } + + private boolean isParallelDispatchMode() { + return "PARALLEL".equals(resolveSummaryDispatchMode()); + } + + private String resolveSummaryDispatchMode() { + if (sysParamService == null) { + return "PARALLEL"; + } + String rawValue = sysParamService.getCachedParamValue(SysParamKeys.MEETING_SUMMARY_DISPATCH_MODE, "PARALLEL"); + if (rawValue == null || rawValue.isBlank()) { + return "PARALLEL"; + } + String normalized = rawValue.trim().toUpperCase(); + return "SERIAL".equals(normalized) ? "SERIAL" : "PARALLEL"; + } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java index 9e5c6a0..15e3c9e 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -2,6 +2,7 @@ package com.imeeting.service.biz.impl; 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.Meeting; import com.imeeting.entity.biz.MeetingTranscript; @@ -13,6 +14,7 @@ import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.unisbase.entity.SysUser; import com.unisbase.mapper.SysUserMapper; +import com.unisbase.service.SysParamService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -54,6 +56,7 @@ public class MeetingDomainSupport { private final ApplicationEventPublisher eventPublisher; private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingPlaybackAudioResolver meetingPlaybackAudioResolver; + private final SysParamService sysParamService; @Value("${unisbase.app.upload-path}") private String uploadPath; @@ -399,6 +402,7 @@ public class MeetingDomainSupport { vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus()); vo.setSummaryModelId(meeting.getSummaryModelId()); vo.setPromptId(meeting.getPromptId()); + vo.setAiCatalogEnabled(resolveAiCatalogEnabled()); vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel())); vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); @@ -648,6 +652,21 @@ public class MeetingDomainSupport { return path != null && path.contains("\\"); } + public boolean resolveAiCatalogEnabled() { + if (sysParamService == null) { + return false; + } + String rawValue = sysParamService.getCachedParamValue(SysParamKeys.MEETING_AI_CATALOG_ENABLED, "false"); + if (rawValue == null || rawValue.isBlank()) { + return false; + } + String normalized = rawValue.trim().toLowerCase(); + return "1".equals(normalized) + || "true".equals(normalized) + || "yes".equals(normalized) + || "on".equals(normalized); + } + private Path resolvePublicAudioPath(String audioUrl) { if (audioUrl == null || audioUrl.isBlank()) { return null; diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUnifiedStatusServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUnifiedStatusServiceImpl.java index f175662..787d967 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUnifiedStatusServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUnifiedStatusServiceImpl.java @@ -2,6 +2,7 @@ package com.imeeting.service.biz.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.imeeting.common.MeetingConstants; +import com.imeeting.common.SysParamKeys; import com.imeeting.dto.biz.MeetingProgressSnapshot; import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.UnifiedMeetingStatusStage; @@ -17,6 +18,7 @@ import com.imeeting.mapper.biz.MeetingTranscriptChapterVersionMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.service.biz.MeetingUnifiedStatusService; import com.imeeting.support.redis.MeetingProgressCache; +import com.unisbase.service.SysParamService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -31,6 +33,7 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ private final MeetingTranscriptMapper meetingTranscriptMapper; private final MeetingTranscriptChapterVersionMapper chapterVersionMapper; private final MeetingProgressCache meetingProgressCache; + private final SysParamService sysParamService; @Override public UnifiedMeetingStatusVO resolve(MeetingVO meeting, MeetingProgressSnapshot snapshot) { @@ -238,6 +241,9 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ } private boolean canViewAiChapters(Long meetingId) { + if (!resolveAiCatalogEnabled()) { + return false; + } return meetingId != null && chapterVersionMapper.selectCount(new LambdaQueryWrapper() .eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId) .eq(MeetingTranscriptChapterVersion::getIsCurrent, 1) @@ -290,6 +296,7 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ vo.setSourceDeviceCode(meeting.getSourceDeviceCode()); vo.setSourceDeviceMode(meeting.getSourceDeviceMode()); vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus()); + vo.setAiCatalogEnabled(resolveAiCatalogEnabled()); vo.setSummaryDetailLevel(meeting.getSummaryDetailLevel()); vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); @@ -300,6 +307,21 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ return vo; } + private boolean resolveAiCatalogEnabled() { + if (sysParamService == null) { + return false; + } + String rawValue = sysParamService.getCachedParamValue(SysParamKeys.MEETING_AI_CATALOG_ENABLED, "false"); + if (rawValue == null || rawValue.isBlank()) { + return false; + } + String normalized = rawValue.trim().toLowerCase(); + return "1".equals(normalized) + || "true".equals(normalized) + || "yes".equals(normalized) + || "on".equals(normalized); + } + private record MeetingUnifiedStageContext(AiTask asrTask, AiTask chapterTask, AiTask summaryTask, diff --git a/backend/src/main/proto/android/push.proto b/backend/src/main/proto/android/push.proto index fcc64f3..0d0b81b 100644 --- a/backend/src/main/proto/android/push.proto +++ b/backend/src/main/proto/android/push.proto @@ -11,8 +11,23 @@ option java_outer_classname = "PushProto"; // ========================= enum Platform { PLATFORM_UNKNOWN = 0; + + // Mobile ANDROID = 1; IOS = 2; + HARMONY_MOBILE = 3; + + // Desktop + WINDOWS = 10; + MACOS = 11; + LINUX = 12; + + // Linux发行版(可选) + KYLIN = 20; + UOS = 21; + + // Harmony PC + HARMONY_PC = 30; } // ========================= diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index 537dbd7..15bf03d 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -9,6 +9,7 @@ export type SummaryDetailLevel = "DETAILED" | "STANDARD" | "BRIEF"; export interface MeetingCreateConfig { offlineEnabled: boolean; realtimeEnabled: boolean; + aiCatalogEnabled?: boolean; offlineAudioMaxSizeMb: number; chunkUploadEnabled?: boolean; chunkDurationSeconds?: number; @@ -35,6 +36,7 @@ export interface MeetingVO { summaryDetailLevel?: SummaryDetailLevel; summaryModelId: number; promptId?: number; + aiCatalogEnabled?: boolean; audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED"; audioSaveMessage?: string; accessPassword?: string; diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index edcd925..63df3a1 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -1226,7 +1226,7 @@ const MeetingDetail: React.FC = () => { const [expandKeywords, setExpandKeywords] = useState(false); const [expandSummary, setExpandSummary] = useState(false); const [selectedKeywords, setSelectedKeywords] = useState([]); - const [workspaceTab, setWorkspaceTab] = useState('catalog'); + const [workspaceTab, setWorkspaceTab] = useState('transcript'); const [addingHotwords, setAddingHotwords] = useState(false); const [editingTranscriptId, setEditingTranscriptId] = useState(null); const [savingTranscriptId, setSavingTranscriptId] = useState(null); @@ -1402,15 +1402,16 @@ const MeetingDetail: React.FC = () => { return false; }, [meeting]); + const aiCatalogEnabled = meeting?.aiCatalogEnabled !== false; const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2 && meeting?.latestSummaryAttemptStatus !== 3 - && meeting?.latestChapterAttemptStatus !== 3; + && (!aiCatalogEnabled || meeting?.latestChapterAttemptStatus !== 3); const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl; const canRetryFailedSummaryTask = isOwner && meeting?.latestSummaryAttemptStatus === 3 && meeting?.status !== 2; - const canRetryFailedChapterTask = isOwner && meeting?.latestChapterAttemptStatus === 3 && meeting?.status !== 2; + const canRetryFailedChapterTask = aiCatalogEnabled && isOwner && meeting?.latestChapterAttemptStatus === 3 && meeting?.status !== 2; const canRetrySchedule = isOwner && meeting?.status === 0 && (!generationProgress || generationProgress.percent <= 0) && !!generationProgress?.queuedAt && dayjs().diff(dayjs(generationProgress.queuedAt)) >= QUEUED_RETRY_THRESHOLD_MS; @@ -1433,14 +1434,14 @@ const MeetingDetail: React.FC = () => { }; const hasSummaryContent = Boolean(meeting?.summaryContent?.trim()); - const hasCatalogContent = catalogChapterLinks.length > 0; + const hasCatalogContent = aiCatalogEnabled && catalogChapterLinks.length > 0; const generationFailureNotice = useMemo(() => { if (!meeting || meeting.status !== 4) { return null; } const hasFallbackContent = hasSummaryContent || hasCatalogContent; - if (meeting.latestChapterAttemptStatus === 3) { + if (aiCatalogEnabled && meeting.latestChapterAttemptStatus === 3) { const detail = meeting.latestChapterAttemptErrorMsg || '章节生成失败'; return { title: hasFallbackContent ? '历史内容仍可查看' : '本次 AI 目录生成失败', @@ -1467,45 +1468,13 @@ const MeetingDetail: React.FC = () => { } return { - title: hasFallbackContent ? '历史内容仍可查看' : '会议处理异常', - description: hasFallbackContent - ? '最近一次处理未成功,当前展示的是最近一次成功生成的内容。你可以继续查看,或重新发起识别/总结。' - : '会议在处理中遇到问题,暂时没有可展示的总结内容。你可以重新发起识别或总结。', + title: '会议处理异常', + description: '会议在处理过程中遇到了问题。您可以尝试重新发起识别或总结。', type: hasFallbackContent ? 'info' : 'warning', hasFallbackContent, scope: 'global', }; - if (meeting.latestChapterAttemptStatus === 3) { - const detail = meeting.latestChapterAttemptErrorMsg || '章节生成失败'; - return { - key: `chapter-${meeting.latestChapterAttemptTaskId ?? 'latest'}`, - title: '本次重新总结失败', - description: hasFallbackContent - ? `章节生成失败,当前展示的是上一次成功的摘要和 AI 目录。失败原因:${detail}` - : `章节生成失败,且当前没有可展示的历史摘要或 AI 目录。失败原因:${detail}`, - hasFallbackContent, - }; - } - - if (meeting.latestSummaryAttemptStatus === 3) { - const detail = meeting.latestSummaryAttemptErrorMsg || '总结生成失败'; - return { - key: `summary-${meeting.latestSummaryAttemptTaskId ?? 'latest'}`, - title: '本次重新总结失败', - description: hasFallbackContent - ? `总结生成失败,当前展示的是上一次成功的摘要和 AI 目录。失败原因:${detail}` - : `总结生成失败,且当前没有可展示的历史摘要或 AI 目录。失败原因:${detail}`, - hasFallbackContent, - }; - } - - return { - key: 'general-failure', - title: '会议处理异常', - description: '会议在处理过程中遇到了问题。您可以尝试重新发起识别或总结。', - hasFallbackContent, - }; - }, [hasCatalogContent, hasSummaryContent, meeting]); + }, [aiCatalogEnabled, hasCatalogContent, hasSummaryContent, meeting]); const summaryPanelNotice = useMemo(() => { if (!meeting || !hasSummaryContent) { return null; @@ -1522,7 +1491,7 @@ const MeetingDetail: React.FC = () => { return null; }, [generationFailureNotice, hasSummaryContent, meeting]); const catalogPanelNotice = useMemo(() => { - if (!generationFailureNotice || generationFailureNotice.scope !== 'catalog') { + if (!aiCatalogEnabled || !generationFailureNotice || generationFailureNotice.scope !== 'catalog') { return null; } if (generationFailureNotice.hasFallbackContent && hasCatalogContent) { @@ -1535,7 +1504,7 @@ const MeetingDetail: React.FC = () => { }; } return generationFailureNotice; - }, [generationFailureNotice, hasCatalogContent]); + }, [aiCatalogEnabled, generationFailureNotice, hasCatalogContent]); const emptyTranscriptFailureNotice = useMemo(() => { if (!meeting || meeting.status !== 4 || transcripts.length > 0) { return null; @@ -1557,12 +1526,18 @@ const MeetingDetail: React.FC = () => { } }, [meeting?.id, meeting?.status]); + useEffect(() => { + if (!aiCatalogEnabled && workspaceTab === 'catalog') { + setWorkspaceTab('transcript'); + } + }, [aiCatalogEnabled, workspaceTab]); + useEffect(() => { const attemptKey = String(meeting?.latestChapterAttemptTaskId ?? meeting?.latestSummaryAttemptTaskId ?? ''); if (meeting?.status !== 2 || !attemptKey) { return; } - if ((generationProgress?.percent ?? 0) < 88 || catalogChapterLinks.length === 0) { + if (!aiCatalogEnabled || (generationProgress?.percent ?? 0) < 88 || catalogChapterLinks.length === 0) { return; } if (autoOpenedCatalogAttemptRef.current === attemptKey) { @@ -1571,6 +1546,7 @@ const MeetingDetail: React.FC = () => { autoOpenedCatalogAttemptRef.current = attemptKey; setWorkspaceTab('catalog'); }, [ + aiCatalogEnabled, catalogChapterLinks.length, generationProgress?.percent, meeting?.latestChapterAttemptTaskId, @@ -2766,13 +2742,15 @@ const MeetingDetail: React.FC = () => { )}
- + {aiCatalogEnabled && ( + + )}
- {workspaceTab === 'catalog' ? ( + {aiCatalogEnabled && workspaceTab === 'catalog' ? (
{catalogPanelNotice && ( splitDisplayItems(meeting?.tags), [meeting?.tags]); const keywords = useMemo(() => analysis.keywords || [], [analysis.keywords]); const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]); + const aiCatalogEnabled = meeting?.aiCatalogEnabled !== false; const statusMeta = STATUS_META[meeting?.status || 0] || { label: TEXT.statusPending, className: "is-warning", @@ -416,6 +417,12 @@ export default function MeetingPreview() { }); }, [analysis.chapters, meetingChapters, transcripts]); + useEffect(() => { + if (!aiCatalogEnabled && pageTab === "catalog") { + setPageTab("summary"); + } + }, [aiCatalogEnabled, pageTab]); + useEffect(() => { if (!activeTranscriptId) { return; @@ -967,7 +974,7 @@ export default function MeetingPreview() { onChange={(key) => setPageTab(key as PreviewPageTab)} items={[ { key: "summary", label: TEXT.pageSummary }, - { key: "catalog", label: TEXT.pageCatalog }, + ...(aiCatalogEnabled ? [{ key: "catalog", label: TEXT.pageCatalog }] : []), { key: "transcript", label: TEXT.pageTranscript }, ]} /> @@ -975,7 +982,7 @@ export default function MeetingPreview() {
{pageTab === "summary" ? summaryTabContent : null} - {pageTab === "catalog" ? catalogTabContent : null} + {aiCatalogEnabled && pageTab === "catalog" ? catalogTabContent : null} {pageTab === "transcript" ? transcriptTabContent : null}
diff --git a/imeeting-h5/src/components/PageHeader.tsx b/imeeting-h5/src/components/PageHeader.tsx index 10c476f..6db38e2 100644 --- a/imeeting-h5/src/components/PageHeader.tsx +++ b/imeeting-h5/src/components/PageHeader.tsx @@ -7,9 +7,10 @@ interface PageHeaderProps { title: string; extra?: ReactNode; back?: boolean; + onBack?: () => void; } -export default function PageHeader({ title, extra, back = false }: PageHeaderProps) { +export default function PageHeader({ title, extra, back = false, onBack }: PageHeaderProps) { const navigate = useNavigate(); return ( @@ -20,7 +21,7 @@ export default function PageHeader({ title, extra, back = false }: PageHeaderPro type="text" shape="circle" icon={} - onClick={() => navigate(-1)} + onClick={() => (onBack ? onBack() : navigate(-1))} className="page-header__back" /> ) : null} diff --git a/imeeting-h5/src/components/preview/MeetingPreviewView.tsx b/imeeting-h5/src/components/preview/MeetingPreviewView.tsx index 08c9165..71848d5 100644 --- a/imeeting-h5/src/components/preview/MeetingPreviewView.tsx +++ b/imeeting-h5/src/components/preview/MeetingPreviewView.tsx @@ -1,4 +1,4 @@ -import {useMemo, useRef, useState} from "react"; +import {useEffect, useMemo, useRef, useState} from "react"; import { AudioOutlined, CalendarOutlined, @@ -195,6 +195,7 @@ export default function MeetingPreviewView({ }, [meeting?.participants, transcripts]); const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]); const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]); + const aiCatalogEnabled = meeting?.aiCatalogEnabled !== false; const statusMeta = STATUS_META[meeting?.status || 0] || { label: TEXT.statusPending, className: "is-warning", @@ -267,6 +268,12 @@ export default function MeetingPreviewView({ }); }, [analysis.chapters, meetingChapters, transcripts]); + useEffect(() => { + if (!aiCatalogEnabled && pageTab === "catalog") { + setPageTab("summary"); + } + }, [aiCatalogEnabled, pageTab]); + const handleTranscriptSeek = (item: MeetingTranscriptVO) => { if (!audioRef.current) return; audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000); @@ -459,7 +466,7 @@ export default function MeetingPreviewView({ onChange={(key) => setPageTab(key as PreviewPageTab)} items={[ {key: "summary", label: TEXT.pageSummary}, - {key: "catalog", label: TEXT.pageCatalog}, + ...(aiCatalogEnabled ? [{key: "catalog", label: TEXT.pageCatalog}] : []), {key: "transcript", label: TEXT.pageTranscript}, ]} /> @@ -489,7 +496,7 @@ export default function MeetingPreviewView({ ) : null} - {pageTab === "catalog" ? ( + {aiCatalogEnabled && pageTab === "catalog" ? (
{catalogChapterLinks.length ? ( catalogChapterLinks.map((chapter, index) => ( diff --git a/imeeting-h5/src/pages/meeting-preview/index.tsx b/imeeting-h5/src/pages/meeting-preview/index.tsx index 8af19c0..d74e309 100644 --- a/imeeting-h5/src/pages/meeting-preview/index.tsx +++ b/imeeting-h5/src/pages/meeting-preview/index.tsx @@ -1,18 +1,21 @@ import { App, Button, Card, Input, Space, Typography } from "antd"; import { useEffect, useMemo, useState } from "react"; -import { useParams, useSearchParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { getMeetingPreviewAccess, getPublicMeetingPreview } from "@/api/meeting"; import LoadingScreen from "@/components/LoadingScreen"; +import PageHeader from "@/components/PageHeader"; import MeetingPreviewView from "@/components/preview/MeetingPreviewView"; import usePageTitle from "@/hooks/usePageTitle"; import type { MeetingChapterVO, MeetingTranscriptVO, MeetingVO } from "@/types"; +import { hasAccessToken } from "@/utils/auth"; import { buildMeetingPreviewUrl } from "@/utils/meeting"; const { Paragraph, Title } = Typography; export default function MeetingPreviewPage() { const { message } = App.useApp(); + const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); const [searchParams] = useSearchParams(); const [loading, setLoading] = useState(true); @@ -27,6 +30,10 @@ export default function MeetingPreviewPage() { const presetAccessPassword = useMemo(() => (searchParams.get("accessPassword") || "").trim(), [searchParams]); usePageTitle(meeting?.title || "会议预览"); + const handleBack = () => { + navigate(hasAccessToken() ? "/meetings" : "/login", { replace: true }); + }; + const loadPreview = async (password?: string) => { const previewResp = await getPublicMeetingPreview(meetingId, password); setMeeting(previewResp.data.data.meeting); @@ -69,7 +76,7 @@ export default function MeetingPreviewPage() { }; void run(); - }, [meetingId, presetAccessPassword]); + }, [meetingId, presetAccessPassword, message]); const handleSubmitPassword = async () => { if (!accessPassword.trim()) { @@ -93,21 +100,24 @@ export default function MeetingPreviewPage() { if (passwordRequired && !passwordVerified) { return (
- - 会议预览 - 该会议已设置访问密码,请输入密码后继续访问分享内容。 - - setAccessPassword(event.target.value)} - onPressEnter={() => void handleSubmitPassword()} - /> - - - +
+ + + 会议预览 + 该会议已设置访问密码,请输入密码后继续访问分享内容。 + + setAccessPassword(event.target.value)} + onPressEnter={() => void handleSubmitPassword()} + /> + + + +
); } @@ -119,9 +129,7 @@ export default function MeetingPreviewPage() { return (
-
- iMeeting 分享预览 -
+