feat: 添加 AI 目录功能和相关逻辑

- 在 `MeetingUnifiedStatusServiceImpl`、`MeetingCommandServiceImpl` 和 `MeetingDomainSupport` 中添加 `resolveAiCatalogEnabled` 方法,用于检查 AI 目录是否启用
- 更新 `MeetingVO` 和 `MeetingCreateConfigVO`,添加 `aiCatalogEnabled` 字段
- 在 `MeetingController` 中添加 `aiCatalogEnabled` 参数,并更新响应构建逻辑
- 在前端页面中添加对 `aiCatalogEnabled` 的处理,包括 `MeetingDetail`、`MeetingPreview` 和 `MeetingPreviewView` 页面
- 在 `sys-params/index.tsx` 中添加 `MEETING_AI_CATALOG_ENABLED` 系统参数配置
- 更新 `AndroidPushGrpcService` 中的平台枚举,增加新的平台类型
- 优化 `AiTaskServiceImpl` 中的任务调度逻辑,支持并行和串行模式
dev_na
chenhao 2026-06-25 10:08:32 +08:00
parent 97065c68a6
commit 2bab042ca0
16 changed files with 338 additions and 90 deletions

View File

@ -10,6 +10,10 @@ public final class SysParamKeys {
public static final String CAPTCHA_ENABLED = "security.captcha.enabled"; public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
/** AI 会议总结使用的系统提示词。 */ /** AI 会议总结使用的系统提示词。 */
public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt"; 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。 */ /** 离线会议音频上传大小上限,单位 MB。 */
public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb"; public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb";
/** 是否允许创建离线会议。 */ /** 是否允许创建离线会议。 */

View File

@ -212,6 +212,7 @@ public class MeetingController {
MeetingCreateConfigVO vo = new MeetingCreateConfigVO(); MeetingCreateConfigVO vo = new MeetingCreateConfigVO();
vo.setOfflineEnabled(resolveBooleanParam(SysParamKeys.MEETING_CREATE_OFFLINE_ENABLED, true)); vo.setOfflineEnabled(resolveBooleanParam(SysParamKeys.MEETING_CREATE_OFFLINE_ENABLED, true));
vo.setRealtimeEnabled(resolveBooleanParam(SysParamKeys.MEETING_CREATE_REALTIME_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)); vo.setOfflineAudioMaxSizeMb(resolveLongParam(SysParamKeys.MEETING_OFFLINE_AUDIO_MAX_SIZE_MB, 1024L));
return ApiResponse.ok(vo); return ApiResponse.ok(vo);
} }

View File

@ -13,6 +13,9 @@ public class MeetingCreateConfigVO {
@Schema(description = "是否启用实时会议") @Schema(description = "是否启用实时会议")
private Boolean realtimeEnabled; private Boolean realtimeEnabled;
@Schema(description = "是否启用 AI 目录")
private Boolean aiCatalogEnabled;
@Schema(description = "离线音频上传大小上限,单位 MB") @Schema(description = "离线音频上传大小上限,单位 MB")
private Long offlineAudioMaxSizeMb; private Long offlineAudioMaxSizeMb;
} }

View File

