Compare commits
2 Commits
552e2255bd
...
3a7baa0341
| Author | SHA1 | Date |
|---|---|---|
|
|
3a7baa0341 | |
|
|
a611ac2b61 |
|
|
@ -47,6 +47,30 @@ public final class RedisKeys {
|
|||
return "biz:meeting:realtime:socket:" + sessionToken;
|
||||
}
|
||||
|
||||
public static String realtimeMeetingSessionStateKey(Long meetingId) {
|
||||
return "biz:meeting:realtime:state:" + meetingId;
|
||||
}
|
||||
|
||||
public static String realtimeMeetingResumeTimeoutKey(Long meetingId) {
|
||||
return realtimeMeetingResumeTimeoutPrefix() + meetingId;
|
||||
}
|
||||
|
||||
public static String realtimeMeetingEmptyTimeoutKey(Long meetingId) {
|
||||
return realtimeMeetingEmptyTimeoutPrefix() + meetingId;
|
||||
}
|
||||
|
||||
public static String realtimeMeetingTimeoutLockKey(Long meetingId) {
|
||||
return "biz:meeting:realtime:timeout:lock:" + meetingId;
|
||||
}
|
||||
|
||||
public static String realtimeMeetingResumeTimeoutPrefix() {
|
||||
return "biz:meeting:realtime:resume-timeout:";
|
||||
}
|
||||
|
||||
public static String realtimeMeetingEmptyTimeoutPrefix() {
|
||||
return "biz:meeting:realtime:empty-timeout:";
|
||||
}
|
||||
|
||||
public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER";
|
||||
public static final String SYS_PARAM_FIELD_VALUE = "value";
|
||||
public static final String SYS_PARAM_FIELD_TYPE = "type";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
package com.imeeting.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
||||
|
||||
@Configuration
|
||||
public class RedisKeyExpirationConfig {
|
||||
|
||||
@Bean
|
||||
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) {
|
||||
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
|
||||
container.setConnectionFactory(connectionFactory);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import com.imeeting.dto.biz.MeetingTranscriptVO;
|
|||
import com.imeeting.dto.biz.MeetingVO;
|
||||
import com.imeeting.dto.biz.OpenRealtimeSocketSessionCommand;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingCompleteDTO;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
||||
import com.imeeting.dto.biz.RealtimeSocketSessionVO;
|
||||
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
||||
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
|
||||
|
|
@ -22,6 +23,7 @@ import com.imeeting.service.biz.MeetingCommandService;
|
|||
import com.imeeting.service.biz.MeetingExportService;
|
||||
import com.imeeting.service.biz.MeetingQueryService;
|
||||
import com.imeeting.service.biz.PromptTemplateService;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
|
||||
import com.unisbase.common.ApiResponse;
|
||||
import com.unisbase.dto.PageResult;
|
||||
|
|
@ -63,6 +65,7 @@ public class MeetingController {
|
|||
private final MeetingExportService meetingExportService;
|
||||
private final PromptTemplateService promptTemplateService;
|
||||
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
|
||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final String uploadPath;
|
||||
private final String resourcePrefix;
|
||||
|
|
@ -73,6 +76,7 @@ public class MeetingController {
|
|||
MeetingExportService meetingExportService,
|
||||
PromptTemplateService promptTemplateService,
|
||||
RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService,
|
||||
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
|
||||
StringRedisTemplate redisTemplate,
|
||||
@Value("${unisbase.app.upload-path}") String uploadPath,
|
||||
@Value("${unisbase.app.resource-prefix}") String resourcePrefix) {
|
||||
|
|
@ -82,6 +86,7 @@ public class MeetingController {
|
|||
this.meetingExportService = meetingExportService;
|
||||
this.promptTemplateService = promptTemplateService;
|
||||
this.realtimeMeetingSocketSessionService = realtimeMeetingSocketSessionService;
|
||||
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.uploadPath = uploadPath;
|
||||
this.resourcePrefix = resourcePrefix;
|
||||
|
|
@ -221,6 +226,15 @@ public class MeetingController {
|
|||
return ApiResponse.ok(meetingQueryService.getTranscripts(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/realtime/session-status")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<RealtimeMeetingSessionStatusVO> getRealtimeSessionStatus(@PathVariable Long id) {
|
||||
LoginUser loginUser = currentLoginUser();
|
||||
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
|
||||
return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/realtime/transcripts")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> appendRealtimeTranscripts(@PathVariable Long id, @RequestBody List<RealtimeTranscriptItemDTO> items) {
|
||||
|
|
@ -231,6 +245,15 @@ public class MeetingController {
|
|||
return ApiResponse.ok(true);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/realtime/pause")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<RealtimeMeetingSessionStatusVO> pauseRealtimeMeeting(@PathVariable Long id) {
|
||||
LoginUser loginUser = currentLoginUser();
|
||||
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
|
||||
return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/realtime/socket-session")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<RealtimeSocketSessionVO> openRealtimeSocketSession(@PathVariable Long id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package com.imeeting.dto.biz;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class RealtimeMeetingResumeConfig {
|
||||
private Long asrModelId;
|
||||
private String mode;
|
||||
private String language;
|
||||
private Integer useSpkId;
|
||||
private Boolean enablePunctuation;
|
||||
private Boolean enableItn;
|
||||
private Boolean enableTextRefine;
|
||||
private Boolean saveAudio;
|
||||
private List<Map<String, Object>> hotwords;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.imeeting.dto.biz;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RealtimeMeetingSessionState {
|
||||
private Long meetingId;
|
||||
private Long tenantId;
|
||||
private Long userId;
|
||||
private String status;
|
||||
private Boolean hasTranscript;
|
||||
private Long transcriptCountSnapshot;
|
||||
private Long lastTranscriptAt;
|
||||
private Long pauseAt;
|
||||
private Long resumeExpireAt;
|
||||
private Long lastResumeAt;
|
||||
private String activeConnectionId;
|
||||
private Long updatedAt;
|
||||
private RealtimeMeetingResumeConfig resumeConfig;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.imeeting.dto.biz;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RealtimeMeetingSessionStatusVO {
|
||||
private Long meetingId;
|
||||
private String status;
|
||||
private Boolean hasTranscript;
|
||||
private Boolean canResume;
|
||||
private Long remainingSeconds;
|
||||
private Long resumeExpireAt;
|
||||
private Boolean activeConnection;
|
||||
private RealtimeMeetingResumeConfig resumeConfig;
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package com.imeeting.listener;
|
||||
|
||||
import com.imeeting.common.RedisKeys;
|
||||
import com.imeeting.service.biz.MeetingCommandService;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.connection.Message;
|
||||
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
|
||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class RealtimeMeetingSessionExpirationListener extends KeyExpirationEventMessageListener {
|
||||
|
||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||
private final MeetingCommandService meetingCommandService;
|
||||
private final boolean listenerEnabled;
|
||||
|
||||
public RealtimeMeetingSessionExpirationListener(
|
||||
RedisMessageListenerContainer listenerContainer,
|
||||
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
|
||||
MeetingCommandService meetingCommandService,
|
||||
@Value("${imeeting.realtime.redis-expire-listener-enabled:true}") String listenerEnabledRaw
|
||||
) {
|
||||
super(listenerContainer);
|
||||
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
|
||||
this.meetingCommandService = meetingCommandService;
|
||||
this.listenerEnabled = parseBooleanOrDefault(listenerEnabledRaw, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(Message message, byte[] pattern) {
|
||||
super.onMessage(message, pattern);
|
||||
if (!listenerEnabled || message == null || message.getBody() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String expiredKey = new String(message.getBody(), StandardCharsets.UTF_8);
|
||||
try {
|
||||
if (expiredKey.startsWith(RedisKeys.realtimeMeetingResumeTimeoutPrefix())) {
|
||||
Long meetingId = parseMeetingId(expiredKey, RedisKeys.realtimeMeetingResumeTimeoutPrefix());
|
||||
if (meetingId != null && realtimeMeetingSessionStateService.markCompletingIfResumeExpired(meetingId)) {
|
||||
meetingCommandService.completeRealtimeMeeting(meetingId, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (expiredKey.startsWith(RedisKeys.realtimeMeetingEmptyTimeoutPrefix())) {
|
||||
Long meetingId = parseMeetingId(expiredKey, RedisKeys.realtimeMeetingEmptyTimeoutPrefix());
|
||||
if (meetingId != null) {
|
||||
realtimeMeetingSessionStateService.expireEmptySession(meetingId);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.error("Handle realtime meeting expiration failed, key={}", expiredKey, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private Long parseMeetingId(String key, String prefix) {
|
||||
String raw = key.substring(prefix.length());
|
||||
if (raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return Long.parseLong(raw);
|
||||
}
|
||||
|
||||
private boolean parseBooleanOrDefault(String raw, boolean defaultValue) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return defaultValue;
|
||||
}
|
||||
return Boolean.parseBoolean(raw.trim());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.imeeting.service.biz;
|
||||
|
||||
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
||||
|
||||
public interface RealtimeMeetingSessionStateService {
|
||||
void initSessionIfAbsent(Long meetingId, Long tenantId, Long userId);
|
||||
|
||||
void rememberResumeConfig(Long meetingId, RealtimeMeetingResumeConfig resumeConfig);
|
||||
|
||||
void assertCanOpenSession(Long meetingId);
|
||||
|
||||
boolean activate(Long meetingId, String connectionId);
|
||||
|
||||
RealtimeMeetingSessionStatusVO getStatus(Long meetingId);
|
||||
|
||||
RealtimeMeetingSessionStatusVO pause(Long meetingId);
|
||||
|
||||
void pauseByDisconnect(Long meetingId, String connectionId);
|
||||
|
||||
void refreshAfterTranscript(Long meetingId);
|
||||
|
||||
boolean markCompletingIfResumeExpired(Long meetingId);
|
||||
|
||||
void expireEmptySession(Long meetingId);
|
||||
|
||||
void clear(Long meetingId);
|
||||
}
|
||||
|
|
@ -101,6 +101,16 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
// Real-time meetings are created without audio files and without ASR tasks.
|
||||
// If they have no transcripts yet, they must stay resumable instead of being
|
||||
// pushed into summary flow and accidentally marked completed.
|
||||
if ((meeting.getAudioUrl() == null || meeting.getAudioUrl().isBlank())
|
||||
&& asrTask == null
|
||||
&& (asrText == null || asrText.isBlank())) {
|
||||
updateProgress(meetingId, 0, "等待实时识别开始...", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
AiTask sumTask = this.getOne(new LambdaQueryWrapper<AiTask>()
|
||||
.eq(AiTask::getMeetingId, meetingId)
|
||||
.eq(AiTask::getTaskType, "SUMMARY")
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import com.imeeting.service.biz.HotWordService;
|
|||
import com.imeeting.service.biz.MeetingCommandService;
|
||||
import com.imeeting.service.biz.MeetingService;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -40,6 +41,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper;
|
||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||
private final MeetingDomainSupport meetingDomainSupport;
|
||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
|
|
@ -90,6 +92,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
null, tenantId, creatorId, creatorName, 1);
|
||||
meetingService.save(meeting);
|
||||
meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId());
|
||||
realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
|
||||
|
||||
MeetingVO vo = new MeetingVO();
|
||||
meetingDomainSupport.fillMeetingVO(meeting, vo, false);
|
||||
|
|
@ -100,6 +103,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteMeeting(Long id) {
|
||||
meetingService.removeById(id);
|
||||
realtimeMeetingSessionStateService.clear(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -119,6 +123,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
.orElse(0);
|
||||
|
||||
int nextSortOrder = maxSortOrder == null ? 0 : maxSortOrder + 1;
|
||||
boolean inserted = false;
|
||||
for (RealtimeTranscriptItemDTO item : items) {
|
||||
if (item.getContent() == null || item.getContent().isBlank()) {
|
||||
continue;
|
||||
|
|
@ -144,6 +149,11 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
transcript.setEndTime(item.getEndTime());
|
||||
transcript.setSortOrder(nextSortOrder++);
|
||||
transcriptMapper.insert(transcript);
|
||||
inserted = true;
|
||||
}
|
||||
|
||||
if (inserted) {
|
||||
realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,14 +173,14 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper<MeetingTranscript>()
|
||||
.eq(MeetingTranscript::getMeetingId, meetingId));
|
||||
if (transcriptCount <= 0) {
|
||||
meeting.setStatus(4);
|
||||
meetingService.updateById(meeting);
|
||||
throw new RuntimeException("当前会议还没有可用的转录文本,无法生成总结");
|
||||
realtimeMeetingSessionStateService.pause(meetingId);
|
||||
throw new RuntimeException("当前还没有转录内容,无法结束会议。请先开始识别,或直接离开页面稍后继续。");
|
||||
}
|
||||
|
||||
realtimeMeetingSessionStateService.clear(meetingId);
|
||||
meeting.setStatus(2);
|
||||
meetingService.updateById(meeting);
|
||||
updateMeetingProgress(meetingId, 90, "正在生成智能总结纪要...", 0);
|
||||
updateMeetingProgress(meetingId, 90, "正在生成会议总结...", 0);
|
||||
aiTaskService.dispatchSummaryTask(meetingId);
|
||||
}
|
||||
|
||||
|
|
@ -260,4 +270,4 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
// Ignore progress write failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.common.RedisKeys;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingSessionState;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.entity.biz.MeetingTranscript;
|
||||
import com.imeeting.mapper.biz.MeetingMapper;
|
||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSessionStateService {
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final MeetingTranscriptMapper transcriptMapper;
|
||||
private final MeetingMapper meetingMapper;
|
||||
|
||||
@Value("${imeeting.realtime.resume-window-minutes:30}")
|
||||
private String resumeWindowMinutesValue;
|
||||
|
||||
@Value("${imeeting.realtime.empty-session-retention-minutes:720}")
|
||||
private String emptySessionRetentionMinutesValue;
|
||||
|
||||
@Override
|
||||
public void initSessionIfAbsent(Long meetingId, Long tenantId, Long userId) {
|
||||
RealtimeMeetingSessionState state = readState(meetingId);
|
||||
if (state != null) {
|
||||
return;
|
||||
}
|
||||
RealtimeMeetingSessionState next = new RealtimeMeetingSessionState();
|
||||
next.setMeetingId(meetingId);
|
||||
next.setTenantId(tenantId);
|
||||
next.setUserId(userId);
|
||||
next.setStatus("IDLE");
|
||||
next.setHasTranscript(countTranscripts(meetingId) > 0);
|
||||
next.setTranscriptCountSnapshot(countTranscripts(meetingId));
|
||||
next.setUpdatedAt(System.currentTimeMillis());
|
||||
writeState(next);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rememberResumeConfig(Long meetingId, RealtimeMeetingResumeConfig resumeConfig) {
|
||||
RealtimeMeetingSessionState state = getOrCreateState(meetingId);
|
||||
state.setResumeConfig(resumeConfig);
|
||||
state.setUpdatedAt(System.currentTimeMillis());
|
||||
writeState(state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void assertCanOpenSession(Long meetingId) {
|
||||
RealtimeMeetingSessionStatusVO status = getStatus(meetingId);
|
||||
if (status == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String currentStatus = status.getStatus();
|
||||
if ("COMPLETING".equals(currentStatus)) {
|
||||
throw new RuntimeException("Realtime meeting is completing");
|
||||
}
|
||||
if ("COMPLETED".equals(currentStatus)) {
|
||||
throw new RuntimeException("Realtime meeting has completed");
|
||||
}
|
||||
if ("ACTIVE".equals(currentStatus) || Boolean.TRUE.equals(status.getActiveConnection())) {
|
||||
throw new RuntimeException("Realtime meeting already has an active connection");
|
||||
}
|
||||
if ("PAUSED_RESUMABLE".equals(currentStatus) && !Boolean.TRUE.equals(status.getCanResume())) {
|
||||
throw new RuntimeException("Realtime meeting resume window has expired");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean activate(Long meetingId, String connectionId) {
|
||||
if (meetingId == null || connectionId == null || connectionId.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RealtimeMeetingSessionState state = getOrCreateState(meetingId);
|
||||
if ("COMPLETING".equals(state.getStatus()) || "COMPLETED".equals(state.getStatus())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String activeConnectionId = state.getActiveConnectionId();
|
||||
if (activeConnectionId != null && !activeConnectionId.isBlank() && !activeConnectionId.equals(connectionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long transcriptCount = countTranscripts(meetingId);
|
||||
state.setStatus("ACTIVE");
|
||||
state.setHasTranscript(transcriptCount > 0);
|
||||
state.setTranscriptCountSnapshot(transcriptCount);
|
||||
state.setActiveConnectionId(connectionId);
|
||||
state.setLastResumeAt(now);
|
||||
state.setPauseAt(null);
|
||||
state.setResumeExpireAt(null);
|
||||
state.setUpdatedAt(now);
|
||||
writeState(state);
|
||||
|
||||
redisTemplate.delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
|
||||
redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RealtimeMeetingSessionStatusVO getStatus(Long meetingId) {
|
||||
RealtimeMeetingSessionState state = readState(meetingId);
|
||||
if (state == null) {
|
||||
return buildFallbackStatus(meetingId);
|
||||
}
|
||||
return toStatusVO(state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RealtimeMeetingSessionStatusVO pause(Long meetingId) {
|
||||
RealtimeMeetingSessionState state = getOrCreateState(meetingId);
|
||||
if ("COMPLETING".equals(state.getStatus()) || "COMPLETED".equals(state.getStatus())) {
|
||||
return toStatusVO(state);
|
||||
}
|
||||
return pauseState(meetingId, state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pauseByDisconnect(Long meetingId, String connectionId) {
|
||||
if (meetingId == null || connectionId == null || connectionId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
RealtimeMeetingSessionState state = readState(meetingId);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
if (!"ACTIVE".equals(state.getStatus())) {
|
||||
return;
|
||||
}
|
||||
if (state.getActiveConnectionId() == null || !connectionId.equals(state.getActiveConnectionId())) {
|
||||
return;
|
||||
}
|
||||
|
||||
pauseState(meetingId, state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void refreshAfterTranscript(Long meetingId) {
|
||||
RealtimeMeetingSessionState state = getOrCreateState(meetingId);
|
||||
long now = System.currentTimeMillis();
|
||||
long transcriptCount = countTranscripts(meetingId);
|
||||
|
||||
state.setHasTranscript(transcriptCount > 0);
|
||||
state.setTranscriptCountSnapshot(transcriptCount);
|
||||
state.setLastTranscriptAt(now);
|
||||
state.setUpdatedAt(now);
|
||||
|
||||
if ("PAUSED_EMPTY".equals(state.getStatus()) || "PAUSED_RESUMABLE".equals(state.getStatus())) {
|
||||
state.setStatus("PAUSED_RESUMABLE");
|
||||
state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis());
|
||||
ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId), Duration.ofMinutes(getResumeWindowMinutes()));
|
||||
redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
|
||||
}
|
||||
|
||||
writeState(state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean markCompletingIfResumeExpired(Long meetingId) {
|
||||
String lockKey = RedisKeys.realtimeMeetingTimeoutLockKey(meetingId);
|
||||
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(1));
|
||||
if (Boolean.FALSE.equals(locked)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
RealtimeMeetingSessionState state = readState(meetingId);
|
||||
if (state == null || !"PAUSED_RESUMABLE".equals(state.getStatus())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long transcriptCount = countTranscripts(meetingId);
|
||||
if (transcriptCount <= 0) {
|
||||
clear(meetingId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.getTranscriptCountSnapshot() != null && transcriptCount > state.getTranscriptCountSnapshot()) {
|
||||
long now = System.currentTimeMillis();
|
||||
state.setTranscriptCountSnapshot(transcriptCount);
|
||||
state.setLastTranscriptAt(now);
|
||||
state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis());
|
||||
writeState(state);
|
||||
ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId), Duration.ofMinutes(getResumeWindowMinutes()));
|
||||
return false;
|
||||
}
|
||||
|
||||
state.setStatus("COMPLETING");
|
||||
state.setUpdatedAt(System.currentTimeMillis());
|
||||
writeState(state);
|
||||
return true;
|
||||
} finally {
|
||||
redisTemplate.delete(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void expireEmptySession(Long meetingId) {
|
||||
RealtimeMeetingSessionState state = readState(meetingId);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
if ("PAUSED_EMPTY".equals(state.getStatus())) {
|
||||
clear(meetingId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear(Long meetingId) {
|
||||
redisTemplate.delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
|
||||
redisTemplate.delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
|
||||
redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
|
||||
}
|
||||
|
||||
private RealtimeMeetingSessionStatusVO pauseState(Long meetingId, RealtimeMeetingSessionState state) {
|
||||
long transcriptCount = countTranscripts(meetingId);
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
state.setHasTranscript(transcriptCount > 0);
|
||||
state.setTranscriptCountSnapshot(transcriptCount);
|
||||
state.setPauseAt(now);
|
||||
state.setActiveConnectionId(null);
|
||||
state.setUpdatedAt(now);
|
||||
|
||||
if (transcriptCount > 0) {
|
||||
state.setStatus("PAUSED_RESUMABLE");
|
||||
state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis());
|
||||
ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId), Duration.ofMinutes(getResumeWindowMinutes()));
|
||||
redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
|
||||
if (state.getLastTranscriptAt() == null) {
|
||||
state.setLastTranscriptAt(now);
|
||||
}
|
||||
} else {
|
||||
state.setStatus("PAUSED_EMPTY");
|
||||
state.setResumeExpireAt(null);
|
||||
redisTemplate.delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
|
||||
ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId), Duration.ofMinutes(getEmptySessionRetentionMinutes()));
|
||||
}
|
||||
|
||||
writeState(state);
|
||||
return toStatusVO(state);
|
||||
}
|
||||
|
||||
private RealtimeMeetingSessionStatusVO buildFallbackStatus(Long meetingId) {
|
||||
RealtimeMeetingSessionStatusVO vo = new RealtimeMeetingSessionStatusVO();
|
||||
vo.setMeetingId(meetingId);
|
||||
Meeting meeting = meetingMapper.selectById(meetingId);
|
||||
if (meeting == null) {
|
||||
vo.setStatus("IDLE");
|
||||
vo.setHasTranscript(false);
|
||||
vo.setCanResume(false);
|
||||
vo.setRemainingSeconds(0L);
|
||||
vo.setActiveConnection(false);
|
||||
return vo;
|
||||
}
|
||||
|
||||
if (Integer.valueOf(2).equals(meeting.getStatus())) {
|
||||
vo.setStatus("COMPLETING");
|
||||
} else if (Integer.valueOf(3).equals(meeting.getStatus()) || Integer.valueOf(4).equals(meeting.getStatus())) {
|
||||
vo.setStatus("COMPLETED");
|
||||
} else {
|
||||
vo.setStatus("IDLE");
|
||||
}
|
||||
vo.setHasTranscript(countTranscripts(meetingId) > 0);
|
||||
vo.setCanResume(false);
|
||||
vo.setRemainingSeconds(0L);
|
||||
vo.setActiveConnection(false);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private RealtimeMeetingSessionStatusVO toStatusVO(RealtimeMeetingSessionState state) {
|
||||
RealtimeMeetingSessionStatusVO vo = new RealtimeMeetingSessionStatusVO();
|
||||
vo.setMeetingId(state.getMeetingId());
|
||||
vo.setStatus(state.getStatus());
|
||||
vo.setHasTranscript(Boolean.TRUE.equals(state.getHasTranscript()));
|
||||
vo.setResumeExpireAt(state.getResumeExpireAt());
|
||||
vo.setResumeConfig(state.getResumeConfig());
|
||||
vo.setActiveConnection(state.getActiveConnectionId() != null && !state.getActiveConnectionId().isBlank());
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long remainingSeconds = 0L;
|
||||
if (state.getResumeExpireAt() != null) {
|
||||
remainingSeconds = Math.max(0L, (state.getResumeExpireAt() - now) / 1000);
|
||||
}
|
||||
vo.setRemainingSeconds(remainingSeconds);
|
||||
vo.setCanResume(
|
||||
"PAUSED_EMPTY".equals(state.getStatus())
|
||||
|| ("PAUSED_RESUMABLE".equals(state.getStatus()) && remainingSeconds > 0)
|
||||
|| "IDLE".equals(state.getStatus())
|
||||
);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private RealtimeMeetingSessionState getOrCreateState(Long meetingId) {
|
||||
RealtimeMeetingSessionState state = readState(meetingId);
|
||||
if (state != null) {
|
||||
return state;
|
||||
}
|
||||
RealtimeMeetingSessionState next = new RealtimeMeetingSessionState();
|
||||
next.setMeetingId(meetingId);
|
||||
next.setStatus("IDLE");
|
||||
next.setHasTranscript(countTranscripts(meetingId) > 0);
|
||||
next.setTranscriptCountSnapshot(countTranscripts(meetingId));
|
||||
next.setUpdatedAt(System.currentTimeMillis());
|
||||
return next;
|
||||
}
|
||||
|
||||
private RealtimeMeetingSessionState readState(Long meetingId) {
|
||||
String raw = redisTemplate.opsForValue().get(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(raw, RealtimeMeetingSessionState.class);
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to read realtime meeting session state, meetingId={}", meetingId, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void writeState(RealtimeMeetingSessionState state) {
|
||||
try {
|
||||
redisTemplate.opsForValue().set(
|
||||
RedisKeys.realtimeMeetingSessionStateKey(state.getMeetingId()),
|
||||
objectMapper.writeValueAsString(state)
|
||||
);
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException("Failed to write realtime meeting session state", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureTimeoutKey(Long meetingId, String key, Duration ttl) {
|
||||
redisTemplate.opsForValue().set(key, String.valueOf(meetingId), ttl);
|
||||
}
|
||||
|
||||
private long getResumeWindowMinutes() {
|
||||
return parseLongOrDefault(resumeWindowMinutesValue, 30L, "resume-window-minutes");
|
||||
}
|
||||
|
||||
private long getEmptySessionRetentionMinutes() {
|
||||
return parseLongOrDefault(emptySessionRetentionMinutesValue, 720L, "empty-session-retention-minutes");
|
||||
}
|
||||
|
||||
private long parseLongOrDefault(String raw, long defaultValue, String configName) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(raw.trim());
|
||||
} catch (NumberFormatException ex) {
|
||||
log.warn("Invalid realtime meeting config {}, rawValue={}, use default={}", configName, raw, defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private long countTranscripts(Long meetingId) {
|
||||
return transcriptMapper.selectCount(new LambdaQueryWrapper<MeetingTranscript>()
|
||||
.eq(MeetingTranscript::getMeetingId, meetingId));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,13 @@ package com.imeeting.service.biz.impl;
|
|||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.common.RedisKeys;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
||||
import com.imeeting.dto.biz.RealtimeSocketSessionData;
|
||||
import com.imeeting.dto.biz.RealtimeSocketSessionVO;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.MeetingAccessService;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
|
@ -31,6 +33,7 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
|
|||
private final StringRedisTemplate redisTemplate;
|
||||
private final MeetingAccessService meetingAccessService;
|
||||
private final AiModelService aiModelService;
|
||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||
|
||||
@Override
|
||||
public RealtimeSocketSessionVO createSession(Long meetingId, Long asrModelId, String mode, String language,
|
||||
|
|
@ -47,6 +50,9 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
|
|||
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
|
||||
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
|
||||
|
||||
realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, loginUser.getTenantId(), loginUser.getUserId());
|
||||
realtimeMeetingSessionStateService.assertCanOpenSession(meetingId);
|
||||
|
||||
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
|
||||
if (asrModel == null) {
|
||||
throw new RuntimeException("ASR model not found");
|
||||
|
|
@ -57,6 +63,18 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
|
|||
throw new RuntimeException("ASR model WebSocket is not configured");
|
||||
}
|
||||
|
||||
RealtimeMeetingResumeConfig resumeConfig = new RealtimeMeetingResumeConfig();
|
||||
resumeConfig.setAsrModelId(asrModelId);
|
||||
resumeConfig.setMode(mode);
|
||||
resumeConfig.setLanguage(language);
|
||||
resumeConfig.setUseSpkId(useSpkId);
|
||||
resumeConfig.setEnablePunctuation(enablePunctuation);
|
||||
resumeConfig.setEnableItn(enableItn);
|
||||
resumeConfig.setEnableTextRefine(enableTextRefine);
|
||||
resumeConfig.setSaveAudio(saveAudio);
|
||||
resumeConfig.setHotwords(hotwords);
|
||||
realtimeMeetingSessionStateService.rememberResumeConfig(meetingId, resumeConfig);
|
||||
|
||||
RealtimeSocketSessionData sessionData = new RealtimeSocketSessionData();
|
||||
sessionData.setMeetingId(meetingId);
|
||||
sessionData.setUserId(loginUser.getUserId());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.imeeting.websocket;
|
||||
|
||||
import com.imeeting.dto.biz.RealtimeSocketSessionData;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
|
@ -22,12 +23,14 @@ import java.nio.charset.StandardCharsets;
|
|||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
|
|
@ -46,6 +49,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
private static final CompletableFuture<Void> COMPLETED = CompletableFuture.completedFuture(null);
|
||||
|
||||
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
|
||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||
|
|
@ -75,16 +79,24 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
upstreamSocket = java.net.http.HttpClient.newHttpClient()
|
||||
.newWebSocketBuilder()
|
||||
.buildAsync(URI.create(sessionData.getTargetWsUrl()),
|
||||
new UpstreamListener(frontendSession, session, sessionData.getMeetingId(), sessionData.getTargetWsUrl()))
|
||||
new UpstreamListener(
|
||||
frontendSession,
|
||||
session,
|
||||
sessionData.getMeetingId(),
|
||||
sessionData.getTargetWsUrl(),
|
||||
realtimeMeetingSessionStateService
|
||||
))
|
||||
.get();
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("Realtime websocket upstream connect interrupted: meetingId={}, sessionId={}",
|
||||
sessionData.getMeetingId(), session.getId(), ex);
|
||||
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_INTERRUPTED", "连接第三方识别服务时被中断");
|
||||
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("Interrupted while connecting upstream"));
|
||||
return;
|
||||
} catch (ExecutionException | CompletionException ex) {
|
||||
log.warn("Failed to connect upstream websocket, meetingId={}, target={}", sessionData.getMeetingId(), sessionData.getTargetWsUrl(), ex);
|
||||
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_FAILED", "连接第三方识别服务失败,请检查模型 WebSocket 配置或服务状态");
|
||||
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("Failed to connect ASR websocket"));
|
||||
return;
|
||||
}
|
||||
|
|
@ -159,6 +171,10 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
||||
log.info("Realtime websocket closed: meetingId={}, sessionId={}, code={}, reason={}",
|
||||
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), status.getCode(), status.getReason());
|
||||
Object meetingIdValue = session.getAttributes().get(ATTR_MEETING_ID);
|
||||
if (meetingIdValue instanceof Long meetingId) {
|
||||
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, session.getId());
|
||||
}
|
||||
closeUpstreamSocket(session, status);
|
||||
}
|
||||
|
||||
|
|
@ -253,6 +269,21 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
return normalized.contains("\"type\":\"start\"");
|
||||
}
|
||||
|
||||
private void sendFrontendError(ConcurrentWebSocketSessionDecorator frontendSession, String code, String message) {
|
||||
try {
|
||||
if (!frontendSession.isOpen()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("type", "error");
|
||||
payload.put("code", code);
|
||||
payload.put("message", message);
|
||||
frontendSession.sendMessage(new TextMessage(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(payload)));
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to send realtime proxy error to frontend: code={}", code, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void queuePendingAudioFrame(WebSocketSession session, byte[] payload) {
|
||||
synchronized (session) {
|
||||
|
|
@ -287,23 +318,41 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
private final WebSocketSession rawSession;
|
||||
private final Long meetingId;
|
||||
private final String targetWsUrl;
|
||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||
private final StringBuilder textBuffer = new StringBuilder();
|
||||
private final ByteArrayOutputStream binaryBuffer = new ByteArrayOutputStream();
|
||||
private final AtomicInteger upstreamTextCount = new AtomicInteger();
|
||||
private final AtomicInteger upstreamBinaryCount = new AtomicInteger();
|
||||
|
||||
private UpstreamListener(ConcurrentWebSocketSessionDecorator frontendSession, WebSocketSession rawSession,
|
||||
Long meetingId, String targetWsUrl) {
|
||||
Long meetingId, String targetWsUrl,
|
||||
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService) {
|
||||
this.frontendSession = frontendSession;
|
||||
this.rawSession = rawSession;
|
||||
this.meetingId = meetingId;
|
||||
this.targetWsUrl = targetWsUrl;
|
||||
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(java.net.http.WebSocket webSocket) {
|
||||
log.info("Upstream websocket opened: meetingId={}, sessionId={}, upstream={}",
|
||||
meetingId, rawSession.getId(), targetWsUrl);
|
||||
if (!realtimeMeetingSessionStateService.activate(meetingId, rawSession.getId())) {
|
||||
sendFrontendError("REALTIME_ACTIVE_CONNECTION_EXISTS", "当前会议已有活跃实时连接,请先关闭旧连接后再继续");
|
||||
webSocket.sendClose(CloseStatus.POLICY_VIOLATION.getCode(), "Active realtime connection already exists");
|
||||
closeFrontend(CloseStatus.POLICY_VIOLATION.withReason("Active realtime connection already exists"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (frontendSession.isOpen()) {
|
||||
frontendSession.sendMessage(new TextMessage("{\"type\":\"proxy_ready\"}"));
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to notify frontend that upstream websocket is ready: meetingId={}, sessionId={}", meetingId, rawSession.getId(), ex);
|
||||
closeFrontend(CloseStatus.SERVER_ERROR);
|
||||
return;
|
||||
}
|
||||
webSocket.request(1);
|
||||
}
|
||||
|
||||
|
|
@ -391,6 +440,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
public java.util.concurrent.CompletionStage<?> onClose(java.net.http.WebSocket webSocket, int statusCode, String reason) {
|
||||
log.info("Upstream websocket closed: meetingId={}, sessionId={}, code={}, reason={}",
|
||||
meetingId, rawSession.getId(), statusCode, reason);
|
||||
sendFrontendError("REALTIME_UPSTREAM_CLOSED", reason == null || reason.isBlank() ? "第三方识别服务已断开连接" : "第三方识别服务已断开: " + reason);
|
||||
closeFrontend(new CloseStatus(statusCode, reason));
|
||||
return COMPLETED;
|
||||
}
|
||||
|
|
@ -399,9 +449,34 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
public void onError(java.net.http.WebSocket webSocket, Throwable error) {
|
||||
log.error("Upstream websocket error: meetingId={}, sessionId={}, upstream={}",
|
||||
meetingId, rawSession.getId(), targetWsUrl, error);
|
||||
sendFrontendError("REALTIME_UPSTREAM_ERROR", error == null || error.getMessage() == null || error.getMessage().isBlank()
|
||||
? "第三方识别服务连接异常"
|
||||
: "第三方识别服务连接异常: " + error.getMessage());
|
||||
closeFrontend(CloseStatus.SERVER_ERROR);
|
||||
}
|
||||
|
||||
private void sendFrontendError(String code, String message) {
|
||||
try {
|
||||
if (!frontendSession.isOpen()) {
|
||||
return;
|
||||
}
|
||||
frontendSession.sendMessage(new TextMessage("{\"type\":\"error\",\"code\":\"" + code + "\",\"message\":\"" + escapeJson(message) + "\"}"));
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to send upstream error to frontend: meetingId={}, sessionId={}, code={}", meetingId, rawSession.getId(), code, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String escapeJson(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\n", "\\n");
|
||||
}
|
||||
|
||||
private void closeFrontend(CloseStatus status) {
|
||||
try {
|
||||
if (rawSession.isOpen()) {
|
||||
|
|
|
|||
|
|
@ -52,4 +52,10 @@ unisbase:
|
|||
max-attempts: 5
|
||||
token:
|
||||
access-default-minutes: 30
|
||||
refresh-default-days: 7
|
||||
refresh-default-days: 7
|
||||
|
||||
imeeting:
|
||||
realtime:
|
||||
resume-window-minutes: 30
|
||||
empty-session-retention-minutes: 720
|
||||
redis-expire-listener-enabled: true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import http from "../http";
|
||||
import http from "../http";
|
||||
import axios from "axios";
|
||||
|
||||
export interface MeetingVO {
|
||||
|
|
@ -22,6 +22,8 @@ export interface MeetingVO {
|
|||
todos?: string[];
|
||||
};
|
||||
status: number;
|
||||
displayStatus?: number;
|
||||
realtimeSessionStatus?: RealtimeMeetingSessionStatus["status"];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -58,11 +60,11 @@ export interface UpdateMeetingSummaryCommand {
|
|||
|
||||
export type MeetingUpdateSummaryDTO = UpdateMeetingSummaryCommand;
|
||||
|
||||
export const getMeetingPage = (params: {
|
||||
current: number;
|
||||
size: number;
|
||||
export const getMeetingPage = (params: {
|
||||
current: number;
|
||||
size: number;
|
||||
title?: string;
|
||||
viewType?: 'all' | 'created' | 'involved';
|
||||
viewType?: "all" | "created" | "involved";
|
||||
}) => {
|
||||
return http.get<any, { code: string; data: { records: MeetingVO[]; total: number }; msg: string }>(
|
||||
"/api/biz/meeting/page",
|
||||
|
|
@ -104,6 +106,17 @@ export interface RealtimeSocketSessionRequest {
|
|||
hotwords?: Array<{ hotword: string; weight: number }>;
|
||||
}
|
||||
|
||||
export interface RealtimeMeetingSessionStatus {
|
||||
meetingId: number;
|
||||
status: "IDLE" | "ACTIVE" | "PAUSED_EMPTY" | "PAUSED_RESUMABLE" | "COMPLETING" | "COMPLETED";
|
||||
hasTranscript: boolean;
|
||||
canResume: boolean;
|
||||
remainingSeconds: number;
|
||||
resumeExpireAt?: number;
|
||||
activeConnection: boolean;
|
||||
resumeConfig?: RealtimeSocketSessionRequest;
|
||||
}
|
||||
|
||||
export const createRealtimeMeeting = (data: CreateMeetingCommand) => {
|
||||
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
||||
"/api/biz/meeting/realtime/start",
|
||||
|
|
@ -118,6 +131,19 @@ export const appendRealtimeTranscripts = (meetingId: number, data: RealtimeTrans
|
|||
);
|
||||
};
|
||||
|
||||
export const getRealtimeMeetingSessionStatus = (meetingId: number) => {
|
||||
return http.get<any, { code: string; data: RealtimeMeetingSessionStatus; msg: string }>(
|
||||
`/api/biz/meeting/${meetingId}/realtime/session-status`
|
||||
);
|
||||
};
|
||||
|
||||
export const pauseRealtimeMeeting = (meetingId: number) => {
|
||||
return http.post<any, { code: string; data: RealtimeMeetingSessionStatus; msg: string }>(
|
||||
`/api/biz/meeting/${meetingId}/realtime/pause`,
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
export const openRealtimeMeetingSocketSession = (
|
||||
meetingId: number,
|
||||
data: RealtimeSocketSessionRequest,
|
||||
|
|
@ -253,11 +279,11 @@ export const getMeetingProgress = (id: number) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const downloadMeetingSummary = (id: number, format: 'pdf' | 'word') => {
|
||||
export const downloadMeetingSummary = (id: number, format: "pdf" | "word") => {
|
||||
const token = localStorage.getItem("accessToken");
|
||||
return axios.get(`/api/biz/meeting/${id}/summary/export`, {
|
||||
params: { format },
|
||||
responseType: 'blob',
|
||||
responseType: "blob",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import {
|
|||
TeamOutlined, ClockCircleOutlined, EditOutlined, RightOutlined,
|
||||
SyncOutlined, InfoCircleOutlined, CloudUploadOutlined, SettingOutlined,
|
||||
QuestionCircleOutlined, FileTextOutlined, CheckOutlined, RocketOutlined,
|
||||
AudioOutlined
|
||||
AudioOutlined, PauseCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants } from '../../api/business/meeting';
|
||||
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants, getRealtimeMeetingSessionStatus } from '../../api/business/meeting';
|
||||
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
||||
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
||||
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
|
||||
|
|
@ -22,12 +22,14 @@ import { useTranslation } from 'react-i18next';
|
|||
const { Text, Title } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
const { Option } = Select;
|
||||
const PAUSED_DISPLAY_STATUS = 5;
|
||||
|
||||
// --- 进度感知 Hook ---
|
||||
const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
|
||||
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
||||
useEffect(() => {
|
||||
if (meeting.status !== 1 && meeting.status !== 2) return;
|
||||
const effectiveStatus = meeting.displayStatus ?? meeting.status;
|
||||
if (effectiveStatus !== 1 && effectiveStatus !== 2) return;
|
||||
const fetchProgress = async () => {
|
||||
try {
|
||||
const res = await getMeetingProgress(meeting.id);
|
||||
|
|
@ -43,27 +45,29 @@ const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
|
|||
fetchProgress();
|
||||
const timer = setInterval(fetchProgress, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [meeting.id, meeting.status]);
|
||||
}, [meeting.id, meeting.status, meeting.displayStatus]);
|
||||
return progress;
|
||||
};
|
||||
|
||||
// --- 状态标签组件 ---
|
||||
const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgress | null }> = ({ meeting, progress }) => {
|
||||
const effectiveStatus = meeting.displayStatus ?? meeting.status;
|
||||
const statusConfig: Record<number, { text: string; color: string; bgColor: string }> = {
|
||||
0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' },
|
||||
1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' },
|
||||
2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' },
|
||||
3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' },
|
||||
4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' }
|
||||
4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' },
|
||||
5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' }
|
||||
};
|
||||
const config = statusConfig[meeting.status] || statusConfig[0];
|
||||
const config = statusConfig[effectiveStatus] || statusConfig[0];
|
||||
const percent = progress?.percent || 0;
|
||||
const isProcessing = meeting.status === 1 || meeting.status === 2;
|
||||
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2;
|
||||
return (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 10px', borderRadius: 6, fontSize: 11, fontWeight: 600, color: config.color, background: config.bgColor, position: 'relative', overflow: 'hidden', border: `1px solid ${isProcessing ? 'transparent' : '#eee'}`, minWidth: 80, justifyContent: 'center' }}>
|
||||
{/* 进度填充背景 */}
|
||||
{isProcessing && percent > 0 && (
|
||||
<div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${percent}%`, background: meeting.status === 1 ? 'rgba(24, 144, 255, 0.2)' : 'rgba(250, 173, 20, 0.2)', transition: 'width 0.5s cubic-bezier(0.4, 0, 0.2, 1)', zIndex: 0 }} />
|
||||
<div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${percent}%`, background: effectiveStatus === 1 ? 'rgba(24, 144, 255, 0.2)' : 'rgba(250, 173, 20, 0.2)', transition: 'width 0.5s cubic-bezier(0.4, 0, 0.2, 1)', zIndex: 0 }} />
|
||||
)}
|
||||
<span style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{isProcessing ? <SyncOutlined spin style={{ fontSize: 10 }} /> : null}
|
||||
|
|
@ -259,15 +263,16 @@ const MeetingCreateForm: React.FC<{
|
|||
};
|
||||
|
||||
// --- 卡片项组件 ---
|
||||
const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants }) => {
|
||||
const navigate = useNavigate();
|
||||
const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void, onOpenMeeting: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants, onOpenMeeting }) => {
|
||||
// 注入自动刷新回调
|
||||
const progress = useMeetingProgress(item, () => fetchData());
|
||||
const isProcessing = item.status === 1 || item.status === 2;
|
||||
const effectiveStatus = item.displayStatus ?? item.status;
|
||||
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2;
|
||||
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
|
||||
|
||||
return (
|
||||
<List.Item style={{ marginBottom: 24 }}>
|
||||
<Card hoverable onClick={() => navigate(`/meetings/${item.id}`)} className="meeting-card" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', backdropFilter: 'blur(16px)', height: '220px', position: 'relative', boxShadow: 'var(--app-shadow)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} bodyStyle={{ padding: 0, display: 'flex', height: '100%' }}>
|
||||
<Card hoverable onClick={() => onOpenMeeting(item)} className="meeting-card" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', backdropFilter: 'blur(16px)', height: '220px', position: 'relative', boxShadow: 'var(--app-shadow)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} bodyStyle={{ padding: 0, display: 'flex', height: '100%' }}>
|
||||
<div className={isProcessing ? 'status-bar-active' : ''} style={{ width: 6, backgroundColor: config.color, borderRadius: '16px 0 0 16px' }}></div>
|
||||
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
|
||||
|
|
@ -294,10 +299,10 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
|||
{isProcessing ? (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: item.status === 1 ? '#1890ff' : '#faad14',
|
||||
color: effectiveStatus === 1 ? '#1890ff' : '#faad14',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: item.status === 1 ? '#e6f7ff' : '#fff7e6',
|
||||
background: effectiveStatus === 1 ? '#e6f7ff' : '#fff7e6',
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
marginTop: 4,
|
||||
|
|
@ -323,6 +328,26 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
|||
{progress?.message || '等待引擎调度...'}
|
||||
</Text>
|
||||
</div>
|
||||
) : isPaused ? (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#d48806',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: '#fff7e6',
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
marginTop: 4,
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
minWidth: 0,
|
||||
maxWidth: 250
|
||||
}}>
|
||||
<PauseCircleOutlined style={{ marginRight: 6, flexShrink: 0 }} />
|
||||
<Text style={{ color: 'inherit', fontSize: '12px', fontWeight: 500 }}>
|
||||
会议已暂停,可继续识别
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}><TeamOutlined style={{ marginRight: 10 }} /><Text type="secondary" ellipsis style={{ maxWidth: '85%' }}>{item.participants || '无参与人员'}</Text></div>
|
||||
|
|
@ -375,7 +400,10 @@ const Meetings: React.FC = () => {
|
|||
const [editingMeeting, setEditingMeeting] = useState<MeetingVO | null>(null);
|
||||
const [participantsEditLoading, setParticipantsEditLoading] = useState(false);
|
||||
const [participantsEditForm] = Form.useForm();
|
||||
const hasRunningTasks = data.some(item => item.status === 0 || item.status === 1 || item.status === 2);
|
||||
const hasRunningTasks = data.some(item => {
|
||||
const effectiveStatus = item.displayStatus ?? item.status;
|
||||
return effectiveStatus === 0 || effectiveStatus === 1 || effectiveStatus === 2;
|
||||
});
|
||||
|
||||
useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]);
|
||||
useEffect(() => {
|
||||
|
|
@ -391,10 +419,52 @@ const Meetings: React.FC = () => {
|
|||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const res = await getMeetingPage({ current, size, title: searchTitle, viewType });
|
||||
if (res.data && res.data.data) { setData(res.data.data.records); setTotal(res.data.data.total); }
|
||||
if (res.data && res.data.data) {
|
||||
const records = res.data.data.records || [];
|
||||
const withDisplayStatus = await Promise.all(records.map(async (item) => {
|
||||
try {
|
||||
const sessionRes = await getRealtimeMeetingSessionStatus(item.id);
|
||||
const sessionStatus = sessionRes.data?.data;
|
||||
if (sessionStatus?.status === 'PAUSED_EMPTY' || sessionStatus?.status === 'PAUSED_RESUMABLE') {
|
||||
return {
|
||||
...item,
|
||||
displayStatus: PAUSED_DISPLAY_STATUS,
|
||||
realtimeSessionStatus: sessionStatus.status
|
||||
};
|
||||
}
|
||||
if (sessionStatus?.status === 'ACTIVE') {
|
||||
return {
|
||||
...item,
|
||||
displayStatus: 1,
|
||||
realtimeSessionStatus: sessionStatus.status
|
||||
};
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
realtimeSessionStatus: sessionStatus?.status
|
||||
};
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
}));
|
||||
setData(withDisplayStatus);
|
||||
setTotal(res.data.data.total);
|
||||
}
|
||||
} catch (err) {} finally { if (!silent) setLoading(false); }
|
||||
};
|
||||
|
||||
const handleOpenMeeting = async (meeting: MeetingVO) => {
|
||||
try {
|
||||
const res = await getRealtimeMeetingSessionStatus(meeting.id);
|
||||
const sessionStatus = res.data?.data;
|
||||
if (sessionStatus && (sessionStatus.status === 'PAUSED_EMPTY' || sessionStatus.status === 'PAUSED_RESUMABLE' || sessionStatus.status === 'ACTIVE')) {
|
||||
navigate(`/meeting-live-session/${meeting.id}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {}
|
||||
navigate(`/meetings/${meeting.id}`);
|
||||
};
|
||||
|
||||
const handleCreateSubmit = async () => {
|
||||
if (!audioUrl) {
|
||||
message.error('请先上传录音文件');
|
||||
|
|
@ -451,7 +521,8 @@ const Meetings: React.FC = () => {
|
|||
1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' },
|
||||
2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' },
|
||||
3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' },
|
||||
4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' }
|
||||
4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' },
|
||||
5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' }
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -482,8 +553,8 @@ const Meetings: React.FC = () => {
|
|||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '4px 8px 4px 4px' }}>
|
||||
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
|
||||
<List grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} dataSource={data} renderItem={(item) => {
|
||||
const config = statusConfig[item.status] || statusConfig[0];
|
||||
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} />;
|
||||
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0];
|
||||
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} onOpenMeeting={handleOpenMeeting} />;
|
||||
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
|
||||
</Skeleton>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
|
|
@ -30,14 +30,16 @@ import {
|
|||
appendRealtimeTranscripts,
|
||||
completeRealtimeMeeting,
|
||||
getMeetingDetail,
|
||||
getRealtimeMeetingSessionStatus,
|
||||
getTranscripts,
|
||||
openRealtimeMeetingSocketSession,
|
||||
pauseRealtimeMeeting,
|
||||
type MeetingTranscriptVO,
|
||||
type MeetingVO,
|
||||
type RealtimeMeetingSessionStatus,
|
||||
type RealtimeTranscriptItemDTO,
|
||||
type RealtimeSocketSessionVO,
|
||||
} from "../../api/business/meeting";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const SAMPLE_RATE = 16000;
|
||||
const CHUNK_SIZE = 1280;
|
||||
|
|
@ -45,7 +47,7 @@ const CHUNK_SIZE = 1280;
|
|||
type WsSpeaker = string | { name?: string; user_id?: string | number } | undefined;
|
||||
type WsMessage = {
|
||||
type?: string;
|
||||
code?: number;
|
||||
code?: number | string;
|
||||
message?: string;
|
||||
data?: {
|
||||
text?: string;
|
||||
|
|
@ -92,6 +94,28 @@ function getSessionKey(meetingId: number) {
|
|||
return `realtimeMeetingSession:${meetingId}`;
|
||||
}
|
||||
|
||||
function buildDraftFromStatus(meetingId: number, meeting: MeetingVO | null, status?: RealtimeMeetingSessionStatus | null): RealtimeMeetingSessionDraft | null {
|
||||
const config = status?.resumeConfig;
|
||||
if (!config?.asrModelId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
meetingId,
|
||||
meetingTitle: meeting?.title || `实时会议 ${meetingId}`,
|
||||
asrModelName: "ASR",
|
||||
summaryModelName: "LLM",
|
||||
asrModelId: config.asrModelId,
|
||||
mode: config.mode || "2pass",
|
||||
language: config.language || "auto",
|
||||
useSpkId: config.useSpkId ? 1 : 0,
|
||||
enablePunctuation: config.enablePunctuation !== false,
|
||||
enableItn: config.enableItn !== false,
|
||||
enableTextRefine: !!config.enableTextRefine,
|
||||
saveAudio: !!config.saveAudio,
|
||||
hotwords: config.hotwords || [],
|
||||
};
|
||||
}
|
||||
|
||||
function floatTo16BitPCM(input: Float32Array) {
|
||||
const buffer = new ArrayBuffer(input.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
|
|
@ -176,7 +200,7 @@ function normalizeWsMessage(payload: WsMessage) {
|
|||
};
|
||||
}
|
||||
|
||||
export default function RealtimeAsrSession() {
|
||||
export function RealtimeAsrSession() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const meetingId = Number(id);
|
||||
|
|
@ -187,12 +211,14 @@ export default function RealtimeAsrSession() {
|
|||
const [recording, setRecording] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
const [pausing, setPausing] = useState(false);
|
||||
const [statusText, setStatusText] = useState("待开始");
|
||||
const [streamingText, setStreamingText] = useState("");
|
||||
const [streamingSpeaker, setStreamingSpeaker] = useState("Unknown");
|
||||
const [transcripts, setTranscripts] = useState<TranscriptCard[]>([]);
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [sessionStatus, setSessionStatus] = useState<RealtimeMeetingSessionStatus | null>(null);
|
||||
|
||||
const transcriptRef = useRef<HTMLDivElement | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
|
@ -203,6 +229,7 @@ export default function RealtimeAsrSession() {
|
|||
const audioBufferRef = useRef<number[]>([]);
|
||||
const completeOnceRef = useRef(false);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const sessionStartedRef = useRef(false);
|
||||
|
||||
const finalTranscriptCount = transcripts.length;
|
||||
const totalTranscriptChars = useMemo(
|
||||
|
|
@ -210,6 +237,7 @@ export default function RealtimeAsrSession() {
|
|||
[streamingText, transcripts],
|
||||
);
|
||||
const statusColor = recording ? "#1677ff" : connecting || finishing ? "#faad14" : "#94a3b8";
|
||||
const hasRemoteActiveConnection = Boolean(sessionStatus?.activeConnection) && !recording && !connecting;
|
||||
|
||||
useEffect(() => {
|
||||
if (!meetingId || Number.isNaN(meetingId)) {
|
||||
|
|
@ -220,10 +248,32 @@ export default function RealtimeAsrSession() {
|
|||
setLoading(true);
|
||||
try {
|
||||
const stored = sessionStorage.getItem(getSessionKey(meetingId));
|
||||
setSessionDraft(stored ? JSON.parse(stored) : null);
|
||||
const parsedDraft = stored ? JSON.parse(stored) : null;
|
||||
|
||||
const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
|
||||
setMeeting(detailRes.data.data);
|
||||
const [detailRes, transcriptRes, statusRes] = await Promise.all([
|
||||
getMeetingDetail(meetingId),
|
||||
getTranscripts(meetingId),
|
||||
getRealtimeMeetingSessionStatus(meetingId),
|
||||
]);
|
||||
const detail = detailRes.data.data;
|
||||
const realtimeStatus = statusRes.data.data;
|
||||
setMeeting(detail);
|
||||
setSessionStatus(realtimeStatus);
|
||||
const fallbackDraft = buildDraftFromStatus(meetingId, detail, realtimeStatus);
|
||||
const resolvedDraft = parsedDraft || fallbackDraft;
|
||||
setSessionDraft(resolvedDraft);
|
||||
if (resolvedDraft) {
|
||||
sessionStorage.setItem(getSessionKey(meetingId), JSON.stringify(resolvedDraft));
|
||||
}
|
||||
if (realtimeStatus?.status === "PAUSED_RESUMABLE") {
|
||||
setStatusText(`已暂停,可在 ${Math.max(1, Math.ceil((realtimeStatus.remainingSeconds || 0) / 60))} 分钟内继续`);
|
||||
} else if (realtimeStatus?.status === "PAUSED_EMPTY") {
|
||||
setStatusText("已暂停,可继续识别");
|
||||
} else if (realtimeStatus?.status === "ACTIVE" && realtimeStatus?.activeConnection) {
|
||||
setStatusText("当前会议已有活跃实时连接");
|
||||
} else if (realtimeStatus?.status === "COMPLETING") {
|
||||
setStatusText("正在生成总结");
|
||||
}
|
||||
setTranscripts(
|
||||
(transcriptRes.data.data || []).map((item: MeetingTranscriptVO) => ({
|
||||
id: String(item.id),
|
||||
|
|
@ -270,11 +320,10 @@ export default function RealtimeAsrSession() {
|
|||
return;
|
||||
}
|
||||
const token = localStorage.getItem("accessToken");
|
||||
completeOnceRef.current = true;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ is_speaking: false }));
|
||||
}
|
||||
fetch(`/api/biz/meeting/${meetingId}/realtime/complete`, {
|
||||
fetch(`/api/biz/meeting/${meetingId}/realtime/pause`, {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
headers: {
|
||||
|
|
@ -306,6 +355,18 @@ export default function RealtimeAsrSession() {
|
|||
setAudioLevel(0);
|
||||
};
|
||||
|
||||
const handleFatalRealtimeError = async (errorMessage: string) => {
|
||||
setConnecting(false);
|
||||
setRecording(false);
|
||||
setStatusText("连接失败");
|
||||
sessionStartedRef.current = false;
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
await shutdownAudioPipeline();
|
||||
startedAtRef.current = null;
|
||||
message.error(errorMessage);
|
||||
};
|
||||
|
||||
const startAudioPipeline = async () => {
|
||||
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error("当前浏览器环境不支持麦克风访问。请使用 localhost 或 HTTPS 域名访问系统。");
|
||||
|
|
@ -373,6 +434,36 @@ export default function RealtimeAsrSession() {
|
|||
await appendRealtimeTranscripts(meetingId, [item]);
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
if (!meetingId || pausing || finishing || (!recording && !connecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPausing(true);
|
||||
setStatusText("暂停识别中...");
|
||||
try {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ is_speaking: false }));
|
||||
}
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
sessionStartedRef.current = false;
|
||||
await shutdownAudioPipeline();
|
||||
const pauseRes = await pauseRealtimeMeeting(meetingId);
|
||||
setSessionStatus(pauseRes.data.data);
|
||||
setRecording(false);
|
||||
setConnecting(false);
|
||||
startedAtRef.current = null;
|
||||
setStatusText(pauseRes.data.data?.hasTranscript ? "已暂停,可继续识别" : "已暂停,当前还没有转录内容");
|
||||
message.success("实时识别已暂停");
|
||||
} catch (error) {
|
||||
setStatusText("暂停失败");
|
||||
message.error(error instanceof Error ? error.message : "暂停实时识别失败");
|
||||
} finally {
|
||||
setPausing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!sessionDraft?.asrModelId) {
|
||||
message.error("未找到实时识别配置,请返回创建页重新进入");
|
||||
|
|
@ -384,6 +475,7 @@ export default function RealtimeAsrSession() {
|
|||
|
||||
setConnecting(true);
|
||||
setStatusText("连接识别服务...");
|
||||
sessionStartedRef.current = false;
|
||||
try {
|
||||
const socketSessionRes = await openRealtimeMeetingSocketSession(meetingId, {
|
||||
asrModelId: sessionDraft.asrModelId,
|
||||
|
|
@ -401,21 +493,37 @@ export default function RealtimeAsrSession() {
|
|||
socket.binaryType = "arraybuffer";
|
||||
wsRef.current = socket;
|
||||
|
||||
socket.onopen = async () => {
|
||||
socket.send(JSON.stringify(socketSession.startMessage || {}));
|
||||
await startAudioPipeline();
|
||||
startedAtRef.current = Date.now();
|
||||
setConnecting(false);
|
||||
setRecording(true);
|
||||
setStatusText("实时识别中");
|
||||
socket.onopen = () => {
|
||||
setStatusText("识别服务连接中,等待第三方服务就绪...");
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as WsMessage;
|
||||
if (payload.code && payload.message) {
|
||||
if (payload.type === "proxy_ready") {
|
||||
if (sessionStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
sessionStartedRef.current = true;
|
||||
setStatusText("启动音频采集中...");
|
||||
socket.send(JSON.stringify(socketSession.startMessage || {}));
|
||||
void startAudioPipeline()
|
||||
.then(() => {
|
||||
startedAtRef.current = Date.now();
|
||||
setConnecting(false);
|
||||
setRecording(true);
|
||||
setSessionStatus((prev) => prev ? { ...prev, status: "ACTIVE", activeConnection: true } : prev);
|
||||
setStatusText("实时识别中");
|
||||
})
|
||||
.catch((error) => {
|
||||
void handleFatalRealtimeError(error instanceof Error ? error.message : "启动麦克风失败");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((payload.code || payload.type === "error") && payload.message) {
|
||||
setStatusText(payload.message);
|
||||
message.error(payload.message);
|
||||
void handleFatalRealtimeError(payload.message);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -451,19 +559,19 @@ export default function RealtimeAsrSession() {
|
|||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
setConnecting(false);
|
||||
setRecording(false);
|
||||
setStatusText("连接失败");
|
||||
message.error("实时识别 WebSocket 连接失败");
|
||||
void handleFatalRealtimeError("实时识别 WebSocket 连接失败");
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
setConnecting(false);
|
||||
setRecording(false);
|
||||
sessionStartedRef.current = false;
|
||||
setSessionStatus((prev) => prev ? { ...prev, activeConnection: false } : prev);
|
||||
};
|
||||
} catch (error) {
|
||||
setConnecting(false);
|
||||
setStatusText("启动失败");
|
||||
sessionStartedRef.current = false;
|
||||
message.error(error instanceof Error ? error.message : "启动实时识别失败");
|
||||
}
|
||||
};
|
||||
|
|
@ -482,24 +590,38 @@ export default function RealtimeAsrSession() {
|
|||
}
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
sessionStartedRef.current = false;
|
||||
|
||||
await shutdownAudioPipeline();
|
||||
|
||||
try {
|
||||
await completeRealtimeMeeting(meetingId, {});
|
||||
sessionStorage.removeItem(getSessionKey(meetingId));
|
||||
setSessionStatus((prev) => prev ? { ...prev, status: "COMPLETING", canResume: false, activeConnection: false } : prev);
|
||||
setStatusText("已提交总结任务");
|
||||
message.success("实时会议已结束,正在生成总结");
|
||||
if (navigateAfterStop) {
|
||||
navigate(`/meetings/${meetingId}`);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
completeOnceRef.current = false;
|
||||
setStatusText("结束失败");
|
||||
const errorMessage = error instanceof Error ? error.message : "结束会议失败";
|
||||
if (errorMessage.includes("当前还没有转录内容")) {
|
||||
try {
|
||||
const statusRes = await getRealtimeMeetingSessionStatus(meetingId);
|
||||
setSessionStatus(statusRes.data.data);
|
||||
} catch {
|
||||
// ignore status refresh failure
|
||||
}
|
||||
setStatusText("当前还没有转录内容,可继续识别");
|
||||
} else {
|
||||
setStatusText("结束失败");
|
||||
}
|
||||
} finally {
|
||||
setRecording(false);
|
||||
setFinishing(false);
|
||||
startedAtRef.current = null;
|
||||
sessionStartedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -636,10 +758,13 @@ export default function RealtimeAsrSession() {
|
|||
</div>
|
||||
|
||||
<Space style={{ width: "100%" }}>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} disabled={recording || connecting || finishing} loading={connecting} onClick={() => void handleStart()} style={{ flex: 1, height: 42 }}>
|
||||
开始识别
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} disabled={recording || connecting || finishing || pausing || hasRemoteActiveConnection} loading={connecting} onClick={() => void handleStart()} style={{ flex: 1, height: 42 }}>
|
||||
{sessionStatus?.status === "ACTIVE" && hasRemoteActiveConnection ? "连接占用中" : sessionStatus?.status === "PAUSED_EMPTY" || sessionStatus?.status === "PAUSED_RESUMABLE" ? "继续识别" : "开始识别"}
|
||||
</Button>
|
||||
<Button danger icon={<PauseCircleOutlined />} disabled={(!recording && !connecting) || finishing} loading={finishing} onClick={() => void handleStop(true)} style={{ flex: 1, height: 42 }}>
|
||||
<Button icon={<PauseCircleOutlined />} disabled={(!recording && !connecting) || finishing || pausing} loading={pausing} onClick={() => void handlePause()} style={{ flex: 1, height: 42 }}>
|
||||
暂停识别
|
||||
</Button>
|
||||
<Button danger icon={<PauseCircleOutlined />} disabled={(!recording && !connecting && !sessionStatus?.hasTranscript) || finishing || pausing} loading={finishing} onClick={() => void handleStop(true)} style={{ flex: 1, height: 42 }}>
|
||||
结束会议
|
||||
</Button>
|
||||
</Space>
|
||||
|
|
@ -667,7 +792,7 @@ export default function RealtimeAsrSession() {
|
|||
</div>
|
||||
|
||||
<div style={{ marginTop: "auto" }}>
|
||||
<Alert type="info" showIcon message="异常关闭保护" description="最终转录会实时写入会议;页面关闭时会自动尝试结束会议并触发总结,避免会中内容整体丢失。" />
|
||||
<Alert type="info" showIcon message="异常关闭保护" description="最终转录会实时写入会议;页面关闭时会优先尝试暂停会议。当前还没有转录内容时,结束会议会被拦截并保留空会话。" />
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -680,7 +805,7 @@ export default function RealtimeAsrSession() {
|
|||
<Text type="secondary">优先展示最终片段,流式草稿保留在底部作为当前正在识别的内容。</Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Tag icon={<SoundOutlined />} color={recording ? "processing" : "default"}>{recording ? "采集中" : connecting ? "连接中" : "待命"}</Tag>
|
||||
<Tag icon={<SoundOutlined />} color={recording ? "processing" : sessionStatus?.status === "ACTIVE" && hasRemoteActiveConnection ? "processing" : sessionStatus?.status === "PAUSED_RESUMABLE" || sessionStatus?.status === "PAUSED_EMPTY" ? "warning" : "default"}>{recording ? "采集中" : connecting ? "连接中" : sessionStatus?.status === "ACTIVE" && hasRemoteActiveConnection ? "连接占用中" : sessionStatus?.status === "PAUSED_RESUMABLE" || sessionStatus?.status === "PAUSED_EMPTY" ? "已暂停" : "待命"}</Tag>
|
||||
<Tag color="blue">{sessionDraft.asrModelName}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
|
|
@ -688,7 +813,7 @@ export default function RealtimeAsrSession() {
|
|||
<div ref={transcriptRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 18, background: "linear-gradient(180deg, #f8fafc 0%, #ffffff 65%, #f8fafc 100%)" }}>
|
||||
{transcripts.length === 0 && !streamingText ? (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<Empty description="会议已创建,点击左侧开始识别即可进入转写。" />
|
||||
<Empty description={hasRemoteActiveConnection ? "当前会议已有活跃连接,请先关闭旧连接后再继续。" : "会议已创建,点击左侧开始识别即可进入转写。"} />
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||
|
|
@ -732,3 +857,4 @@ export default function RealtimeAsrSession() {
|
|||
);
|
||||
}
|
||||
|
||||
export default RealtimeAsrSession;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Spin } from "antd";
|
||||
import { Spin } from "antd";
|
||||
import { Suspense, lazy } from "react";
|
||||
import type { MenuRoute } from "@/types";
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ const RolePermissionBinding = lazy(() => import("@/pages/bindings/role-permissio
|
|||
|
||||
import SpeakerReg from "../pages/business/SpeakerReg";
|
||||
import RealtimeAsr from "../pages/business/RealtimeAsr";
|
||||
import RealtimeAsrSession from "../pages/business/RealtimeAsrSession";
|
||||
const RealtimeAsrSession = lazy(async () => { const mod = await import("../pages/business/RealtimeAsrSession"); return { default: mod.default ?? mod.RealtimeAsrSession }; });
|
||||
import HotWords from "../pages/business/HotWords";
|
||||
import PromptTemplates from "../pages/business/PromptTemplates";
|
||||
import AiModels from "../pages/business/AiModels";
|
||||
|
|
|
|||
Loading…
Reference in New Issue