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";
/** 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";
/** 是否允许创建离线会议。 */

View File

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

View File

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

View File

@ -75,6 +75,9 @@ public class MeetingVO {
@Schema(description = "总结模板ID")
private Long promptId;
@Schema(description = "是否启用 AI 目录")
private Boolean aiCatalogEnabled;
@Schema(description = "音频保存状态")
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.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<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> 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<AiTask>()
.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.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());
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<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);
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";
}
}

View File

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

View File

@ -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<MeetingTranscriptChapterVersion>()
.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,

View File

@ -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;
}
// =========================

View File

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

View File

@ -1226,7 +1226,7 @@ const MeetingDetail: React.FC = () => {
const [expandKeywords, setExpandKeywords] = useState(false);
const [expandSummary, setExpandSummary] = useState(false);
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('catalog');
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('transcript');
const [addingHotwords, setAddingHotwords] = useState(false);
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(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<MeetingStateNotice | null>(() => {
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<MeetingStateNotice | null>(() => {
if (!meeting || !hasSummaryContent) {
return null;
@ -1522,7 +1491,7 @@ const MeetingDetail: React.FC = () => {
return null;
}, [generationFailureNotice, hasSummaryContent, meeting]);
const catalogPanelNotice = useMemo<MeetingStateNotice | null>(() => {
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,6 +2742,7 @@ const MeetingDetail: React.FC = () => {
)}
<div className="transcript-stage-tabs">
{aiCatalogEnabled && (
<button
type="button"
className={workspaceTab === 'catalog' ? 'active' : ''}
@ -2773,6 +2750,7 @@ const MeetingDetail: React.FC = () => {
>
AI
</button>
)}
<button
type="button"
className={workspaceTab === 'transcript' ? 'active' : ''}
@ -2783,7 +2761,7 @@ const MeetingDetail: React.FC = () => {
</div>
<div className="transcript-scroll-shell">
{workspaceTab === 'catalog' ? (
{aiCatalogEnabled && workspaceTab === 'catalog' ? (
<div className="catalog-list">
{catalogPanelNotice && (
<Alert

View File

@ -339,6 +339,7 @@ export default function MeetingPreview() {
const tags = useMemo(() => 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() {
<div className="meeting-preview-tab-content">
{pageTab === "summary" ? summaryTabContent : null}
{pageTab === "catalog" ? catalogTabContent : null}
{aiCatalogEnabled && pageTab === "catalog" ? catalogTabContent : null}
{pageTab === "transcript" ? transcriptTabContent : null}
</div>
</div>

View File

@ -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={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
onClick={() => (onBack ? onBack() : navigate(-1))}
className="page-header__back"
/>
) : null}

View File

@ -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" ? (
<div className="meeting-preview-catalog-list">
{catalogChapterLinks.length ? (
catalogChapterLinks.map((chapter, index) => (

View File

@ -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,6 +100,8 @@ export default function MeetingPreviewPage() {
if (passwordRequired && !passwordVerified) {
return (
<div className="preview-page">
<div className="preview-page__inner">
<PageHeader title="分享预览" back onBack={handleBack} />
<Card className="surface-card preview-password-card">
<Title level={3}></Title>
<Paragraph type="secondary">访访</Paragraph>
@ -109,6 +118,7 @@ export default function MeetingPreviewPage() {
</Space>
</Card>
</div>
</div>
);
}
@ -119,9 +129,7 @@ export default function MeetingPreviewPage() {
return (
<div className="preview-page">
<div className="preview-page__inner">
<div className="preview-page__header">
<span className="login-page__badge">iMeeting </span>
</div>
<PageHeader title="分享预览" back onBack={handleBack} />
<MeetingPreviewView
meeting={meeting}
transcripts={transcripts}

View File

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