@ -75,6 +75,9 @@ public class MeetingVO {
@Schema(description = "总结模板ID") @Schema(description = "总结模板ID")
private Long promptId; private Long promptId;
@Schema(description = "是否启用 AI 目录")
private Boolean aiCatalogEnabled;
@Schema(description = "音频保存状态") @Schema(description = "音频保存状态")
private String audioSaveStatus; private String audioSaveStatus;

View File

@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.SysParamKeys;
import com.imeeting.common.MeetingProgressStage; import com.imeeting.common.MeetingProgressStage;
import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.MeetingSummarySource; import com.imeeting.dto.biz.MeetingSummarySource;
@ -66,6 +67,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private static final Duration ASR_SUBMIT_REQUEST_TIMEOUT = Duration.ofSeconds(30); 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 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 MeetingMapper meetingMapper;
private final MeetingTranscriptMapper transcriptMapper; private final MeetingTranscriptMapper transcriptMapper;
@ -288,8 +291,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
sumTask == null ? null : sumTask.getId()); sumTask == null ? null : sumTask.getId());
meetingProgressService.markStage(meetingId, asrTask, 1, MeetingProgressStage.ASR_COMPLETED, 80, "转写完成,准备生成总结", 0); meetingProgressService.markStage(meetingId, asrTask, 1, MeetingProgressStage.ASR_COMPLETED, 80, "转写完成,准备生成总结", 0);
scheduleQueuedAsrTasks(); scheduleQueuedAsrTasks();
self.dispatchChapterTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); dispatchPostAsrTasks(meeting, chapterTask, sumTask);
self.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId());
return; return;
} }
if (sumTask != null && canExecuteTask(sumTask)) { if (sumTask != null && canExecuteTask(sumTask)) {
@ -343,8 +345,20 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return; return;
} }
executeChapterFlow(meeting, chapterTask); 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); 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={}", log.info("[CHAPTER-FLOW] 章节任务流程结束: meetingId={}, chapterTaskId={}, costMs={}",
meetingId, chapterTask.getId(), System.currentTimeMillis() - startMillis); meetingId, chapterTask.getId(), System.currentTimeMillis() - startMillis);
} }
@ -1178,7 +1192,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
log.info("[SUMMARY-EXEC] 已获取总结锁,开始构建总结来源: meetingId={}, sumTaskId={}", log.info("[SUMMARY-EXEC] 已获取总结锁,开始构建总结来源: meetingId={}, sumTaskId={}",
meeting.getId(), sumTask == null ? null : sumTask.getId()); meeting.getId(), sumTask == null ? null : sumTask.getId());
try { try {
MeetingSummarySource summarySource = buildRawTranscriptSummarySource(meeting); MeetingSummarySource summarySource = buildSummarySourceForExecution(meeting, sumTask);
if (summarySource.getText() == null || summarySource.getText().isBlank()) { if (summarySource.getText() == null || summarySource.getText().isBlank()) {
log.warn("[SUMMARY-EXEC] 无转录内容,无法生成总结: meetingId={}, sumTaskId={}", log.warn("[SUMMARY-EXEC] 无转录内容,无法生成总结: meetingId={}, sumTaskId={}",
meeting.getId(), sumTask == null ? null : sumTask.getId()); meeting.getId(), sumTask == null ? null : sumTask.getId());
@ -1208,6 +1222,85 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
.build(); .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) { private AiTask findLatestTask(Long meetingId, String taskType) {
return this.getOne(new LambdaQueryWrapper<AiTask>() return this.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId) .eq(AiTask::getMeetingId, meetingId)

View File

@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingConstants; import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys; import com.imeeting.common.RedisKeys;
import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.android.AndroidPendingMeetingDraft; import com.imeeting.dto.android.AndroidPendingMeetingDraft;
import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
@ -48,6 +49,7 @@ import com.imeeting.support.redis.MeetingAsrPermitCache;
import com.imeeting.support.redis.MeetingLockCache; import com.imeeting.support.redis.MeetingLockCache;
import com.unisbase.common.exception.BusinessException; import com.unisbase.common.exception.BusinessException;
import com.unisbase.common.exception.ErrorCodeEnum; import com.unisbase.common.exception.ErrorCodeEnum;
import com.unisbase.service.SysParamService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -90,6 +92,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
private final AndroidPendingMeetingDraftService androidPendingMeetingDraftService; private final AndroidPendingMeetingDraftService androidPendingMeetingDraftService;
private final MeetingLockCache meetingLockCache; private final MeetingLockCache meetingLockCache;
private final MeetingAsrPermitCache meetingAsrPermitCache; private final MeetingAsrPermitCache meetingAsrPermitCache;
private final SysParamService sysParamService;
@Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}") @Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}")
private String summaryOrchestrationMode; private String summaryOrchestrationMode;
@ -115,7 +118,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
AndroidPushMessageService androidPushMessageService, AndroidPushMessageService androidPushMessageService,
AndroidPendingMeetingDraftService androidPendingMeetingDraftService, AndroidPendingMeetingDraftService androidPendingMeetingDraftService,
MeetingLockCache meetingLockCache, MeetingLockCache meetingLockCache,
MeetingAsrPermitCache meetingAsrPermitCache) { MeetingAsrPermitCache meetingAsrPermitCache,
SysParamService sysParamService) {
this.meetingService = meetingService; this.meetingService = meetingService;
this.aiTaskService = aiTaskService; this.aiTaskService = aiTaskService;
this.hotWordService = hotWordService; this.hotWordService = hotWordService;
@ -137,6 +141,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
this.androidPendingMeetingDraftService = androidPendingMeetingDraftService; this.androidPendingMeetingDraftService = androidPendingMeetingDraftService;
this.meetingLockCache = meetingLockCache; this.meetingLockCache = meetingLockCache;
this.meetingAsrPermitCache = meetingAsrPermitCache; this.meetingAsrPermitCache = meetingAsrPermitCache;
this.sysParamService = sysParamService;
} }
@Override @Override
@ -179,7 +184,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
aiTaskService.save(asrTask); aiTaskService.save(asrTask);
Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId(); Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId();
meetingDomainSupport.createChapterTask( createChapterTaskIfEnabled(
meeting.getId(), meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedSummaryModelId(),
chapterModelId, chapterModelId,
@ -231,7 +236,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
hostUserId, hostName, runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId(), summaryDetailLevel, 0); hostUserId, hostName, runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId(), summaryDetailLevel, 0);
meetingService.save(meeting); meetingService.save(meeting);
Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId(); Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId();
meetingDomainSupport.createChapterTask( createChapterTaskIfEnabled(
meeting.getId(), meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedSummaryModelId(),
chapterModelId, chapterModelId,
@ -497,10 +502,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
realtimeMeetingSessionStateService.clear(meetingId); realtimeMeetingSessionStateService.clear(meetingId);
meeting.setStatus(MeetingStatusEnum.SUMMARIZING.getCode()); meeting.setStatus(MeetingStatusEnum.SUMMARIZING.getCode());
meetingService.updateById(meeting); meetingService.updateById(meeting);
updateMeetingProgress(meetingId, 85, "正在生成 AI 目录与总结...", 0); updateMeetingProgress(meetingId, resolveAiCatalogEnabled() ? 85 : 90, resolveAiCatalogEnabled() ? "正在生成 AI 目录与总结..." : "正在生成会议总结...", 0);
meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl()); meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl());
aiTaskService.dispatchChapterTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); if (!resolveAiCatalogEnabled()) {
aiTaskService.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); 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 @Override
@ -786,6 +797,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command) { public MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command) {
ensureExternalSummaryModeEnabled(); ensureExternalSummaryModeEnabled();
ensureAiCatalogEnabled();
if (command == null || command.getMeetingId() == null) { if (command == null || command.getMeetingId() == null) {
throw new RuntimeException("缺少会议ID无法导入章节"); throw new RuntimeException("缺少会议ID无法导入章节");
} }
@ -806,6 +818,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
.last("LIMIT 1")); .last("LIMIT 1"));
if (latestChapterTask == null) { if (latestChapterTask == null) {
ensureAiCatalogEnabled();
Long summaryModelId = resolveSummaryModelId(command, latestSummaryTask); Long summaryModelId = resolveSummaryModelId(command, latestSummaryTask);
Long chapterModelId = resolveChapterModelId(command, latestSummaryTask, summaryModelId); Long chapterModelId = resolveChapterModelId(command, latestSummaryTask, summaryModelId);
Long promptId = resolvePromptId(command, latestSummaryTask); Long promptId = resolvePromptId(command, latestSummaryTask);
@ -1139,7 +1152,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
.eq(AiTask::getTaskType, "CHAPTER") .eq(AiTask::getTaskType, "CHAPTER")
.orderByDesc(AiTask::getId) .orderByDesc(AiTask::getId)
.last("LIMIT 1")); .last("LIMIT 1"));
if (chapterTask == null) { if (resolveAiCatalogEnabled() && chapterTask == null) {
chapterTask = meetingDomainSupport.createChapterTask( chapterTask = meetingDomainSupport.createChapterTask(
meetingId, meetingId,
effectiveSummaryModelId, effectiveSummaryModelId,
@ -1148,7 +1161,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
effectiveUserPrompt, effectiveUserPrompt,
effectiveSummaryDetailLevel effectiveSummaryDetailLevel
); );
} else { } else if (resolveAiCatalogEnabled()) {
resetAiTask(chapterTask, meetingSummaryPromptAssembler.buildTaskConfig( resetAiTask(chapterTask, meetingSummaryPromptAssembler.buildTaskConfig(
effectiveSummaryModelId, effectiveSummaryModelId,
effectiveChapterModelId, effectiveChapterModelId,
@ -1204,6 +1217,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
if (!Integer.valueOf(3).equals(summaryTask.getStatus())) { if (!Integer.valueOf(3).equals(summaryTask.getStatus())) {
throw new RuntimeException("当前总结环节未失败,无需重试"); throw new RuntimeException("当前总结环节未失败,无需重试");
} }
if (resolveAiCatalogEnabled() && isSerialDispatchMode()) {
AiTask chapterTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.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); Long effectiveSummaryModelId = resolveMeetingSummaryModelId(meeting, summaryTask);
resetAiTask(summaryTask, buildSummaryTaskConfigForRetry( resetAiTask(summaryTask, buildSummaryTaskConfigForRetry(
summaryTask, summaryTask,
@ -1226,6 +1249,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void retryChapter(Long meetingId) { public void retryChapter(Long meetingId) {
ensureAiCatalogEnabled();
Meeting meeting = meetingService.getById(meetingId); Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) { if (meeting == null) {
throw new RuntimeException("会议不存在"); throw new RuntimeException("会议不存在");
@ -1267,6 +1291,25 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
dispatchChapterTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId()); 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) { private void clearLegacyDispatchState(Long meetingId) {
if (meetingId == null) { if (meetingId == null) {
return; return;
@ -1605,4 +1648,45 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
} }
return MeetingConstants.SUMMARY_DETAIL_STANDARD; 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";
}
} }

View File

@ -2,6 +2,7 @@ package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants; import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript; import com.imeeting.entity.biz.MeetingTranscript;
@ -13,6 +14,7 @@ import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.unisbase.entity.SysUser; import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper; import com.unisbase.mapper.SysUserMapper;
import com.unisbase.service.SysParamService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -54,6 +56,7 @@ public class MeetingDomainSupport {
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingPlaybackAudioResolver meetingPlaybackAudioResolver; private final MeetingPlaybackAudioResolver meetingPlaybackAudioResolver;
private final SysParamService sysParamService;
@Value("${unisbase.app.upload-path}") @Value("${unisbase.app.upload-path}")
private String uploadPath; private String uploadPath;
@ -399,6 +402,7 @@ public class MeetingDomainSupport {
vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus()); vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus());
vo.setSummaryModelId(meeting.getSummaryModelId()); vo.setSummaryModelId(meeting.getSummaryModelId());
vo.setPromptId(meeting.getPromptId()); vo.setPromptId(meeting.getPromptId());
vo.setAiCatalogEnabled(resolveAiCatalogEnabled());
vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel())); vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel()));
vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
@ -648,6 +652,21 @@ public class MeetingDomainSupport {
return path != null && path.contains("\\"); 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) { private Path resolvePublicAudioPath(String audioUrl) {
if (audioUrl == null || audioUrl.isBlank()) { if (audioUrl == null || audioUrl.isBlank()) {
return null; return null;

View File

@ -2,6 +2,7 @@ package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants; import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.biz.MeetingProgressSnapshot; import com.imeeting.dto.biz.MeetingProgressSnapshot;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusStage; 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.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.MeetingUnifiedStatusService; import com.imeeting.service.biz.MeetingUnifiedStatusService;
import com.imeeting.support.redis.MeetingProgressCache; import com.imeeting.support.redis.MeetingProgressCache;
import com.unisbase.service.SysParamService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -31,6 +33,7 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
private final MeetingTranscriptMapper meetingTranscriptMapper; private final MeetingTranscriptMapper meetingTranscriptMapper;
private final MeetingTranscriptChapterVersionMapper chapterVersionMapper; private final MeetingTranscriptChapterVersionMapper chapterVersionMapper;
private final MeetingProgressCache meetingProgressCache; private final MeetingProgressCache meetingProgressCache;
private final SysParamService sysParamService;
@Override @Override
public UnifiedMeetingStatusVO resolve(MeetingVO meeting, MeetingProgressSnapshot snapshot) { public UnifiedMeetingStatusVO resolve(MeetingVO meeting, MeetingProgressSnapshot snapshot) {
@ -238,6 +241,9 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
} }
private boolean canViewAiChapters(Long meetingId) { private boolean canViewAiChapters(Long meetingId) {
if (!resolveAiCatalogEnabled()) {
return false;
}
return meetingId != null && chapterVersionMapper.selectCount(new LambdaQueryWrapper<MeetingTranscriptChapterVersion>() return meetingId != null && chapterVersionMapper.selectCount(new LambdaQueryWrapper<MeetingTranscriptChapterVersion>()
.eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId) .eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId)
.eq(MeetingTranscriptChapterVersion::getIsCurrent, 1) .eq(MeetingTranscriptChapterVersion::getIsCurrent, 1)
@ -290,6 +296,7 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
vo.setSourceDeviceCode(meeting.getSourceDeviceCode()); vo.setSourceDeviceCode(meeting.getSourceDeviceCode());
vo.setSourceDeviceMode(meeting.getSourceDeviceMode()); vo.setSourceDeviceMode(meeting.getSourceDeviceMode());
vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus()); vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus());
vo.setAiCatalogEnabled(resolveAiCatalogEnabled());
vo.setSummaryDetailLevel(meeting.getSummaryDetailLevel()); vo.setSummaryDetailLevel(meeting.getSummaryDetailLevel());
vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
@ -300,6 +307,21 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
return vo; 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, private record MeetingUnifiedStageContext(AiTask asrTask,
AiTask chapterTask, AiTask chapterTask,
AiTask summaryTask, AiTask summaryTask,

View File

@ -11,8 +11,23 @@ option java_outer_classname = "PushProto";
// ========================= // =========================
enum Platform { enum Platform {
PLATFORM_UNKNOWN = 0; PLATFORM_UNKNOWN = 0;
// Mobile
ANDROID = 1; ANDROID = 1;
IOS = 2; IOS = 2;
HARMONY_MOBILE = 3;
// Desktop
WINDOWS = 10;
MACOS = 11;
LINUX = 12;
// Linux
KYLIN = 20;
UOS = 21;
// Harmony PC
HARMONY_PC = 30;
} }
// ========================= // =========================

View File

@ -9,6 +9,7 @@ export type SummaryDetailLevel = "DETAILED" | "STANDARD" | "BRIEF";
export interface MeetingCreateConfig { export interface MeetingCreateConfig {
offlineEnabled: boolean; offlineEnabled: boolean;
realtimeEnabled: boolean; realtimeEnabled: boolean;
aiCatalogEnabled?: boolean;
offlineAudioMaxSizeMb: number; offlineAudioMaxSizeMb: number;
chunkUploadEnabled?: boolean; chunkUploadEnabled?: boolean;
chunkDurationSeconds?: number; chunkDurationSeconds?: number;
@ -35,6 +36,7 @@ export interface MeetingVO {
summaryDetailLevel?: SummaryDetailLevel; summaryDetailLevel?: SummaryDetailLevel;
summaryModelId: number; summaryModelId: number;
promptId?: number; promptId?: number;
aiCatalogEnabled?: boolean;
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED"; audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
audioSaveMessage?: string; audioSaveMessage?: string;
accessPassword?: string; accessPassword?: string;

View File

@ -1226,7 +1226,7 @@ const MeetingDetail: React.FC = () => {
const [expandKeywords, setExpandKeywords] = useState(false); const [expandKeywords, setExpandKeywords] = useState(false);
const [expandSummary, setExpandSummary] = useState(false); const [expandSummary, setExpandSummary] = useState(false);
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]); const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('catalog'); const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('transcript');
const [addingHotwords, setAddingHotwords] = useState(false); const [addingHotwords, setAddingHotwords] = useState(false);
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null); const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null); const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
@ -1402,15 +1402,16 @@ const MeetingDetail: React.FC = () => {
return false; return false;
}, [meeting]); }, [meeting]);
const aiCatalogEnabled = meeting?.aiCatalogEnabled !== false;
const canRetrySummary = isOwner const canRetrySummary = isOwner
&& transcripts.length > 0 && transcripts.length > 0
&& meeting?.status !== 1 && meeting?.status !== 1
&& meeting?.status !== 2 && meeting?.status !== 2
&& meeting?.latestSummaryAttemptStatus !== 3 && meeting?.latestSummaryAttemptStatus !== 3
&& meeting?.latestChapterAttemptStatus !== 3; && (!aiCatalogEnabled || meeting?.latestChapterAttemptStatus !== 3);
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl; const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
const canRetryFailedSummaryTask = isOwner && meeting?.latestSummaryAttemptStatus === 3 && meeting?.status !== 2; 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; 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 hasSummaryContent = Boolean(meeting?.summaryContent?.trim());
const hasCatalogContent = catalogChapterLinks.length > 0; const hasCatalogContent = aiCatalogEnabled && catalogChapterLinks.length > 0;
const generationFailureNotice = useMemo<MeetingStateNotice | null>(() => { const generationFailureNotice = useMemo<MeetingStateNotice | null>(() => {
if (!meeting || meeting.status !== 4) { if (!meeting || meeting.status !== 4) {
return null; return null;
} }
const hasFallbackContent = hasSummaryContent || hasCatalogContent; const hasFallbackContent = hasSummaryContent || hasCatalogContent;
if (meeting.latestChapterAttemptStatus === 3) { if (aiCatalogEnabled && meeting.latestChapterAttemptStatus === 3) {
const detail = meeting.latestChapterAttemptErrorMsg || '章节生成失败'; const detail = meeting.latestChapterAttemptErrorMsg || '章节生成失败';
return { return {
title: hasFallbackContent ? '历史内容仍可查看' : '本次 AI 目录生成失败', title: hasFallbackContent ? '历史内容仍可查看' : '本次 AI 目录生成失败',
@ -1467,45 +1468,13 @@ const MeetingDetail: React.FC = () => {
} }
return { return {
title: hasFallbackContent ? '历史内容仍可查看' : '会议处理异常', title: '会议处理异常',
description: hasFallbackContent description: '会议在处理过程中遇到了问题。您可以尝试重新发起识别或总结。',
? '最近一次处理未成功,当前展示的是最近一次成功生成的内容。你可以继续查看,或重新发起识别/总结。'
: '会议在处理中遇到问题,暂时没有可展示的总结内容。你可以重新发起识别或总结。',
type: hasFallbackContent ? 'info' : 'warning', type: hasFallbackContent ? 'info' : 'warning',
hasFallbackContent, hasFallbackContent,
scope: 'global', scope: 'global',
}; };
if (meeting.latestChapterAttemptStatus === 3) { }, [aiCatalogEnabled, hasCatalogContent, hasSummaryContent, meeting]);
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]);
const summaryPanelNotice = useMemo<MeetingStateNotice | null>(() => { const summaryPanelNotice = useMemo<MeetingStateNotice | null>(() => {
if (!meeting || !hasSummaryContent) { if (!meeting || !hasSummaryContent) {
return null; return null;
@ -1522,7 +1491,7 @@ const MeetingDetail: React.FC = () => {
return null; return null;
}, [generationFailureNotice, hasSummaryContent, meeting]); }, [generationFailureNotice, hasSummaryContent, meeting]);
const catalogPanelNotice = useMemo<MeetingStateNotice | null>(() => { const catalogPanelNotice = useMemo<MeetingStateNotice | null>(() => {
if (!generationFailureNotice || generationFailureNotice.scope !== 'catalog') { if (!aiCatalogEnabled || !generationFailureNotice || generationFailureNotice.scope !== 'catalog') {
return null; return null;
} }
if (generationFailureNotice.hasFallbackContent && hasCatalogContent) { if (generationFailureNotice.hasFallbackContent && hasCatalogContent) {
@ -1535,7 +1504,7 @@ const MeetingDetail: React.FC = () => {
}; };
} }
return generationFailureNotice; return generationFailureNotice;
}, [generationFailureNotice, hasCatalogContent]); }, [aiCatalogEnabled, generationFailureNotice, hasCatalogContent]);
const emptyTranscriptFailureNotice = useMemo(() => { const emptyTranscriptFailureNotice = useMemo(() => {
if (!meeting || meeting.status !== 4 || transcripts.length > 0) { if (!meeting || meeting.status !== 4 || transcripts.length > 0) {
return null; return null;
@ -1557,12 +1526,18 @@ const MeetingDetail: React.FC = () => {
} }
}, [meeting?.id, meeting?.status]); }, [meeting?.id, meeting?.status]);
useEffect(() => {
if (!aiCatalogEnabled && workspaceTab === 'catalog') {
setWorkspaceTab('transcript');
}
}, [aiCatalogEnabled, workspaceTab]);
useEffect(() => { useEffect(() => {
const attemptKey = String(meeting?.latestChapterAttemptTaskId ?? meeting?.latestSummaryAttemptTaskId ?? ''); const attemptKey = String(meeting?.latestChapterAttemptTaskId ?? meeting?.latestSummaryAttemptTaskId ?? '');
if (meeting?.status !== 2 || !attemptKey) { if (meeting?.status !== 2 || !attemptKey) {
return; return;
} }
if ((generationProgress?.percent ?? 0) < 88 || catalogChapterLinks.length === 0) { if (!aiCatalogEnabled || (generationProgress?.percent ?? 0) < 88 || catalogChapterLinks.length === 0) {
return; return;
} }
if (autoOpenedCatalogAttemptRef.current === attemptKey) { if (autoOpenedCatalogAttemptRef.current === attemptKey) {
@ -1571,6 +1546,7 @@ const MeetingDetail: React.FC = () => {
autoOpenedCatalogAttemptRef.current = attemptKey; autoOpenedCatalogAttemptRef.current = attemptKey;
setWorkspaceTab('catalog'); setWorkspaceTab('catalog');
}, [ }, [
aiCatalogEnabled,
catalogChapterLinks.length, catalogChapterLinks.length,
generationProgress?.percent, generationProgress?.percent,
meeting?.latestChapterAttemptTaskId, meeting?.latestChapterAttemptTaskId,
@ -2766,6 +2742,7 @@ const MeetingDetail: React.FC = () => {
)} )}
<div className="transcript-stage-tabs"> <div className="transcript-stage-tabs">
{aiCatalogEnabled && (
<button <button
type="button" type="button"
className={workspaceTab === 'catalog' ? 'active' : ''} className={workspaceTab === 'catalog' ? 'active' : ''}
@ -2773,6 +2750,7 @@ const MeetingDetail: React.FC = () => {
> >
AI AI
</button> </button>
)}
<button <button
type="button" type="button"
className={workspaceTab === 'transcript' ? 'active' : ''} className={workspaceTab === 'transcript' ? 'active' : ''}
@ -2783,7 +2761,7 @@ const MeetingDetail: React.FC = () => {
</div> </div>
<div className="transcript-scroll-shell"> <div className="transcript-scroll-shell">
{workspaceTab === 'catalog' ? ( {aiCatalogEnabled && workspaceTab === 'catalog' ? (
<div className="catalog-list"> <div className="catalog-list">
{catalogPanelNotice && ( {catalogPanelNotice && (
<Alert <Alert

View File

@ -339,6 +339,7 @@ export default function MeetingPreview() {
const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]); const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]);
const keywords = useMemo(() => analysis.keywords || [], [analysis.keywords]); const keywords = useMemo(() => analysis.keywords || [], [analysis.keywords]);
const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]); const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]);
const aiCatalogEnabled = meeting?.aiCatalogEnabled !== false;
const statusMeta = STATUS_META[meeting?.status || 0] || { const statusMeta = STATUS_META[meeting?.status || 0] || {
label: TEXT.statusPending, label: TEXT.statusPending,
className: "is-warning", className: "is-warning",
@ -416,6 +417,12 @@ export default function MeetingPreview() {
}); });
}, [analysis.chapters, meetingChapters, transcripts]); }, [analysis.chapters, meetingChapters, transcripts]);
useEffect(() => {
if (!aiCatalogEnabled && pageTab === "catalog") {
setPageTab("summary");
}
}, [aiCatalogEnabled, pageTab]);
useEffect(() => { useEffect(() => {
if (!activeTranscriptId) { if (!activeTranscriptId) {
return; return;
@ -967,7 +974,7 @@ export default function MeetingPreview() {
onChange={(key) => setPageTab(key as PreviewPageTab)} onChange={(key) => setPageTab(key as PreviewPageTab)}
items={[ items={[
{ key: "summary", label: TEXT.pageSummary }, { key: "summary", label: TEXT.pageSummary },
{ key: "catalog", label: TEXT.pageCatalog }, ...(aiCatalogEnabled ? [{ key: "catalog", label: TEXT.pageCatalog }] : []),
{ key: "transcript", label: TEXT.pageTranscript }, { key: "transcript", label: TEXT.pageTranscript },
]} ]}
/> />
@ -975,7 +982,7 @@ export default function MeetingPreview() {
<div className="meeting-preview-tab-content"> <div className="meeting-preview-tab-content">
{pageTab === "summary" ? summaryTabContent : null} {pageTab === "summary" ? summaryTabContent : null}
{pageTab === "catalog" ? catalogTabContent : null} {aiCatalogEnabled && pageTab === "catalog" ? catalogTabContent : null}
{pageTab === "transcript" ? transcriptTabContent : null} {pageTab === "transcript" ? transcriptTabContent : null}
</div> </div>
</div> </div>

View File

@ -7,9 +7,10 @@ interface PageHeaderProps {
title: string; title: string;
extra?: ReactNode; extra?: ReactNode;
back?: boolean; 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(); const navigate = useNavigate();
return ( return (
@ -20,7 +21,7 @@ export default function PageHeader({ title, extra, back = false }: PageHeaderPro
type="text" type="text"
shape="circle" shape="circle"
icon={<ArrowLeftOutlined />} icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)} onClick={() => (onBack ? onBack() : navigate(-1))}
className="page-header__back" className="page-header__back"
/> />
) : null} ) : null}

View File

@ -1,4 +1,4 @@
import {useMemo, useRef, useState} from "react"; import {useEffect, useMemo, useRef, useState} from "react";
import { import {
AudioOutlined, AudioOutlined,
CalendarOutlined, CalendarOutlined,
@ -195,6 +195,7 @@ export default function MeetingPreviewView({
}, [meeting?.participants, transcripts]); }, [meeting?.participants, transcripts]);
const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]); const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]);
const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]); const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]);
const aiCatalogEnabled = meeting?.aiCatalogEnabled !== false;
const statusMeta = STATUS_META[meeting?.status || 0] || { const statusMeta = STATUS_META[meeting?.status || 0] || {
label: TEXT.statusPending, label: TEXT.statusPending,
className: "is-warning", className: "is-warning",
@ -267,6 +268,12 @@ export default function MeetingPreviewView({
}); });
}, [analysis.chapters, meetingChapters, transcripts]); }, [analysis.chapters, meetingChapters, transcripts]);
useEffect(() => {
if (!aiCatalogEnabled && pageTab === "catalog") {
setPageTab("summary");
}
}, [aiCatalogEnabled, pageTab]);
const handleTranscriptSeek = (item: MeetingTranscriptVO) => { const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
if (!audioRef.current) return; if (!audioRef.current) return;
audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000); audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000);
@ -459,7 +466,7 @@ export default function MeetingPreviewView({
onChange={(key) => setPageTab(key as PreviewPageTab)} onChange={(key) => setPageTab(key as PreviewPageTab)}
items={[ items={[
{key: "summary", label: TEXT.pageSummary}, {key: "summary", label: TEXT.pageSummary},
{key: "catalog", label: TEXT.pageCatalog}, ...(aiCatalogEnabled ? [{key: "catalog", label: TEXT.pageCatalog}] : []),
{key: "transcript", label: TEXT.pageTranscript}, {key: "transcript", label: TEXT.pageTranscript},
]} ]}
/> />
@ -489,7 +496,7 @@ export default function MeetingPreviewView({
</> </>
) : null} ) : null}
{pageTab === "catalog" ? ( {aiCatalogEnabled && pageTab === "catalog" ? (
<div className="meeting-preview-catalog-list"> <div className="meeting-preview-catalog-list">
{catalogChapterLinks.length ? ( {catalogChapterLinks.length ? (
catalogChapterLinks.map((chapter, index) => ( catalogChapterLinks.map((chapter, index) => (

View File

@ -1,18 +1,21 @@
import { App, Button, Card, Input, Space, Typography } from "antd"; import { App, Button, Card, Input, Space, Typography } from "antd";
import { useEffect, useMemo, useState } from "react"; 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 { getMeetingPreviewAccess, getPublicMeetingPreview } from "@/api/meeting";
import LoadingScreen from "@/components/LoadingScreen"; import LoadingScreen from "@/components/LoadingScreen";
import PageHeader from "@/components/PageHeader";
import MeetingPreviewView from "@/components/preview/MeetingPreviewView"; import MeetingPreviewView from "@/components/preview/MeetingPreviewView";
import usePageTitle from "@/hooks/usePageTitle"; import usePageTitle from "@/hooks/usePageTitle";
import type { MeetingChapterVO, MeetingTranscriptVO, MeetingVO } from "@/types"; import type { MeetingChapterVO, MeetingTranscriptVO, MeetingVO } from "@/types";
import { hasAccessToken } from "@/utils/auth";
import { buildMeetingPreviewUrl } from "@/utils/meeting"; import { buildMeetingPreviewUrl } from "@/utils/meeting";
const { Paragraph, Title } = Typography; const { Paragraph, Title } = Typography;
export default function MeetingPreviewPage() { export default function MeetingPreviewPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -27,6 +30,10 @@ export default function MeetingPreviewPage() {
const presetAccessPassword = useMemo(() => (searchParams.get("accessPassword") || "").trim(), [searchParams]); const presetAccessPassword = useMemo(() => (searchParams.get("accessPassword") || "").trim(), [searchParams]);
usePageTitle(meeting?.title || "会议预览"); usePageTitle(meeting?.title || "会议预览");
const handleBack = () => {
navigate(hasAccessToken() ? "/meetings" : "/login", { replace: true });
};
const loadPreview = async (password?: string) => { const loadPreview = async (password?: string) => {
const previewResp = await getPublicMeetingPreview(meetingId, password); const previewResp = await getPublicMeetingPreview(meetingId, password);
setMeeting(previewResp.data.data.meeting); setMeeting(previewResp.data.data.meeting);
@ -69,7 +76,7 @@ export default function MeetingPreviewPage() {
}; };
void run(); void run();
}, [meetingId, presetAccessPassword]); }, [meetingId, presetAccessPassword, message]);
const handleSubmitPassword = async () => { const handleSubmitPassword = async () => {
if (!accessPassword.trim()) { if (!accessPassword.trim()) {
@ -93,6 +100,8 @@ export default function MeetingPreviewPage() {
if (passwordRequired && !passwordVerified) { if (passwordRequired && !passwordVerified) {
return ( return (
<div className="preview-page"> <div className="preview-page">
<div className="preview-page__inner">
<PageHeader title="分享预览" back onBack={handleBack} />
<Card className="surface-card preview-password-card"> <Card className="surface-card preview-password-card">
<Title level={3}></Title> <Title level={3}></Title>
<Paragraph type="secondary">访访</Paragraph> <Paragraph type="secondary">访访</Paragraph>
@ -109,6 +118,7 @@ export default function MeetingPreviewPage() {
</Space> </Space>
</Card> </Card>
</div> </div>
</div>
); );
} }
@ -119,9 +129,7 @@ export default function MeetingPreviewPage() {
return ( return (
<div className="preview-page"> <div className="preview-page">
<div className="preview-page__inner"> <div className="preview-page__inner">
<div className="preview-page__header"> <PageHeader title="分享预览" back onBack={handleBack} />
<span className="login-page__badge">iMeeting </span>
</div>
<MeetingPreviewView <MeetingPreviewView
meeting={meeting} meeting={meeting}
transcripts={transcripts} transcripts={transcripts}

View File

@ -47,6 +47,7 @@ export interface MeetingVO {
summaryDetailLevel?: "DETAILED" | "STANDARD" | "BRIEF"; summaryDetailLevel?: "DETAILED" | "STANDARD" | "BRIEF";
summaryModelId?: number; summaryModelId?: number;
promptId?: number; promptId?: number;
aiCatalogEnabled?: boolean;
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED"; audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
audioSaveMessage?: string; audioSaveMessage?: string;
accessPassword?: string | null; accessPassword?: string | null;