feat: 增加实时会议配置选项和WebSocket支持
- 在 `RealtimeAsr` 组件中添加语言、标点、ITN、文本修正和音频保存等配置选项 - 添加构建WebSocket URL的函数 `buildRealtimeProxyPreviewUrl` - 更新 `meeting.ts` API,增加 `openRealtimeMeetingSocketSession` 接口 - 更新 `vite.config.ts`,添加WebSocket代理配置 - 优化 `RealtimeAsrSession` 组件,处理WebSocket消息并支持新的配置选项dev_na
parent
60754bbd26
commit
9d1a8710af
|
|
@ -27,6 +27,10 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ public final class RedisKeys {
|
|||
return "biz:meeting:polling:lock:" + meetingId;
|
||||
}
|
||||
|
||||
public static String realtimeMeetingSocketSessionKey(String sessionToken) {
|
||||
return "biz:meeting:realtime:socket:" + sessionToken;
|
||||
}
|
||||
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO;
|
|||
import com.imeeting.dto.biz.MeetingSummaryExportResult;
|
||||
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.RealtimeSocketSessionVO;
|
||||
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
||||
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
|
||||
import com.imeeting.dto.biz.UpdateMeetingParticipantsCommand;
|
||||
|
|
@ -20,6 +22,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.RealtimeMeetingSocketSessionService;
|
||||
import com.unisbase.common.ApiResponse;
|
||||
import com.unisbase.dto.PageResult;
|
||||
import com.unisbase.security.LoginUser;
|
||||
|
|
@ -59,6 +62,7 @@ public class MeetingController {
|
|||
private final MeetingAccessService meetingAccessService;
|
||||
private final MeetingExportService meetingExportService;
|
||||
private final PromptTemplateService promptTemplateService;
|
||||
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final String uploadPath;
|
||||
private final String resourcePrefix;
|
||||
|
|
@ -68,6 +72,7 @@ public class MeetingController {
|
|||
MeetingAccessService meetingAccessService,
|
||||
MeetingExportService meetingExportService,
|
||||
PromptTemplateService promptTemplateService,
|
||||
RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService,
|
||||
StringRedisTemplate redisTemplate,
|
||||
@Value("${unisbase.app.upload-path}") String uploadPath,
|
||||
@Value("${unisbase.app.resource-prefix}") String resourcePrefix) {
|
||||
|
|
@ -76,6 +81,7 @@ public class MeetingController {
|
|||
this.meetingAccessService = meetingAccessService;
|
||||
this.meetingExportService = meetingExportService;
|
||||
this.promptTemplateService = promptTemplateService;
|
||||
this.realtimeMeetingSocketSessionService = realtimeMeetingSocketSessionService;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.uploadPath = uploadPath;
|
||||
this.resourcePrefix = resourcePrefix;
|
||||
|
|
@ -225,6 +231,26 @@ public class MeetingController {
|
|||
return ApiResponse.ok(true);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/realtime/socket-session")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<RealtimeSocketSessionVO> openRealtimeSocketSession(@PathVariable Long id,
|
||||
@RequestBody OpenRealtimeSocketSessionCommand command) {
|
||||
LoginUser loginUser = currentLoginUser();
|
||||
return ApiResponse.ok(realtimeMeetingSocketSessionService.createSession(
|
||||
id,
|
||||
command.getAsrModelId(),
|
||||
command.getMode(),
|
||||
command.getLanguage(),
|
||||
command.getUseSpkId(),
|
||||
command.getEnablePunctuation(),
|
||||
command.getEnableItn(),
|
||||
command.getEnableTextRefine(),
|
||||
command.getSaveAudio(),
|
||||
command.getHotwords(),
|
||||
loginUser
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/realtime/complete")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> completeRealtimeMeeting(@PathVariable Long id, @RequestBody(required = false) RealtimeMeetingCompleteDTO dto) {
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
||||
.eq(MeetingTranscript::getMeetingId, meetingId)
|
||||
.orderByAsc(MeetingTranscript::getStartTime));
|
||||
|
||||
|
||||
if (transcripts.isEmpty()) {
|
||||
throw new RuntimeException("没有找到可用的转录文本,无法生成总结");
|
||||
}
|
||||
|
|
@ -157,11 +157,11 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
|
||||
private String processAsrTask(Meeting meeting, AiTask taskRecord) throws Exception {
|
||||
updateMeetingStatus(meeting.getId(), 1);
|
||||
|
||||
|
||||
taskRecord.setStatus(1);
|
||||
taskRecord.setStartedAt(LocalDateTime.now());
|
||||
this.updateById(taskRecord);
|
||||
|
||||
|
||||
Long asrModelId = Long.valueOf(taskRecord.getTaskConfig().get("asrModelId").toString());
|
||||
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
|
||||
if (asrModel == null) throw new RuntimeException("ASR模型配置不存在");
|
||||
|
|
@ -173,7 +173,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
Map<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
|
||||
taskRecord.setRequestData(req);
|
||||
this.updateById(taskRecord);
|
||||
|
||||
|
||||
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
||||
JsonNode submitNode = objectMapper.readTree(respBody);
|
||||
if (submitNode.path("code").asInt() != 0) {
|
||||
|
|
@ -185,7 +185,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
this.updateById(taskRecord);
|
||||
|
||||
String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId);
|
||||
|
||||
|
||||
// 轮询逻辑 (带防卡死防护)
|
||||
JsonNode resultNode = null;
|
||||
int lastPercent = -1;
|
||||
|
|
@ -208,7 +208,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
} else {
|
||||
int currentPercent = data.path("percentage").asInt();
|
||||
int eta = data.path("eta_seconds").asInt(statusNode.path("eta_seconds").asInt(data.path("eta").asInt(0)));
|
||||
updateProgress(meeting.getId(), (int)(currentPercent * 0.85), data.path("message").asText(), eta);
|
||||
updateProgress(meeting.getId(), (int) (currentPercent * 0.85), data.path("message").asText(), eta);
|
||||
|
||||
if (currentPercent > 0 && currentPercent == lastPercent) {
|
||||
if (++unchangedCount > 300) throw new RuntimeException("识别任务长时间无进度增长,自动强制超时");
|
||||
|
|
@ -230,8 +230,11 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
String rawAudioUrl = meeting.getAudioUrl();
|
||||
String encodedAudioUrl = Arrays.stream(rawAudioUrl.split("/"))
|
||||
.map(part -> {
|
||||
try { return URLEncoder.encode(part, StandardCharsets.UTF_8).replace("+", "%20"); }
|
||||
catch (Exception e) { return part; }
|
||||
try {
|
||||
return URLEncoder.encode(part, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
} catch (Exception e) {
|
||||
return part;
|
||||
}
|
||||
})
|
||||
.collect(Collectors.joining("/"));
|
||||
req.put("file_url", serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl);
|
||||
|
|
@ -240,13 +243,12 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
if (asrModel.getModelCode() != null && !asrModel.getModelCode().isBlank()) {
|
||||
config.put("model", asrModel.getModelCode());
|
||||
}
|
||||
|
||||
|
||||
Object useSpkObj = taskRecord.getTaskConfig().get("useSpkId");
|
||||
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
||||
config.put("enable_speaker", useSpk);
|
||||
config.put("enable_two_pass", true);
|
||||
|
||||
|
||||
List<Map<String, Object>> hotwords = new ArrayList<>();
|
||||
Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords");
|
||||
if (hotWordsObj instanceof List) {
|
||||
|
|
@ -254,7 +256,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
if (!words.isEmpty()) {
|
||||
List<HotWord> entities = hotWordService.list(new LambdaQueryWrapper<HotWord>()
|
||||
.eq(HotWord::getTenantId, meeting.getTenantId()).in(HotWord::getWord, words));
|
||||
Map<String, Integer> weightMap = entities.stream().collect(Collectors.toMap(HotWord::getWord, HotWord::getWeight, (v1, v2) -> v1));
|
||||
Map<String, Integer> weightMap = entities.stream()
|
||||
.collect(Collectors.toMap(HotWord::getWord, HotWord::getWeight, (v1, v2) -> v1));
|
||||
for (String w : words) {
|
||||
hotwords.add(Map.of("hotword", w, "weight", weightMap.getOrDefault(w, 10) / 10.0));
|
||||
}
|
||||
|
|
@ -269,7 +272,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
protected String saveTranscripts(Meeting meeting, JsonNode resultNode) {
|
||||
// 关键:入库前清理旧记录,防止恢复任务导致数据重复
|
||||
transcriptMapper.delete(new LambdaQueryWrapper<MeetingTranscript>().eq(MeetingTranscript::getMeetingId, meeting.getId()));
|
||||
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
JsonNode segments = resultNode.path("segments");
|
||||
if (segments.isArray()) {
|
||||
|
|
@ -277,7 +280,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
for (JsonNode seg : segments) {
|
||||
MeetingTranscript mt = new MeetingTranscript();
|
||||
mt.setMeetingId(meeting.getId());
|
||||
|
||||
|
||||
String spkId = extractSpeakerId(seg);
|
||||
String spkName = resolveTranscriptSpeakerName(seg, spkId);
|
||||
|
||||
|
|
@ -390,24 +393,24 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
private void processSummaryTask(Meeting meeting, String asrText, AiTask taskRecord) throws Exception {
|
||||
updateMeetingStatus(meeting.getId(), 2);
|
||||
updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0);
|
||||
|
||||
|
||||
taskRecord.setStatus(1);
|
||||
taskRecord.setStartedAt(LocalDateTime.now());
|
||||
this.updateById(taskRecord);
|
||||
|
||||
|
||||
Long summaryModelId = Long.valueOf(taskRecord.getTaskConfig().get("summaryModelId").toString());
|
||||
AiModelVO llmModel = aiModelService.getModelById(summaryModelId, "LLM");
|
||||
if (llmModel == null) return;
|
||||
|
||||
String promptContent = taskRecord.getTaskConfig().get("promptContent") != null ?
|
||||
taskRecord.getTaskConfig().get("promptContent").toString() : "";
|
||||
|
||||
String promptContent = taskRecord.getTaskConfig().get("promptContent") != null
|
||||
? taskRecord.getTaskConfig().get("promptContent").toString() : "";
|
||||
|
||||
Map<String, Object> req = new HashMap<>();
|
||||
req.put("model", llmModel.getModelCode());
|
||||
req.put("temperature", llmModel.getTemperature());
|
||||
req.put("messages", List.of(
|
||||
Map.of("role", "system", "content", buildSummarySystemPrompt(promptContent)),
|
||||
Map.of("role", "user", "content", buildSummaryUserPrompt(meeting, asrText))
|
||||
Map.of("role", "system", "content", buildSummarySystemPrompt(promptContent)),
|
||||
Map.of("role", "user", "content", buildSummaryUserPrompt(meeting, asrText))
|
||||
));
|
||||
|
||||
taskRecord.setRequestData(req);
|
||||
|
|
@ -416,7 +419,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
String url = llmModel.getBaseUrl() + (llmModel.getApiPath() != null ? llmModel.getApiPath() : "/v1/chat/completions");
|
||||
String requestBody = objectMapper.writeValueAsString(req);
|
||||
log.info("Sending LLM summary request to url={}, body={}", url, requestBody);
|
||||
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
|
|
@ -432,18 +435,22 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
if (response.statusCode() == 200 && respNode.has("choices")) {
|
||||
String content = sanitizeSummaryContent(respNode.path("choices").path(0).path("message").path("content").asText());
|
||||
Map<String, Object> summaryBundle = meetingSummaryFileService.parseSummaryBundle(content);
|
||||
String markdownContent = summaryBundle != null
|
||||
? String.valueOf(summaryBundle.getOrDefault("summaryContent", ""))
|
||||
: content;
|
||||
if (markdownContent == null || markdownContent.isBlank()) {
|
||||
markdownContent = content;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> normalizedAnalysis = summaryBundle != null
|
||||
? (Map<String, Object>) summaryBundle.get("analysis")
|
||||
: meetingSummaryFileService.parseSummaryAnalysis(content);
|
||||
|
||||
// Save to File
|
||||
|
||||
String markdownContent = summaryBundle != null
|
||||
? String.valueOf(summaryBundle.getOrDefault("summaryContent", ""))
|
||||
: "";
|
||||
if ((markdownContent == null || markdownContent.isBlank()) && normalizedAnalysis != null && !normalizedAnalysis.isEmpty()) {
|
||||
markdownContent = meetingSummaryFileService.buildSummaryMarkdown(normalizedAnalysis);
|
||||
}
|
||||
if (markdownContent == null || markdownContent.isBlank()) {
|
||||
updateAiTaskFail(taskRecord, "LLM summary content parse failed: " + content);
|
||||
throw new RuntimeException("AI总结结果解析失败,未生成可保存的会议纪要");
|
||||
}
|
||||
|
||||
String timestamp = java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
|
||||
String fileName = "summary_" + timestamp + ".md";
|
||||
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
|
||||
|
|
@ -451,29 +458,25 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
Files.createDirectories(targetDir);
|
||||
Path filePath = targetDir.resolve(fileName);
|
||||
|
||||
Files.writeString(filePath, markdownContent, StandardCharsets.UTF_8);
|
||||
|
||||
Files.writeString(filePath, markdownContent, StandardCharsets.UTF_8);
|
||||
|
||||
taskRecord.setResultFilePath("meetings/" + meeting.getId() + "/summaries/" + fileName);
|
||||
if (summaryBundle != null || normalizedAnalysis != null) {
|
||||
Map<String, Object> responseData = objectMapper.convertValue(respNode, Map.class);
|
||||
if (summaryBundle != null) {
|
||||
responseData.put("summaryBundle", summaryBundle);
|
||||
}
|
||||
if (normalizedAnalysis != null) {
|
||||
responseData.put("normalizedAnalysis", normalizedAnalysis);
|
||||
}
|
||||
taskRecord.setResponseData(responseData);
|
||||
taskRecord.setStatus(2);
|
||||
taskRecord.setCompletedAt(LocalDateTime.now());
|
||||
this.updateById(taskRecord);
|
||||
} else {
|
||||
updateAiTaskSuccess(taskRecord, respNode);
|
||||
Map<String, Object> responseData = objectMapper.convertValue(respNode, Map.class);
|
||||
if (summaryBundle != null) {
|
||||
responseData.put("summaryBundle", summaryBundle);
|
||||
}
|
||||
|
||||
if (normalizedAnalysis != null) {
|
||||
responseData.put("normalizedAnalysis", normalizedAnalysis);
|
||||
}
|
||||
taskRecord.setResponseData(responseData);
|
||||
taskRecord.setStatus(2);
|
||||
taskRecord.setCompletedAt(LocalDateTime.now());
|
||||
this.updateById(taskRecord);
|
||||
|
||||
meeting.setLatestSummaryTaskId(taskRecord.getId());
|
||||
meeting.setStatus(3);
|
||||
meeting.setStatus(3);
|
||||
meetingMapper.updateById(meeting);
|
||||
|
||||
|
||||
updateProgress(meeting.getId(), 100, "全流程分析完成", 0);
|
||||
} else {
|
||||
updateAiTaskFail(taskRecord, "LLM Summary failed: " + response.body());
|
||||
|
|
@ -488,8 +491,12 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
progress.put("message", msg);
|
||||
progress.put("eta", eta);
|
||||
progress.put("updateAt", System.currentTimeMillis());
|
||||
redisTemplate.opsForValue().set(RedisKeys.meetingProgressKey(meetingId),
|
||||
objectMapper.writeValueAsString(progress), 1, TimeUnit.HOURS);
|
||||
redisTemplate.opsForValue().set(
|
||||
RedisKeys.meetingProgressKey(meetingId),
|
||||
objectMapper.writeValueAsString(progress),
|
||||
1,
|
||||
TimeUnit.HOURS
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.error("Redis progress update error", e);
|
||||
}
|
||||
|
|
@ -633,23 +640,34 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
}
|
||||
|
||||
private void updateMeetingStatus(Long id, int status) {
|
||||
Meeting m = new Meeting(); m.setId(id); m.setStatus(status); meetingMapper.updateById(m);
|
||||
Meeting m = new Meeting();
|
||||
m.setId(id);
|
||||
m.setStatus(status);
|
||||
meetingMapper.updateById(m);
|
||||
}
|
||||
|
||||
private AiTask createAiTask(Long meetingId, String type, Map<String, Object> req) {
|
||||
AiTask task = new AiTask();
|
||||
task.setMeetingId(meetingId); task.setTaskType(type); task.setStatus(1);
|
||||
task.setRequestData(req); task.setStartedAt(LocalDateTime.now());
|
||||
this.save(task); return task;
|
||||
task.setMeetingId(meetingId);
|
||||
task.setTaskType(type);
|
||||
task.setStatus(1);
|
||||
task.setRequestData(req);
|
||||
task.setStartedAt(LocalDateTime.now());
|
||||
this.save(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
private void updateAiTaskSuccess(AiTask task, JsonNode resp) {
|
||||
task.setStatus(2); task.setResponseData(objectMapper.convertValue(resp, Map.class));
|
||||
task.setCompletedAt(LocalDateTime.now()); this.updateById(task);
|
||||
task.setStatus(2);
|
||||
task.setResponseData(objectMapper.convertValue(resp, Map.class));
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
this.updateById(task);
|
||||
}
|
||||
|
||||
private void updateAiTaskFail(AiTask task, String error) {
|
||||
task.setStatus(3); task.setErrorMsg(error);
|
||||
task.setCompletedAt(LocalDateTime.now()); this.updateById(task);
|
||||
task.setStatus(3);
|
||||
task.setErrorMsg(error);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
this.updateById(task);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.imeeting.service.biz.impl;
|
|||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.common.RedisKeys;
|
||||
import com.imeeting.dto.biz.CreateMeetingCommand;
|
||||
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
|
||||
import com.imeeting.dto.biz.MeetingVO;
|
||||
|
|
@ -18,12 +20,14 @@ import com.imeeting.service.biz.MeetingCommandService;
|
|||
import com.imeeting.service.biz.MeetingService;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
|
|
@ -36,6 +40,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper;
|
||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||
private final MeetingDomainSupport meetingDomainSupport;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
|
|
@ -158,9 +164,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
if (transcriptCount <= 0) {
|
||||
meeting.setStatus(4);
|
||||
meetingService.updateById(meeting);
|
||||
throw new RuntimeException("鏈帴鏀跺埌鍙敤鐨勫疄鏃惰浆褰曞唴瀹?");
|
||||
throw new RuntimeException("当前会议还没有可用的转录文本,无法生成总结");
|
||||
}
|
||||
|
||||
meeting.setStatus(2);
|
||||
meetingService.updateById(meeting);
|
||||
updateMeetingProgress(meetingId, 90, "正在生成智能总结纪要...", 0);
|
||||
aiTaskService.dispatchSummaryTask(meetingId);
|
||||
}
|
||||
|
||||
|
|
@ -232,4 +241,22 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
meetingService.updateById(meeting);
|
||||
aiTaskService.dispatchSummaryTask(meetingId);
|
||||
}
|
||||
|
||||
private void updateMeetingProgress(Long meetingId, int percent, String message, int eta) {
|
||||
try {
|
||||
Map<String, Object> progress = new HashMap<>();
|
||||
progress.put("percent", percent);
|
||||
progress.put("message", message);
|
||||
progress.put("eta", eta);
|
||||
progress.put("updateAt", System.currentTimeMillis());
|
||||
redisTemplate.opsForValue().set(
|
||||
RedisKeys.meetingProgressKey(meetingId),
|
||||
objectMapper.writeValueAsString(progress),
|
||||
1,
|
||||
TimeUnit.HOURS
|
||||
);
|
||||
} catch (Exception ignored) {
|
||||
// Ignore progress write failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,25 @@ export interface RealtimeTranscriptItemDTO {
|
|||
endTime?: number;
|
||||
}
|
||||
|
||||
export interface RealtimeSocketSessionVO {
|
||||
sessionToken: string;
|
||||
path: string;
|
||||
expiresInSeconds: number;
|
||||
startMessage: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface RealtimeSocketSessionRequest {
|
||||
asrModelId: number;
|
||||
mode?: string;
|
||||
language?: string;
|
||||
useSpkId?: number;
|
||||
enablePunctuation?: boolean;
|
||||
enableItn?: boolean;
|
||||
enableTextRefine?: boolean;
|
||||
saveAudio?: boolean;
|
||||
hotwords?: Array<{ hotword: string; weight: number }>;
|
||||
}
|
||||
|
||||
export const createRealtimeMeeting = (data: CreateMeetingCommand) => {
|
||||
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
||||
"/api/biz/meeting/realtime/start",
|
||||
|
|
@ -98,6 +117,16 @@ export const appendRealtimeTranscripts = (meetingId: number, data: RealtimeTrans
|
|||
);
|
||||
};
|
||||
|
||||
export const openRealtimeMeetingSocketSession = (
|
||||
meetingId: number,
|
||||
data: RealtimeSocketSessionRequest,
|
||||
) => {
|
||||
return http.post<any, { code: string; data: RealtimeSocketSessionVO; msg: string }>(
|
||||
`/api/biz/meeting/${meetingId}/realtime/socket-session`,
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const completeRealtimeMeeting = (meetingId: number, data?: { audioUrl?: string }) => {
|
||||
return http.post<any, { code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${meetingId}/realtime/complete`,
|
||||
|
|
|
|||
|
|
@ -47,9 +47,14 @@ type RealtimeMeetingSessionDraft = {
|
|||
meetingTitle: string;
|
||||
asrModelName: string;
|
||||
summaryModelName: string;
|
||||
wsUrl: string;
|
||||
asrModelId: number;
|
||||
mode: string;
|
||||
language: string;
|
||||
useSpkId: number;
|
||||
enablePunctuation: boolean;
|
||||
enableItn: boolean;
|
||||
enableTextRefine: boolean;
|
||||
saveAudio: boolean;
|
||||
hotwords: Array<{ hotword: string; weight: number }>;
|
||||
};
|
||||
|
||||
|
|
@ -63,6 +68,11 @@ function resolveWsUrl(model?: AiModelVO | null) {
|
|||
return "";
|
||||
}
|
||||
|
||||
function buildRealtimeProxyPreviewUrl() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${protocol}://${window.location.host}/ws/meeting/realtime`;
|
||||
}
|
||||
|
||||
function getSessionKey(meetingId: number) {
|
||||
return `realtimeMeetingSession:${meetingId}`;
|
||||
}
|
||||
|
|
@ -127,6 +137,11 @@ export default function RealtimeAsr() {
|
|||
promptId: activePrompts[0]?.id,
|
||||
useSpkId: 1,
|
||||
mode: "2pass",
|
||||
language: "auto",
|
||||
enablePunctuation: true,
|
||||
enableItn: true,
|
||||
enableTextRefine: false,
|
||||
saveAudio: false,
|
||||
});
|
||||
} catch {
|
||||
message.error("加载实时会议配置失败");
|
||||
|
|
@ -172,9 +187,14 @@ export default function RealtimeAsr() {
|
|||
meetingTitle: createdMeeting.title,
|
||||
asrModelName: selectedAsrModel?.modelName || "ASR",
|
||||
summaryModelName: selectedSummaryModel?.modelName || "LLM",
|
||||
wsUrl,
|
||||
asrModelId: selectedAsrModel?.id || values.asrModelId,
|
||||
mode: values.mode || "2pass",
|
||||
language: values.language || "auto",
|
||||
useSpkId: values.useSpkId ? 1 : 0,
|
||||
enablePunctuation: values.enablePunctuation !== false,
|
||||
enableItn: values.enableItn !== false,
|
||||
enableTextRefine: !!values.enableTextRefine,
|
||||
saveAudio: !!values.saveAudio,
|
||||
hotwords: selectedHotwords,
|
||||
};
|
||||
|
||||
|
|
@ -190,10 +210,7 @@ export default function RealtimeAsr() {
|
|||
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<PageHeader
|
||||
title="实时识别会议"
|
||||
subtitle="先配置再进入会中识别,减少会中页面干扰。"
|
||||
/>
|
||||
<PageHeader title="实时识别会议" subtitle="先完成配置,再进入会中识别页面,减少会中页面干扰。" />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
{loading ? (
|
||||
|
|
@ -210,15 +227,37 @@ export default function RealtimeAsr() {
|
|||
style={{ height: "100%", borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)" }}
|
||||
bodyStyle={{ height: "100%", padding: 16, display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<div style={{ marginBottom: 12, padding: 14, borderRadius: 16, background: "linear-gradient(135deg, #f8fbff 0%, #eef6ff 58%, #ffffff 100%)", border: "1px solid #dbeafe", flexShrink: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
padding: 14,
|
||||
borderRadius: 16,
|
||||
background: "linear-gradient(135deg, #f8fbff 0%, #eef6ff 58%, #ffffff 100%)",
|
||||
border: "1px solid #dbeafe",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={6}>
|
||||
<Space size={10}>
|
||||
<div style={{ width: 42, height: 42, borderRadius: 12, background: "#1677ff", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 12,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>创建实时会议</Title>
|
||||
<Text type="secondary">完成会前配置后再进入会中识别页。</Text>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
创建实时会议
|
||||
</Title>
|
||||
<Text type="secondary">会前完成配置后再进入会中识别页。</Text>
|
||||
</div>
|
||||
</Space>
|
||||
<Space wrap size={[8, 8]}>
|
||||
|
|
@ -229,113 +268,184 @@ export default function RealtimeAsr() {
|
|||
</Space>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" initialValues={{ mode: "2pass", useSpkId: 1 }} style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
mode: "2pass",
|
||||
language: "auto",
|
||||
useSpkId: 1,
|
||||
enablePunctuation: true,
|
||||
enableItn: true,
|
||||
enableTextRefine: false,
|
||||
saveAudio: false,
|
||||
}}
|
||||
style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="title" label="会议标题" rules={[{ required: true, message: "请输入会议标题" }]}>
|
||||
<Input placeholder="例如:产品例会实时记录" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true, message: "请选择会议时间" }]}>
|
||||
<DatePicker showTime style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="title" label="会议标题" rules={[{ required: true, message: "请输入会议标题" }]}>
|
||||
<Input placeholder="例如:产品例会实时记录" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="meetingTime"
|
||||
label="会议时间"
|
||||
rules={[{ required: true, message: "请选择会议时间" }]}
|
||||
>
|
||||
<DatePicker showTime style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="participants" label="参会人员">
|
||||
<Select mode="multiple" showSearch optionFilterProp="children" placeholder="选择参会人员">
|
||||
{userList.map((user) => (
|
||||
<Option key={user.userId} value={user.userId}>
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} />
|
||||
{user.displayName || user.username}
|
||||
</Space>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="tags" label="会议标签">
|
||||
<Select mode="tags" placeholder="输入标签后回车" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="participants" label="参会人员">
|
||||
<Select mode="multiple" showSearch optionFilterProp="children" placeholder="选择参会人员">
|
||||
{userList.map((user) => (
|
||||
<Option key={user.userId} value={user.userId}>
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} />
|
||||
{user.displayName || user.username}
|
||||
</Space>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="tags" label="会议标签">
|
||||
<Select mode="tags" placeholder="输入标签后回车" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="asrModelId" label="识别模型 (ASR)" rules={[{ required: true, message: "请选择 ASR 模型" }]}>
|
||||
<Select placeholder="选择实时识别模型">
|
||||
{asrModels.map((model) => (
|
||||
<Option key={model.id} value={model.id}>{model.modelName}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true, message: "请选择总结模型" }]}>
|
||||
<Select placeholder="选择总结模型">
|
||||
{llmModels.map((model) => (
|
||||
<Option key={model.id} value={model.id}>{model.modelName}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="asrModelId"
|
||||
label="识别模型 (ASR)"
|
||||
rules={[{ required: true, message: "请选择 ASR 模型" }]}
|
||||
>
|
||||
<Select placeholder="选择实时识别模型">
|
||||
{asrModels.map((model) => (
|
||||
<Option key={model.id} value={model.id}>
|
||||
{model.modelName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="summaryModelId"
|
||||
label="总结模型 (LLM)"
|
||||
rules={[{ required: true, message: "请选择总结模型" }]}
|
||||
>
|
||||
<Select placeholder="选择总结模型">
|
||||
{llmModels.map((model) => (
|
||||
<Option key={model.id} value={model.id}>
|
||||
{model.modelName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="promptId" label="总结模板" rules={[{ required: true, message: "请选择总结模板" }]}>
|
||||
<Select placeholder="选择总结模板">
|
||||
{prompts.map((prompt) => (
|
||||
<Option key={prompt.id} value={prompt.id}>{prompt.templateName}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="hotWords" label={<span>热词增强 <Tooltip title="不选择时将带上系统当前启用的热词"><QuestionCircleOutlined /></Tooltip></span>}>
|
||||
<Select mode="multiple" allowClear placeholder="可选热词">
|
||||
{hotwordList.map((item) => (
|
||||
<Option key={item.word} value={item.word}>{item.word}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="promptId"
|
||||
label="总结模板"
|
||||
rules={[{ required: true, message: "请选择总结模板" }]}
|
||||
>
|
||||
<Select placeholder="选择总结模板">
|
||||
{prompts.map((prompt) => (
|
||||
<Option key={prompt.id} value={prompt.id}>
|
||||
{prompt.templateName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="hotWords"
|
||||
label={
|
||||
<span>
|
||||
热词增强{" "}
|
||||
<Tooltip title="不选择时将带上系统当前启用的热词">
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Select mode="multiple" allowClear placeholder="可选热词">
|
||||
{hotwordList.map((item) => (
|
||||
<Option key={item.word} value={item.word}>
|
||||
{item.word}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} align="middle">
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="mode" label="识别模式">
|
||||
<Select>
|
||||
<Option value="2pass">2pass</Option>
|
||||
<Option value="online">online</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item
|
||||
name="useSpkId"
|
||||
label={<span>说话人区分 <Tooltip title="开启后会尝试区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>}
|
||||
valuePropName="checked"
|
||||
getValueProps={(value) => ({ checked: value === 1 || value === true })}
|
||||
normalize={(value) => (value ? 1 : 0)}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="WebSocket 地址">
|
||||
<Input value={resolveWsUrl(selectedAsrModel)} prefix={<LinkOutlined />} readOnly />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="language" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="enablePunctuation" hidden valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="enableItn" hidden valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="saveAudio" hidden valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16} align="middle">
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="mode" label="识别模式">
|
||||
<Select>
|
||||
<Option value="2pass">2pass</Option>
|
||||
<Option value="online">online</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="useSpkId"
|
||||
label={
|
||||
<span>
|
||||
说话人区分
|
||||
<Tooltip title="开启后会尝试区分不同发言人">
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
valuePropName="checked"
|
||||
getValueProps={(value) => ({ checked: value === 1 || value === true })}
|
||||
normalize={(value) => (value ? 1 : 0)}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="WebSocket 地址">
|
||||
<Input value={buildRealtimeProxyPreviewUrl()} prefix={<LinkOutlined />} readOnly />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="enableTextRefine" label="文本修正" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -347,34 +457,70 @@ export default function RealtimeAsr() {
|
|||
bodyStyle={{ height: "100%", padding: 16, display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
<Col span={12}><Statistic title="参会人数" value={watchedParticipants.length} prefix={<TeamOutlined />} /></Col>
|
||||
<Col span={12}><Statistic title="热词数量" value={selectedHotwordCount} prefix={<MessageOutlined />} /></Col>
|
||||
<Col span={12}><Statistic title="说话人区分" value={watchedUseSpkId ? "开启" : "关闭"} prefix={<CheckCircleOutlined />} /></Col>
|
||||
<Col span={12}><Statistic title="识别链路" value={selectedAsrModel ? "已就绪" : "待配置"} prefix={<AudioOutlined />} /></Col>
|
||||
<Col span={12}>
|
||||
<Statistic title="参会人数" value={watchedParticipants.length} prefix={<TeamOutlined />} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic title="热词数量" value={selectedHotwordCount} prefix={<MessageOutlined />} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="说话人区分"
|
||||
value={watchedUseSpkId ? "开启" : "关闭"}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="识别链路"
|
||||
value={selectedAsrModel ? "已就绪" : "待配置"}
|
||||
prefix={<AudioOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Space direction="vertical" size={12} style={{ width: "100%", flex: 1, minHeight: 0 }}>
|
||||
<div><Text strong style={{ fontSize: 15 }}>本次识别摘要</Text></div>
|
||||
<div style={{ padding: 14, borderRadius: 14, background: "#fafcff", border: "1px solid #edf2ff" }}>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">ASR</Text><Text strong>{selectedAsrModel?.modelName || "-"}</Text></div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">LLM</Text><Text strong>{selectedSummaryModel?.modelName || "-"}</Text></div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}><Text type="secondary">WebSocket</Text><Text ellipsis style={{ maxWidth: 220 }}>{resolveWsUrl(selectedAsrModel) || "-"}</Text></div>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 15 }}>
|
||||
本次识别摘要
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ padding: 14, borderRadius: 14, background: "#fafcff", border: "1px solid #edf2ff" }}>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">ASR</Text>
|
||||
<Text strong>{selectedAsrModel?.modelName || "-"}</Text>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">LLM</Text>
|
||||
<Text strong>{selectedSummaryModel?.modelName || "-"}</Text>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">WebSocket</Text>
|
||||
<Text ellipsis style={{ maxWidth: 220 }}>
|
||||
{buildRealtimeProxyPreviewUrl()}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="异常关闭保护"
|
||||
description="会中页持续写库,并在关闭时自动兜底结束。"
|
||||
/>
|
||||
<div style={{ marginTop: "auto", padding: 12, borderRadius: 14, background: "#f6ffed", border: "1px solid #b7eb8f" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 10 }}>
|
||||
<Text type="secondary">创建成功后会直接进入识别页,不会在当前页占用麦克风。</Text>
|
||||
<Space>
|
||||
<Button onClick={() => navigate("/meetings")}>返回会议中心</Button>
|
||||
<Button type="primary" icon={<RocketOutlined />} loading={submitting} onClick={() => void handleCreate()}>
|
||||
创建并进入识别
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Alert type="info" showIcon message="异常关闭保护" description="会中页持续写库并在关闭时自动兜底结束。" />
|
||||
<div style={{ marginTop: "auto", padding: 12, borderRadius: 14, background: "#f6ffed", border: "1px solid #b7eb8f" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 10 }}>
|
||||
<Text type="secondary">创建成功后会直接进入识别页,不会在当前页面占用麦克风。</Text>
|
||||
<Space>
|
||||
<Button onClick={() => navigate("/meetings")}>返回会议中心</Button>
|
||||
<Button type="primary" icon={<RocketOutlined />} loading={submitting} onClick={() => void handleCreate()}>
|
||||
创建并进入识别
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
PlayCircleOutlined,
|
||||
SoundOutlined,
|
||||
SyncOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import dayjs from "dayjs";
|
||||
|
|
@ -29,10 +31,11 @@ import {
|
|||
completeRealtimeMeeting,
|
||||
getMeetingDetail,
|
||||
getTranscripts,
|
||||
uploadAudio,
|
||||
openRealtimeMeetingSocketSession,
|
||||
type MeetingTranscriptVO,
|
||||
type MeetingVO,
|
||||
type RealtimeTranscriptItemDTO,
|
||||
type RealtimeSocketSessionVO,
|
||||
} from "../../api/business/meeting";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
|
@ -41,6 +44,18 @@ const CHUNK_SIZE = 1280;
|
|||
|
||||
type WsSpeaker = string | { name?: string; user_id?: string | number } | undefined;
|
||||
type WsMessage = {
|
||||
type?: string;
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
text?: string;
|
||||
is_final?: boolean;
|
||||
start?: number;
|
||||
end?: number;
|
||||
speaker_id?: string;
|
||||
speaker_name?: string;
|
||||
user_id?: string | number | null;
|
||||
};
|
||||
text?: string;
|
||||
is_final?: boolean;
|
||||
speaker?: WsSpeaker;
|
||||
|
|
@ -62,9 +77,14 @@ type RealtimeMeetingSessionDraft = {
|
|||
meetingTitle: string;
|
||||
asrModelName: string;
|
||||
summaryModelName: string;
|
||||
wsUrl: string;
|
||||
asrModelId: number;
|
||||
mode: string;
|
||||
language: string;
|
||||
useSpkId: number;
|
||||
enablePunctuation: boolean;
|
||||
enableItn: boolean;
|
||||
enableTextRefine: boolean;
|
||||
saveAudio: boolean;
|
||||
hotwords: Array<{ hotword: string; weight: number }>;
|
||||
};
|
||||
|
||||
|
|
@ -72,40 +92,6 @@ function getSessionKey(meetingId: number) {
|
|||
return `realtimeMeetingSession:${meetingId}`;
|
||||
}
|
||||
|
||||
function buildWavBlob(samples: number[], sampleRate: number) {
|
||||
const pcmBuffer = new ArrayBuffer(samples.length * 2);
|
||||
const pcmView = new DataView(pcmBuffer);
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const value = Math.max(-1, Math.min(1, samples[i]));
|
||||
pcmView.setInt16(i * 2, value < 0 ? value * 0x8000 : value * 0x7fff, true);
|
||||
}
|
||||
|
||||
const wavBuffer = new ArrayBuffer(44 + pcmBuffer.byteLength);
|
||||
const wavView = new DataView(wavBuffer);
|
||||
const writeString = (offset: number, text: string) => {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
wavView.setUint8(offset + i, text.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
writeString(0, "RIFF");
|
||||
wavView.setUint32(4, 36 + pcmBuffer.byteLength, true);
|
||||
writeString(8, "WAVE");
|
||||
writeString(12, "fmt ");
|
||||
wavView.setUint32(16, 16, true);
|
||||
wavView.setUint16(20, 1, true);
|
||||
wavView.setUint16(22, 1, true);
|
||||
wavView.setUint32(24, sampleRate, true);
|
||||
wavView.setUint32(28, sampleRate * 2, true);
|
||||
wavView.setUint16(32, 2, true);
|
||||
wavView.setUint16(34, 16, true);
|
||||
writeString(36, "data");
|
||||
wavView.setUint32(40, pcmBuffer.byteLength, true);
|
||||
new Uint8Array(wavBuffer, 44).set(new Uint8Array(pcmBuffer));
|
||||
|
||||
return new Blob([wavBuffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function floatTo16BitPCM(input: Float32Array) {
|
||||
const buffer = new ArrayBuffer(input.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
|
|
@ -150,6 +136,46 @@ function formatTranscriptTime(ms?: number) {
|
|||
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toMs(value?: number) {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.round(value * 1000);
|
||||
}
|
||||
|
||||
function buildRealtimeProxyWsUrl(socketSession: RealtimeSocketSessionVO) {
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${protocol}://${window.location.host}${socketSession.path}?sessionToken=${encodeURIComponent(socketSession.sessionToken)}`;
|
||||
}
|
||||
|
||||
function normalizeWsMessage(payload: WsMessage) {
|
||||
if (payload.type === "partial" || payload.type === "segment") {
|
||||
const data = payload.data || {};
|
||||
return {
|
||||
text: data.text || "",
|
||||
isFinal: payload.type === "segment" || !!data.is_final,
|
||||
speaker: {
|
||||
name: data.speaker_name,
|
||||
user_id: data.user_id ?? data.speaker_id,
|
||||
} as WsSpeaker,
|
||||
startTime: toMs(data.start),
|
||||
endTime: toMs(data.end),
|
||||
};
|
||||
}
|
||||
|
||||
if (!payload.text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
text: payload.text,
|
||||
isFinal: !!payload.is_final,
|
||||
speaker: payload.speaker,
|
||||
startTime: payload.timestamp?.[0]?.[0],
|
||||
endTime: payload.timestamp?.[payload.timestamp.length - 1]?.[1],
|
||||
};
|
||||
}
|
||||
|
||||
export default function RealtimeAsrSession() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
|
@ -175,7 +201,6 @@ export default function RealtimeAsrSession() {
|
|||
const audioSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioBufferRef = useRef<number[]>([]);
|
||||
const recordedSamplesRef = useRef<number[]>([]);
|
||||
const completeOnceRef = useRef(false);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
|
||||
|
|
@ -278,13 +303,14 @@ export default function RealtimeAsrSession() {
|
|||
audioSourceRef.current = null;
|
||||
audioContextRef.current = null;
|
||||
audioBufferRef.current = [];
|
||||
const recordedSamples = recordedSamplesRef.current;
|
||||
recordedSamplesRef.current = [];
|
||||
setAudioLevel(0);
|
||||
return recordedSamples.length > 0 ? buildWavBlob(recordedSamples, SAMPLE_RATE) : null;
|
||||
};
|
||||
|
||||
const startAudioPipeline = async () => {
|
||||
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error("当前浏览器环境不支持麦克风访问。请使用 localhost 或 HTTPS 域名访问系统。");
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
|
|
@ -300,7 +326,6 @@ export default function RealtimeAsrSession() {
|
|||
audioContextRef.current = audioContext;
|
||||
audioSourceRef.current = source;
|
||||
processorRef.current = processor;
|
||||
recordedSamplesRef.current = [];
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
const input = event.inputBuffer.getChannelData(0);
|
||||
|
|
@ -311,7 +336,6 @@ export default function RealtimeAsrSession() {
|
|||
maxAmplitude = amplitude;
|
||||
}
|
||||
audioBufferRef.current.push(input[i]);
|
||||
recordedSamplesRef.current.push(input[i]);
|
||||
}
|
||||
|
||||
setAudioLevel(Math.min(100, Math.round(maxAmplitude * 180)));
|
||||
|
|
@ -329,23 +353,28 @@ export default function RealtimeAsrSession() {
|
|||
processor.connect(audioContext.destination);
|
||||
};
|
||||
|
||||
const saveFinalTranscript = async (msg: WsMessage) => {
|
||||
if (!msg.text || !meetingId) {
|
||||
const saveFinalTranscript = async (normalized: {
|
||||
text: string;
|
||||
speaker?: WsSpeaker;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
}) => {
|
||||
if (!normalized.text || !meetingId) {
|
||||
return;
|
||||
}
|
||||
const speaker = resolveSpeaker(msg.speaker);
|
||||
const speaker = resolveSpeaker(normalized.speaker);
|
||||
const item: RealtimeTranscriptItemDTO = {
|
||||
speakerId: speaker.speakerId,
|
||||
speakerName: speaker.speakerName,
|
||||
content: msg.text,
|
||||
startTime: msg.timestamp?.[0]?.[0],
|
||||
endTime: msg.timestamp?.[msg.timestamp.length - 1]?.[1],
|
||||
content: normalized.text,
|
||||
startTime: normalized.startTime,
|
||||
endTime: normalized.endTime,
|
||||
};
|
||||
await appendRealtimeTranscripts(meetingId, [item]);
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!sessionDraft?.wsUrl) {
|
||||
if (!sessionDraft?.asrModelId) {
|
||||
message.error("未找到实时识别配置,请返回创建页重新进入");
|
||||
return;
|
||||
}
|
||||
|
|
@ -356,22 +385,24 @@ export default function RealtimeAsrSession() {
|
|||
setConnecting(true);
|
||||
setStatusText("连接识别服务...");
|
||||
try {
|
||||
const socket = new WebSocket(sessionDraft.wsUrl);
|
||||
const socketSessionRes = await openRealtimeMeetingSocketSession(meetingId, {
|
||||
asrModelId: sessionDraft.asrModelId,
|
||||
mode: sessionDraft.mode || "2pass",
|
||||
language: sessionDraft.language || "auto",
|
||||
useSpkId: sessionDraft.useSpkId,
|
||||
enablePunctuation: sessionDraft.enablePunctuation !== false,
|
||||
enableItn: sessionDraft.enableItn !== false,
|
||||
enableTextRefine: !!sessionDraft.enableTextRefine,
|
||||
saveAudio: !!sessionDraft.saveAudio,
|
||||
hotwords: sessionDraft.hotwords || [],
|
||||
});
|
||||
const socketSession = socketSessionRes.data.data;
|
||||
const socket = new WebSocket(buildRealtimeProxyWsUrl(socketSession));
|
||||
socket.binaryType = "arraybuffer";
|
||||
wsRef.current = socket;
|
||||
|
||||
socket.onopen = async () => {
|
||||
socket.send(JSON.stringify({
|
||||
mode: sessionDraft.mode || "2pass",
|
||||
chunk_size: [0, 8, 4],
|
||||
chunk_interval: 4,
|
||||
wav_name: `meeting_${meetingId}`,
|
||||
is_speaking: true,
|
||||
speaker_name: null,
|
||||
use_spk_id: sessionDraft.useSpkId,
|
||||
save_audio: false,
|
||||
hotwords: sessionDraft.hotwords,
|
||||
}));
|
||||
socket.send(JSON.stringify(socketSession.startMessage || {}));
|
||||
await startAudioPipeline();
|
||||
startedAtRef.current = Date.now();
|
||||
setConnecting(false);
|
||||
|
|
@ -382,29 +413,36 @@ export default function RealtimeAsrSession() {
|
|||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as WsMessage;
|
||||
if (!payload.text) {
|
||||
if (payload.code && payload.message) {
|
||||
setStatusText(payload.message);
|
||||
message.error(payload.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const speaker = resolveSpeaker(payload.speaker);
|
||||
if (payload.is_final) {
|
||||
const normalized = normalizeWsMessage(payload);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const speaker = resolveSpeaker(normalized.speaker);
|
||||
if (normalized.isFinal) {
|
||||
setTranscripts((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `${Date.now()}-${Math.random()}`,
|
||||
speakerName: speaker.speakerName,
|
||||
userId: speaker.userId,
|
||||
text: payload.text,
|
||||
startTime: payload.timestamp?.[0]?.[0],
|
||||
endTime: payload.timestamp?.[payload.timestamp.length - 1]?.[1],
|
||||
text: normalized.text,
|
||||
startTime: normalized.startTime,
|
||||
endTime: normalized.endTime,
|
||||
final: true,
|
||||
},
|
||||
]);
|
||||
setStreamingText("");
|
||||
setStreamingSpeaker("Unknown");
|
||||
void saveFinalTranscript(payload);
|
||||
void saveFinalTranscript(normalized);
|
||||
} else {
|
||||
setStreamingText(payload.text);
|
||||
setStreamingText(normalized.text);
|
||||
setStreamingSpeaker(speaker.speakerName);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -423,10 +461,10 @@ export default function RealtimeAsrSession() {
|
|||
setConnecting(false);
|
||||
setRecording(false);
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
setConnecting(false);
|
||||
setStatusText("启动失败");
|
||||
message.error("启动实时识别失败");
|
||||
message.error(error instanceof Error ? error.message : "启动实时识别失败");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -445,20 +483,10 @@ export default function RealtimeAsrSession() {
|
|||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
|
||||
const audioBlob = await shutdownAudioPipeline();
|
||||
let uploadedAudioUrl: string | undefined;
|
||||
if (audioBlob) {
|
||||
try {
|
||||
const file = new File([audioBlob], `meeting-${meetingId}.wav`, { type: audioBlob.type || "audio/wav" });
|
||||
const uploadRes = await uploadAudio(file);
|
||||
uploadedAudioUrl = uploadRes.data.data;
|
||||
} catch {
|
||||
message.warning("会议音频上传失败,已保留转录内容");
|
||||
}
|
||||
}
|
||||
await shutdownAudioPipeline();
|
||||
|
||||
try {
|
||||
await completeRealtimeMeeting(meetingId, uploadedAudioUrl ? { audioUrl: uploadedAudioUrl } : {});
|
||||
await completeRealtimeMeeting(meetingId, {});
|
||||
sessionStorage.removeItem(getSessionKey(meetingId));
|
||||
setStatusText("已提交总结任务");
|
||||
message.success("实时会议已结束,正在生成总结");
|
||||
|
|
@ -499,6 +527,80 @@ export default function RealtimeAsrSession() {
|
|||
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<style>{`
|
||||
.ant-list-item.transcript-row,
|
||||
.live-transcript-row {
|
||||
display: grid !important;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
justify-content: flex-start !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.transcript-time {
|
||||
position: relative;
|
||||
padding-top: 10px;
|
||||
color: #58627f;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.transcript-time::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: 8px;
|
||||
border-radius: 50%;
|
||||
background: #6e76ff;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.transcript-row:not(:last-child) .transcript-time::before,
|
||||
.live-transcript-row:not(:last-child) .transcript-time::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 38px;
|
||||
width: 1px;
|
||||
height: calc(100% + 12px);
|
||||
background: rgba(218, 223, 243, 0.96);
|
||||
}
|
||||
.transcript-entry {
|
||||
justify-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.transcript-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
color: #8e98b8;
|
||||
}
|
||||
.transcript-avatar {
|
||||
background: linear-gradient(135deg, #7a84ff, #9363ff) !important;
|
||||
}
|
||||
.transcript-speaker {
|
||||
color: #5e698d;
|
||||
font-weight: 700;
|
||||
}
|
||||
.transcript-bubble {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 18px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(234, 238, 248, 1);
|
||||
box-shadow: 0 12px 28px rgba(137, 149, 193, 0.08);
|
||||
color: #3f496a;
|
||||
line-height: 1.86;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`}</style>
|
||||
<PageHeader
|
||||
title={meeting.title || "实时识别中"}
|
||||
subtitle={`会议编号 #${meeting.id} · ${dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm")}`}
|
||||
|
|
@ -512,7 +614,7 @@ export default function RealtimeAsrSession() {
|
|||
type="warning"
|
||||
showIcon
|
||||
message="缺少实时识别启动配置"
|
||||
description="这个会议的实时会中配置没有保存在当前浏览器中,请返回创建页重新进入。"
|
||||
description="这个会议的实时识别配置没有保存在当前浏览器中,请返回创建页重新进入。"
|
||||
action={<Button size="small" onClick={() => navigate("/meeting-live-create")}>返回创建页</Button>}
|
||||
/>
|
||||
</Card>
|
||||
|
|
@ -529,7 +631,7 @@ export default function RealtimeAsrSession() {
|
|||
<Space direction="vertical" size={8}>
|
||||
<Tag color="blue" style={{ width: "fit-content", margin: 0 }}>LIVE SESSION</Tag>
|
||||
<Title level={4} style={{ color: "#fff", margin: 0 }}>会中实时识别</Title>
|
||||
<Text style={{ color: "rgba(255,255,255,0.82)" }}>会中页只保留控制区和实时转写流。</Text>
|
||||
<Text style={{ color: "rgba(255,255,255,0.82)" }}>会中页面只保留控制区和实时转写流。</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
|
@ -586,37 +688,35 @@ 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="会议已创建,点击左侧开始识别即可进入转写。" />
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||
{transcripts.map((item) => (
|
||||
<div key={item.id} style={{ padding: 16, borderRadius: 16, background: "#fff", boxShadow: "0 6px 18px rgba(15,23,42,0.05)", display: "grid", gridTemplateColumns: "46px 1fr", gap: 14 }}>
|
||||
<div style={{ width: 46, height: 46, borderRadius: "50%", background: "#e6f4ff", color: "#1677ff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 700, flexShrink: 0 }}>
|
||||
{item.speakerName.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<Space wrap size={[8, 8]} style={{ marginBottom: 6 }}>
|
||||
<Text strong>{item.speakerName}</Text>
|
||||
<div key={item.id} className="live-transcript-row">
|
||||
<div className="transcript-time">{formatTranscriptTime(item.startTime)}</div>
|
||||
<div className="transcript-entry">
|
||||
<div className="transcript-meta">
|
||||
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
||||
<span className="transcript-speaker">{item.speakerName}</span>
|
||||
{item.userId ? <Tag color="blue">UID: {item.userId}</Tag> : null}
|
||||
<Tag icon={<ClockCircleOutlined />}>{formatTranscriptTime(item.startTime)} - {formatTranscriptTime(item.endTime)}</Tag>
|
||||
</Space>
|
||||
<div style={{ color: "#1f2937", lineHeight: 1.8 }}>{item.text}</div>
|
||||
<Text type="secondary">{formatTranscriptTime(item.startTime)} - {formatTranscriptTime(item.endTime)}</Text>
|
||||
</div>
|
||||
<div className="transcript-bubble">{item.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{streamingText ? (
|
||||
<div style={{ padding: 16, borderRadius: 16, background: "linear-gradient(135deg, rgba(230,244,255,0.9), rgba(245,250,255,0.96))", border: "1px solid #b7d8ff", display: "grid", gridTemplateColumns: "46px 1fr", gap: 14 }}>
|
||||
<div style={{ width: 46, height: 46, borderRadius: "50%", background: "#1677ff", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 700 }}>
|
||||
{streamingSpeaker.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<Space wrap size={[8, 8]} style={{ marginBottom: 6 }}>
|
||||
<Text strong>{streamingSpeaker}</Text>
|
||||
<div className="live-transcript-row">
|
||||
<div className="transcript-time">--:--</div>
|
||||
<div className="transcript-entry">
|
||||
<div className="transcript-meta">
|
||||
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
||||
<span className="transcript-speaker">{streamingSpeaker}</span>
|
||||
<Tag color="processing">流式草稿</Tag>
|
||||
</Space>
|
||||
<div style={{ color: "#334155", lineHeight: 1.8 }}>{streamingText}</div>
|
||||
</div>
|
||||
<div className="transcript-bubble">{streamingText}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -631,3 +731,4 @@ export default function RealtimeAsrSession() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ export default defineConfig({
|
|||
proxy: {
|
||||
"/auth": "http://localhost:8081",
|
||||
"/sys": "http://localhost:8081",
|
||||
"/api": "http://localhost:8081"
|
||||
"/api": "http://localhost:8081",
|
||||
"/ws": {
|
||||
target: "ws://localhost:8081",
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue