feat: 增加实时会议配置选项和WebSocket支持

- 在 `RealtimeAsr` 组件中添加语言、标点、ITN、文本修正和音频保存等配置选项
- 添加构建WebSocket URL的函数 `buildRealtimeProxyPreviewUrl`
- 更新 `meeting.ts` API,增加 `openRealtimeMeetingSocketSession` 接口
- 更新 `vite.config.ts`,添加WebSocket代理配置
- 优化 `RealtimeAsrSession` 组件,处理WebSocket消息并支持新的配置选项
dev_na
chenhao 2026-03-30 17:56:30 +08:00
parent 60754bbd26
commit 9d1a8710af
9 changed files with 659 additions and 300 deletions

View File

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

View File

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

View File

@ -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) {

View File

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

View File

@ -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.
}
}
}

View File

@ -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`,

View File

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

View File

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

View File

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