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