feat: 添加总结详细程度选项和优化用户认证逻辑

- 在 `MeetingCommandService` 中添加 `summaryDetailLevel` 参数
- 更新 `MeetingSummaryPromptAssembler` 以支持不同详细程度的总结
- 在前端 `useAuth` 钩子中添加 `profileVersion` 状态,同步用户资料更新
- 优化密码重置流程,保持登录状态并更新用户资料
- 在 `Meeting` 实体中添加 `summaryDetailLevel` 字段
- 更新相关服务和控制器以处理新的总结详细程度选项
dev_na
chenhao 2026-05-13 14:31:48 +08:00
parent 51190f330c
commit f7480df565
29 changed files with 1583 additions and 1168 deletions

View File

@ -7,6 +7,9 @@ public final class MeetingConstants {
public static final String SOURCE_WEB = "WEB"; public static final String SOURCE_WEB = "WEB";
public static final String SOURCE_ANDROID = "ANDROID"; public static final String SOURCE_ANDROID = "ANDROID";
public static final String SUMMARY_DETAIL_DETAILED = "DETAILED";
public static final String SUMMARY_DETAIL_STANDARD = "STANDARD";
public static final String SUMMARY_DETAIL_BRIEF = "BRIEF";
private MeetingConstants() { private MeetingConstants() {
} }

View File

@ -8,4 +8,6 @@ public final class SysParamKeys {
public static final String MEETING_TRANSCRIPT_CLEANUP_FILLER_WORDS = "meeting.transcript.cleanup.filler_words"; public static final String MEETING_TRANSCRIPT_CLEANUP_FILLER_WORDS = "meeting.transcript.cleanup.filler_words";
public static final String MEETING_TRANSCRIPT_CLEANUP_REPLACEMENTS = "meeting.transcript.cleanup.replacements"; public static final String MEETING_TRANSCRIPT_CLEANUP_REPLACEMENTS = "meeting.transcript.cleanup.replacements";
public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb"; public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb";
public static final String MEETING_CREATE_OFFLINE_ENABLED = "meeting.create.offline_enabled";
public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled";
} }

View File

@ -200,6 +200,7 @@ public class AndroidMeetingRealtimeController {
createCommand.setAsrModelId(runtimeProfile.getResolvedAsrModelId()); createCommand.setAsrModelId(runtimeProfile.getResolvedAsrModelId());
createCommand.setSummaryModelId(runtimeProfile.getResolvedSummaryModelId()); createCommand.setSummaryModelId(runtimeProfile.getResolvedSummaryModelId());
createCommand.setPromptId(runtimeProfile.getResolvedPromptId()); createCommand.setPromptId(runtimeProfile.getResolvedPromptId());
createCommand.setSummaryDetailLevel(command == null ? null : command.getSummaryDetailLevel());
createCommand.setMode(runtimeProfile.getResolvedMode()); createCommand.setMode(runtimeProfile.getResolvedMode());
createCommand.setLanguage(runtimeProfile.getResolvedLanguage()); createCommand.setLanguage(runtimeProfile.getResolvedLanguage());
createCommand.setUseSpkId(runtimeProfile.getResolvedUseSpkId()); createCommand.setUseSpkId(runtimeProfile.getResolvedUseSpkId());

View File

@ -2,8 +2,10 @@ package com.imeeting.controller.biz;
import com.imeeting.common.MeetingConstants; import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys; import com.imeeting.common.RedisKeys;
import com.imeeting.common.SysParamKeys;
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.MeetingCreateConfigVO;
import com.imeeting.dto.biz.MeetingResummaryDTO; import com.imeeting.dto.biz.MeetingResummaryDTO;
import com.imeeting.dto.biz.MeetingSummaryOrchestrationTriggerResultVO; import com.imeeting.dto.biz.MeetingSummaryOrchestrationTriggerResultVO;
import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO; import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO;
@ -34,6 +36,7 @@ import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log; import com.unisbase.common.annotation.Log;
import com.unisbase.dto.PageResult; import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import com.unisbase.service.SysParamService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@ -77,6 +80,7 @@ public class MeetingController {
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final MeetingAudioUploadSupport meetingAudioUploadSupport; private final MeetingAudioUploadSupport meetingAudioUploadSupport;
private final StringRedisTemplate redisTemplate; private final StringRedisTemplate redisTemplate;
private final SysParamService sysParamService;
public MeetingController(MeetingQueryService meetingQueryService, public MeetingController(MeetingQueryService meetingQueryService,
MeetingCommandService meetingCommandService, MeetingCommandService meetingCommandService,
@ -87,7 +91,8 @@ public class MeetingController {
RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService, RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService,
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService, RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
MeetingAudioUploadSupport meetingAudioUploadSupport, MeetingAudioUploadSupport meetingAudioUploadSupport,
StringRedisTemplate redisTemplate) { StringRedisTemplate redisTemplate,
SysParamService sysParamService) {
this.meetingQueryService = meetingQueryService; this.meetingQueryService = meetingQueryService;
this.meetingCommandService = meetingCommandService; this.meetingCommandService = meetingCommandService;
this.meetingAccessService = meetingAccessService; this.meetingAccessService = meetingAccessService;
@ -98,6 +103,7 @@ public class MeetingController {
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService; this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
this.meetingAudioUploadSupport = meetingAudioUploadSupport; this.meetingAudioUploadSupport = meetingAudioUploadSupport;
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
this.sysParamService = sysParamService;
} }
@Operation(summary = "查询会议处理进度") @Operation(summary = "查询会议处理进度")
@ -139,6 +145,17 @@ public class MeetingController {
return ApiResponse.ok(meetingAudioUploadSupport.storeUploadedAudio(file)); return ApiResponse.ok(meetingAudioUploadSupport.storeUploadedAudio(file));
} }
@Operation(summary = "获取会议创建配置")
@GetMapping("/create-config")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingCreateConfigVO> getCreateConfig() {
MeetingCreateConfigVO vo = new MeetingCreateConfigVO();
vo.setOfflineEnabled(resolveBooleanParam(SysParamKeys.MEETING_CREATE_OFFLINE_ENABLED, true));
vo.setRealtimeEnabled(resolveBooleanParam(SysParamKeys.MEETING_CREATE_REALTIME_ENABLED, true));
vo.setOfflineAudioMaxSizeMb(resolveLongParam(SysParamKeys.MEETING_OFFLINE_AUDIO_MAX_SIZE_MB, 1024L));
return ApiResponse.ok(vo);
}
@Operation(summary = "创建离线会议") @Operation(summary = "创建离线会议")
@PostMapping @PostMapping
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
@ -414,7 +431,14 @@ public class MeetingController {
meetingAccessService.assertCanEditMeeting(meeting, loginUser); meetingAccessService.assertCanEditMeeting(meeting, loginUser);
dto.setMeetingId(id); dto.setMeetingId(id);
assertPromptAvailable(dto.getPromptId(), loginUser); assertPromptAvailable(dto.getPromptId(), loginUser);
meetingCommandService.reSummary(dto.getMeetingId(), dto.getSummaryModelId(), dto.getChapterModelId(), dto.getPromptId(), dto.getUserPrompt()); meetingCommandService.reSummary(
dto.getMeetingId(),
dto.getSummaryModelId(),
dto.getChapterModelId(),
dto.getPromptId(),
dto.getUserPrompt(),
dto.getSummaryDetailLevel()
);
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@ -501,4 +525,32 @@ public class MeetingController {
private String resolveCreatorName(LoginUser loginUser) { private String resolveCreatorName(LoginUser loginUser) {
return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername();
} }
private boolean resolveBooleanParam(String key, boolean defaultValue) {
String rawValue = sysParamService.getCachedParamValue(key, String.valueOf(defaultValue));
if (rawValue == null || rawValue.isBlank()) {
return defaultValue;
}
String normalized = rawValue.trim().toLowerCase();
if ("1".equals(normalized) || "true".equals(normalized) || "yes".equals(normalized) || "on".equals(normalized)) {
return true;
}
if ("0".equals(normalized) || "false".equals(normalized) || "no".equals(normalized) || "off".equals(normalized)) {
return false;
}
return defaultValue;
}
private long resolveLongParam(String key, long defaultValue) {
String rawValue = sysParamService.getCachedParamValue(key, String.valueOf(defaultValue));
if (rawValue == null || rawValue.isBlank()) {
return defaultValue;
}
try {
long parsed = Long.parseLong(rawValue.trim());
return parsed > 0 ? parsed : defaultValue;
} catch (NumberFormatException ex) {
return defaultValue;
}
}
} }

View File

@ -1,6 +1,7 @@
package com.imeeting.dto.android; package com.imeeting.dto.android;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -20,6 +21,8 @@ public class AndroidCreateRealtimeMeetingCommand {
private Long asrModelId; private Long asrModelId;
private Long summaryModelId; private Long summaryModelId;
private Long promptId; private Long promptId;
@Schema(description = "总结详细程度DETAILED=详细STANDARD=标准BRIEF=简洁")
private String summaryDetailLevel;
private String mode; private String mode;
private String language; private String language;
private Integer useSpkId; private Integer useSpkId;

View File

@ -1,6 +1,8 @@
package com.imeeting.dto.biz; package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.imeeting.common.MeetingConstants;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@ -9,6 +11,7 @@ import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Schema(description = "创建离线会议请求")
@Data @Data
public class CreateMeetingCommand { public class CreateMeetingCommand {
@NotBlank(message = "标题不能为空") @NotBlank(message = "标题不能为空")
@ -42,6 +45,16 @@ public class CreateMeetingCommand {
@Size(max = 2000, message = "userPrompt length must be <= 2000") @Size(max = 2000, message = "userPrompt length must be <= 2000")
private String userPrompt; private String userPrompt;
@Schema(
description = "总结详细程度DETAILED=详细STANDARD=标准BRIEF=简洁",
allowableValues = {
MeetingConstants.SUMMARY_DETAIL_DETAILED,
MeetingConstants.SUMMARY_DETAIL_STANDARD,
MeetingConstants.SUMMARY_DETAIL_BRIEF
}
)
private String summaryDetailLevel;
private Integer useSpkId; private Integer useSpkId;
private Boolean enableTextRefine; private Boolean enableTextRefine;
private List<String> hotWords; private List<String> hotWords;

View File

@ -1,6 +1,8 @@
package com.imeeting.dto.biz; package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.imeeting.common.MeetingConstants;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@ -9,6 +11,7 @@ import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Schema(description = "创建实时会议请求")
@Data @Data
public class CreateRealtimeMeetingCommand { public class CreateRealtimeMeetingCommand {
@NotBlank(message = "标题不能为空") @NotBlank(message = "标题不能为空")
@ -39,6 +42,16 @@ public class CreateRealtimeMeetingCommand {
@Size(max = 2000, message = "userPrompt length must be <= 2000") @Size(max = 2000, message = "userPrompt length must be <= 2000")
private String userPrompt; private String userPrompt;
@Schema(
description = "总结详细程度DETAILED=详细STANDARD=标准BRIEF=简洁",
allowableValues = {
MeetingConstants.SUMMARY_DETAIL_DETAILED,
MeetingConstants.SUMMARY_DETAIL_STANDARD,
MeetingConstants.SUMMARY_DETAIL_BRIEF
}
)
private String summaryDetailLevel;
private String mode; private String mode;
private String language; private String language;
private Integer useSpkId; private Integer useSpkId;

View File

@ -1,8 +1,11 @@
package com.imeeting.dto.biz; package com.imeeting.dto.biz;
import lombok.Data; import com.imeeting.common.MeetingConstants;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "重新生成会议摘要请求")
@Data @Data
public class MeetingResummaryDTO { public class MeetingResummaryDTO {
private Long meetingId; private Long meetingId;
@ -12,4 +15,14 @@ public class MeetingResummaryDTO {
@Size(max = 2000, message = "userPrompt length must be <= 2000") @Size(max = 2000, message = "userPrompt length must be <= 2000")
private String userPrompt; private String userPrompt;
@Schema(
description = "总结详细程度DETAILED=详细STANDARD=标准BRIEF=简洁",
allowableValues = {
MeetingConstants.SUMMARY_DETAIL_DETAILED,
MeetingConstants.SUMMARY_DETAIL_STANDARD,
MeetingConstants.SUMMARY_DETAIL_BRIEF
}
)
private String summaryDetailLevel;
} }

View File

@ -43,6 +43,8 @@ public class MeetingVO {
private String meetingType; private String meetingType;
@Schema(description = "会议来源") @Schema(description = "会议来源")
private String meetingSource; private String meetingSource;
@Schema(description = "总结详细程度")
private String summaryDetailLevel;
@Schema(description = "音频保存状态") @Schema(description = "音频保存状态")
private String audioSaveStatus; private String audioSaveStatus;
@Schema(description = "音频保存说明") @Schema(description = "音频保存说明")

View File

@ -40,6 +40,8 @@ public class Meeting extends BaseEntity {
@Schema(description = "会议来源") @Schema(description = "会议来源")
private String meetingSource; private String meetingSource;
@Schema(description = "总结详细程度")
private String summaryDetailLevel;
@Schema(description = "音频保存状态") @Schema(description = "音频保存状态")
private String audioSaveStatus; private String audioSaveStatus;

View File

@ -81,6 +81,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
resolveCreatorName(loginUser), resolveCreatorName(loginUser),
loginUser.getUserId(), loginUser.getUserId(),
resolveCreatorName(loginUser), resolveCreatorName(loginUser),
MeetingConstants.SUMMARY_DETAIL_STANDARD,
0 0
); );
meetingService.save(meeting); meetingService.save(meeting);

View File

@ -37,7 +37,7 @@ public interface MeetingCommandService {
void updateSummaryContent(Long meetingId, String summaryContent); void updateSummaryContent(Long meetingId, String summaryContent);
void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt); void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt, String summaryDetailLevel);
void retryTranscription(Long meetingId); void retryTranscription(Long meetingId);

View File

@ -940,7 +940,7 @@ public class AiModelServiceImpl implements AiModelService {
entity.setApiPath(dto.getApiPath()); entity.setApiPath(dto.getApiPath());
entity.setApiKey(dto.getApiKey()); entity.setApiKey(dto.getApiKey());
entity.setModelCode(dto.getModelCode()); entity.setModelCode(dto.getModelCode());
entity.setTemperature(dto.getTemperature() == null ? BigDecimal.valueOf(0.7) : dto.getTemperature()); entity.setTemperature(dto.getTemperature() == null ? BigDecimal.valueOf(0.2) : dto.getTemperature());
entity.setTopP(dto.getTopP() == null ? BigDecimal.valueOf(0.9) : dto.getTopP()); entity.setTopP(dto.getTopP() == null ? BigDecimal.valueOf(0.9) : dto.getTopP());
entity.setIsDefault(dto.getIsDefault()); entity.setIsDefault(dto.getIsDefault());
entity.setStatus(dto.getStatus()); entity.setStatus(dto.getStatus());

View File

@ -83,8 +83,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId); RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId); String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
String summaryDetailLevel = resolveSummaryDetailLevel(command.getSummaryDetailLevel());
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
command.getAudioUrl(), MeetingConstants.TYPE_OFFLINE, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, 0); command.getAudioUrl(), MeetingConstants.TYPE_OFFLINE, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, summaryDetailLevel, 0);
meetingService.save(meeting); meetingService.save(meeting);
AiTask asrTask = new AiTask(); AiTask asrTask = new AiTask();
@ -118,14 +119,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedSummaryModelId(),
chapterModelId, chapterModelId,
runtimeProfile.getResolvedPromptId(), runtimeProfile.getResolvedPromptId(),
command.getUserPrompt() command.getUserPrompt(),
summaryDetailLevel
); );
if (Objects.equals(chapterModelId, runtimeProfile.getResolvedSummaryModelId())) { if (Objects.equals(chapterModelId, runtimeProfile.getResolvedSummaryModelId())) {
meetingDomainSupport.createSummaryTask( meetingDomainSupport.createSummaryTask(
meeting.getId(), meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedSummaryModelId(),
runtimeProfile.getResolvedPromptId(), runtimeProfile.getResolvedPromptId(),
command.getUserPrompt() command.getUserPrompt(),
summaryDetailLevel
); );
} else { } else {
meetingDomainSupport.createSummaryTask( meetingDomainSupport.createSummaryTask(
@ -133,7 +136,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedSummaryModelId(),
chapterModelId, chapterModelId,
runtimeProfile.getResolvedPromptId(), runtimeProfile.getResolvedPromptId(),
command.getUserPrompt() command.getUserPrompt(),
summaryDetailLevel
); );
} }
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl())); meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl()));
@ -152,8 +156,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId); RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId); String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
String summaryDetailLevel = resolveSummaryDetailLevel(command.getSummaryDetailLevel());
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
null, MeetingConstants.TYPE_REALTIME, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, 0); null, MeetingConstants.TYPE_REALTIME, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, summaryDetailLevel, 0);
meetingService.save(meeting); meetingService.save(meeting);
Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId(); Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId();
meetingDomainSupport.createChapterTask( meetingDomainSupport.createChapterTask(
@ -161,14 +166,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedSummaryModelId(),
chapterModelId, chapterModelId,
runtimeProfile.getResolvedPromptId(), runtimeProfile.getResolvedPromptId(),
command.getUserPrompt() command.getUserPrompt(),
summaryDetailLevel
); );
if (Objects.equals(chapterModelId, runtimeProfile.getResolvedSummaryModelId())) { if (Objects.equals(chapterModelId, runtimeProfile.getResolvedSummaryModelId())) {
meetingDomainSupport.createSummaryTask( meetingDomainSupport.createSummaryTask(
meeting.getId(), meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedSummaryModelId(),
runtimeProfile.getResolvedPromptId(), runtimeProfile.getResolvedPromptId(),
command.getUserPrompt() command.getUserPrompt(),
summaryDetailLevel
); );
} else { } else {
meetingDomainSupport.createSummaryTask( meetingDomainSupport.createSummaryTask(
@ -176,7 +183,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedSummaryModelId(),
chapterModelId, chapterModelId,
runtimeProfile.getResolvedPromptId(), runtimeProfile.getResolvedPromptId(),
command.getUserPrompt() command.getUserPrompt(),
summaryDetailLevel
); );
} }
realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId); realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
@ -582,7 +590,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
summaryModelId, summaryModelId,
chapterModelId, chapterModelId,
promptId, promptId,
command.getUserPrompt() command.getUserPrompt(),
meeting.getSummaryDetailLevel()
); );
} }
@ -627,14 +636,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
meeting.getId(), meeting.getId(),
summaryModelId, summaryModelId,
promptId, promptId,
userPrompt userPrompt,
meeting.getSummaryDetailLevel()
) )
: meetingDomainSupport.createSummaryTask( : meetingDomainSupport.createSummaryTask(
meeting.getId(), meeting.getId(),
summaryModelId, summaryModelId,
chapterModelId, chapterModelId,
promptId, promptId,
userPrompt userPrompt,
meeting.getSummaryDetailLevel()
); );
meeting.setLatestSummaryTaskId(createdSummaryTask.getId()); meeting.setLatestSummaryTaskId(createdSummaryTask.getId());
meeting.setStatus(2); meeting.setStatus(2);
@ -803,31 +814,35 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) { public void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt, String summaryDetailLevel) {
Meeting meeting = meetingService.getById(meetingId); Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) { if (meeting == null) {
throw new RuntimeException("会议不存在"); throw new RuntimeException("会议不存在");
} }
String effectiveSummaryDetailLevel = resolveSummaryDetailLevel(summaryDetailLevel != null ? summaryDetailLevel : meeting.getSummaryDetailLevel());
Long effectiveChapterModelId = chapterModelId != null ? chapterModelId : summaryModelId; Long effectiveChapterModelId = chapterModelId != null ? chapterModelId : summaryModelId;
meetingDomainSupport.createChapterTask( meetingDomainSupport.createChapterTask(
meetingId, meetingId,
summaryModelId, summaryModelId,
effectiveChapterModelId, effectiveChapterModelId,
promptId, promptId,
userPrompt userPrompt,
effectiveSummaryDetailLevel
); );
if (Objects.equals(effectiveChapterModelId, summaryModelId)) { if (Objects.equals(effectiveChapterModelId, summaryModelId)) {
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId, userPrompt); meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId, userPrompt, effectiveSummaryDetailLevel);
} else { } else {
meetingDomainSupport.createSummaryTask( meetingDomainSupport.createSummaryTask(
meetingId, meetingId,
summaryModelId, summaryModelId,
effectiveChapterModelId, effectiveChapterModelId,
promptId, promptId,
userPrompt userPrompt,
effectiveSummaryDetailLevel
); );
} }
meeting.setSummaryDetailLevel(effectiveSummaryDetailLevel);
meeting.setStatus(2); meeting.setStatus(2);
meetingService.updateById(meeting); meetingService.updateById(meeting);
if ("EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode)) { if ("EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode)) {
@ -890,7 +905,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
summaryModelId, summaryModelId,
chapterModelId != null ? chapterModelId : summaryModelId, chapterModelId != null ? chapterModelId : summaryModelId,
promptId, promptId,
userPrompt userPrompt,
meeting.getSummaryDetailLevel()
); );
} else { } else {
resetAiTask(chapterTask, new HashMap<>(chapterTask.getTaskConfig())); resetAiTask(chapterTask, new HashMap<>(chapterTask.getTaskConfig()));
@ -1150,4 +1166,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
} }
return null; return null;
} }
private String resolveSummaryDetailLevel(String requestedSummaryDetailLevel) {
if (requestedSummaryDetailLevel == null || requestedSummaryDetailLevel.isBlank()) {
return MeetingConstants.SUMMARY_DETAIL_STANDARD;
}
String normalized = requestedSummaryDetailLevel.trim().toUpperCase();
if (MeetingConstants.SUMMARY_DETAIL_DETAILED.equals(normalized)
|| MeetingConstants.SUMMARY_DETAIL_BRIEF.equals(normalized)) {
return normalized;
}
return MeetingConstants.SUMMARY_DETAIL_STANDARD;
}
} }

View File

@ -1,6 +1,7 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript; import com.imeeting.entity.biz.MeetingTranscript;
@ -52,7 +53,7 @@ public class MeetingDomainSupport {
public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags, public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags,
String audioUrl, String meetingType, String meetingSource, String audioUrl, String meetingType, String meetingSource,
Long tenantId, Long creatorId, String creatorName, Long tenantId, Long creatorId, String creatorName,
Long hostUserId, String hostName, int status) { Long hostUserId, String hostName, String summaryDetailLevel, int status) {
Meeting meeting = new Meeting(); Meeting meeting = new Meeting();
meeting.setTitle(title); meeting.setTitle(title);
meeting.setMeetingTime(meetingTime); meeting.setMeetingTime(meetingTime);
@ -66,31 +67,87 @@ public class MeetingDomainSupport {
meeting.setHostName(hostName); meeting.setHostName(hostName);
meeting.setTenantId(tenantId != null ? tenantId : 0L); meeting.setTenantId(tenantId != null ? tenantId : 0L);
meeting.setAudioUrl(audioUrl); meeting.setAudioUrl(audioUrl);
meeting.setSummaryDetailLevel(normalizeSummaryDetailLevel(summaryDetailLevel));
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_NONE); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_NONE);
meeting.setStatus(status); meeting.setStatus(status);
return meeting; return meeting;
} }
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) { public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) {
return createSummaryTask(meetingId, summaryModelId, summaryModelId, promptId, userPrompt); return createSummaryTask(
meetingId,
summaryModelId,
summaryModelId,
promptId,
userPrompt,
MeetingConstants.SUMMARY_DETAIL_STANDARD
);
}
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long promptId,
String userPrompt, String summaryDetailLevel) {
return createSummaryTask(
meetingId,
summaryModelId,
summaryModelId,
promptId,
userPrompt,
summaryDetailLevel
);
} }
public AiTask createChapterTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) { public AiTask createChapterTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) {
return createChapterTask(
meetingId,
summaryModelId,
chapterModelId,
promptId,
userPrompt,
MeetingConstants.SUMMARY_DETAIL_STANDARD
);
}
public AiTask createChapterTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId,
String userPrompt, String summaryDetailLevel) {
AiTask chapterTask = new AiTask(); AiTask chapterTask = new AiTask();
chapterTask.setMeetingId(meetingId); chapterTask.setMeetingId(meetingId);
chapterTask.setTaskType("CHAPTER"); chapterTask.setTaskType("CHAPTER");
chapterTask.setStatus(0); chapterTask.setStatus(0);
chapterTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt)); chapterTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(
summaryModelId,
chapterModelId,
promptId,
userPrompt,
normalizeSummaryDetailLevel(summaryDetailLevel)
));
aiTaskService.save(chapterTask); aiTaskService.save(chapterTask);
return chapterTask; return chapterTask;
} }
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) { public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) {
return createSummaryTask(
meetingId,
summaryModelId,
chapterModelId,
promptId,
userPrompt,
MeetingConstants.SUMMARY_DETAIL_STANDARD
);
}
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId,
String userPrompt, String summaryDetailLevel) {
AiTask sumTask = new AiTask(); AiTask sumTask = new AiTask();
sumTask.setMeetingId(meetingId); sumTask.setMeetingId(meetingId);
sumTask.setTaskType("SUMMARY"); sumTask.setTaskType("SUMMARY");
sumTask.setStatus(0); sumTask.setStatus(0);
sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt)); sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(
summaryModelId,
chapterModelId,
promptId,
userPrompt,
normalizeSummaryDetailLevel(summaryDetailLevel)
));
aiTaskService.save(sumTask); aiTaskService.save(sumTask);
return sumTask; return sumTask;
} }
@ -306,6 +363,7 @@ public class MeetingDomainSupport {
} }
vo.setMeetingType(meeting.getMeetingType()); vo.setMeetingType(meeting.getMeetingType());
vo.setMeetingSource(meeting.getMeetingSource()); vo.setMeetingSource(meeting.getMeetingSource());
vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel()));
vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
vo.setAccessPassword(meeting.getAccessPassword()); vo.setAccessPassword(meeting.getAccessPassword());
@ -415,6 +473,18 @@ public class MeetingDomainSupport {
return normalized.isEmpty() ? null : normalized; return normalized.isEmpty() ? null : normalized;
} }
private String normalizeSummaryDetailLevel(String summaryDetailLevel) {
if (summaryDetailLevel == null || summaryDetailLevel.isBlank()) {
return MeetingConstants.SUMMARY_DETAIL_STANDARD;
}
String normalized = summaryDetailLevel.trim().toUpperCase();
if (MeetingConstants.SUMMARY_DETAIL_DETAILED.equals(normalized)
|| MeetingConstants.SUMMARY_DETAIL_BRIEF.equals(normalized)) {
return normalized;
}
return MeetingConstants.SUMMARY_DETAIL_STANDARD;
}
private record AudioRelocationPlan(Path sourcePath, Path targetPath, Path backupPath, String relocatedUrl) { private record AudioRelocationPlan(Path sourcePath, Path targetPath, Path backupPath, String relocatedUrl) {
} }
} }

View File

@ -147,8 +147,18 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
String userPrompt = request != null && request.getUserPrompt() != null String userPrompt = request != null && request.getUserPrompt() != null
? request.getUserPrompt() ? request.getUserPrompt()
: stringValue(latestSummaryTask.getTaskConfig().get("userPrompt")); : stringValue(latestSummaryTask.getTaskConfig().get("userPrompt"));
String summaryDetailLevel = stringValue(latestSummaryTask.getTaskConfig().get("summaryDetailLevel"));
if (summaryDetailLevel == null) {
summaryDetailLevel = meeting.getSummaryDetailLevel();
}
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt); Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
summaryModelId,
chapterModelId,
promptId,
userPrompt,
summaryDetailLevel
);
MeetingSummaryPromptContextVO context = new MeetingSummaryPromptContextVO(); MeetingSummaryPromptContextVO context = new MeetingSummaryPromptContextVO();
context.setPromptSchemaVersion(String.valueOf(taskConfig.get("promptSchemaVersion"))); context.setPromptSchemaVersion(String.valueOf(taskConfig.get("promptSchemaVersion")));
context.setSystemMessage(meetingSummaryPromptAssembler.buildSystemMessage(taskConfig)); context.setSystemMessage(meetingSummaryPromptAssembler.buildSystemMessage(taskConfig));

View File

@ -1,5 +1,6 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys; import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.biz.MeetingSummarySource; import com.imeeting.dto.biz.MeetingSummarySource;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
@ -26,15 +27,33 @@ public class MeetingSummaryPromptAssembler {
private final SysParamService sysParamService; private final SysParamService sysParamService;
public Map<String, Object> buildTaskConfig(Long summaryModelId, Long promptId, String userPrompt) { public Map<String, Object> buildTaskConfig(Long summaryModelId, Long promptId, String userPrompt) {
return buildTaskConfig(summaryModelId, summaryModelId, promptId, userPrompt); return buildTaskConfig(
summaryModelId,
summaryModelId,
promptId,
userPrompt,
MeetingConstants.SUMMARY_DETAIL_STANDARD
);
} }
public Map<String, Object> buildTaskConfig(Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) { public Map<String, Object> buildTaskConfig(Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) {
return buildTaskConfig(
summaryModelId,
chapterModelId,
promptId,
userPrompt,
MeetingConstants.SUMMARY_DETAIL_STANDARD
);
}
public Map<String, Object> buildTaskConfig(Long summaryModelId, Long chapterModelId, Long promptId,
String userPrompt, String summaryDetailLevel) {
Map<String, Object> taskConfig = new HashMap<>(); Map<String, Object> taskConfig = new HashMap<>();
taskConfig.put("summaryModelId", summaryModelId); taskConfig.put("summaryModelId", summaryModelId);
taskConfig.put("chapterModelId", chapterModelId != null ? chapterModelId : summaryModelId); taskConfig.put("chapterModelId", chapterModelId != null ? chapterModelId : summaryModelId);
taskConfig.put("promptSchemaVersion", PROMPT_SCHEMA_VERSION); taskConfig.put("promptSchemaVersion", PROMPT_SCHEMA_VERSION);
taskConfig.put("effectiveSystemPrompt", resolveSystemPrompt()); taskConfig.put("effectiveSystemPrompt", resolveSystemPrompt());
taskConfig.put("summaryDetailLevel", normalizeSummaryDetailLevel(summaryDetailLevel));
String templatePrompt = resolveTemplatePrompt(promptId); String templatePrompt = resolveTemplatePrompt(promptId);
taskConfig.put("effectiveTemplatePrompt", templatePrompt); taskConfig.put("effectiveTemplatePrompt", templatePrompt);
@ -59,11 +78,13 @@ public class MeetingSummaryPromptAssembler {
stringValue(taskConfig, "promptContent"), stringValue(taskConfig, "promptContent"),
"请输出结构清晰、信息完整、适合直接阅读和导出的正式会议纪要。" "请输出结构清晰、信息完整、适合直接阅读和导出的正式会议纪要。"
); );
String summaryDetailLevel = normalizeSummaryDetailLevel(stringValue(taskConfig, "summaryDetailLevel"));
return String.join("\n\n", return String.join("\n\n",
"你是一名擅长中文会议纪要、结构化分析和待办提取的助手。", "你是一名擅长中文会议纪要、结构化分析和待办提取的助手。",
"系统提示词(最高优先级):\n" + systemPrompt, "系统提示词(最高优先级):\n" + systemPrompt,
"模板提示词(结构和风格要求):\n" + templatePrompt, "模板提示词(结构和风格要求):\n" + templatePrompt,
"总结详细程度要求:\n" + buildSummaryDetailInstruction(summaryDetailLevel),
""" """
1. JSON Markdown 1. JSON Markdown
@ -193,4 +214,27 @@ public class MeetingSummaryPromptAssembler {
} }
return null; return null;
} }
private String normalizeSummaryDetailLevel(String summaryDetailLevel) {
if (!StringUtils.hasText(summaryDetailLevel)) {
return MeetingConstants.SUMMARY_DETAIL_STANDARD;
}
String normalized = summaryDetailLevel.trim().toUpperCase();
if (MeetingConstants.SUMMARY_DETAIL_DETAILED.equals(normalized)
|| MeetingConstants.SUMMARY_DETAIL_BRIEF.equals(normalized)) {
return normalized;
}
return MeetingConstants.SUMMARY_DETAIL_STANDARD;
}
private String buildSummaryDetailInstruction(String summaryDetailLevel) {
return switch (normalizeSummaryDetailLevel(summaryDetailLevel)) {
case MeetingConstants.SUMMARY_DETAIL_DETAILED ->
"DETAILED详细尽量完整覆盖会议背景、讨论过程、分歧、结论、负责人、时间点和风险适当展开章节内容。";
case MeetingConstants.SUMMARY_DETAIL_BRIEF ->
"BRIEF简洁只保留核心结论、关键决策、待办事项和必要上下文避免冗长铺陈。";
default ->
"STANDARD标准保持信息完整和篇幅平衡覆盖核心过程、结论和待办不做过度展开。";
};
}
} }

View File

@ -1,5 +1,6 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys; import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.biz.MeetingSummarySource; import com.imeeting.dto.biz.MeetingSummarySource;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
@ -22,7 +23,7 @@ import static org.mockito.Mockito.when;
class MeetingSummaryPromptAssemblerTest { class MeetingSummaryPromptAssemblerTest {
@Test @Test
void buildTaskConfigShouldCaptureEffectivePromptsAndUserPrompt() { void buildTaskConfigShouldCaptureEffectivePromptsUserPromptAndDetailLevel() {
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class); PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
SysParamService sysParamService = mock(SysParamService.class); SysParamService sysParamService = mock(SysParamService.class);
PromptTemplate template = new PromptTemplate(); PromptTemplate template = new PromptTemplate();
@ -33,20 +34,27 @@ class MeetingSummaryPromptAssemblerTest {
MeetingSummaryPromptAssembler assembler = new MeetingSummaryPromptAssembler(promptTemplateService, sysParamService); MeetingSummaryPromptAssembler assembler = new MeetingSummaryPromptAssembler(promptTemplateService, sysParamService);
Map<String, Object> taskConfig = assembler.buildTaskConfig(2L, 3L, " 关注风险项 "); Map<String, Object> taskConfig = assembler.buildTaskConfig(
2L,
5L,
3L,
" 关注风险项 ",
MeetingConstants.SUMMARY_DETAIL_DETAILED
);
assertEquals(2L, taskConfig.get("summaryModelId")); assertEquals(2L, taskConfig.get("summaryModelId"));
assertEquals(2L, taskConfig.get("chapterModelId")); assertEquals(5L, taskConfig.get("chapterModelId"));
assertEquals(3L, taskConfig.get("promptId")); assertEquals(3L, taskConfig.get("promptId"));
assertEquals("v2", taskConfig.get("promptSchemaVersion")); assertEquals("v2", taskConfig.get("promptSchemaVersion"));
assertEquals("系统提示词", taskConfig.get("effectiveSystemPrompt")); assertEquals("系统提示词", taskConfig.get("effectiveSystemPrompt"));
assertEquals("模板提示词", taskConfig.get("effectiveTemplatePrompt")); assertEquals("模板提示词", taskConfig.get("effectiveTemplatePrompt"));
assertEquals("关注风险项", taskConfig.get("userPrompt")); assertEquals("关注风险项", taskConfig.get("userPrompt"));
assertEquals("模板提示词", taskConfig.get("promptContent")); assertEquals("模板提示词", taskConfig.get("promptContent"));
assertEquals(MeetingConstants.SUMMARY_DETAIL_DETAILED, taskConfig.get("summaryDetailLevel"));
} }
@Test @Test
void buildSystemMessageShouldFallbackToLegacyPromptContent() { void buildSystemMessageShouldContainSummaryDetailInstruction() {
SysParamService sysParamService = mock(SysParamService.class); SysParamService sysParamService = mock(SysParamService.class);
when(sysParamService.getCachedParamValue(eq(SysParamKeys.MEETING_SUMMARY_SYSTEM_PROMPT), eq(""))) when(sysParamService.getCachedParamValue(eq(SysParamKeys.MEETING_SUMMARY_SYSTEM_PROMPT), eq("")))
.thenReturn("系统提示词"); .thenReturn("系统提示词");
@ -55,9 +63,14 @@ class MeetingSummaryPromptAssemblerTest {
sysParamService sysParamService
); );
String systemMessage = assembler.buildSystemMessage(Map.of("promptContent", "旧模板提示词")); String systemMessage = assembler.buildSystemMessage(Map.of(
"promptContent", "旧模板提示词",
"summaryDetailLevel", MeetingConstants.SUMMARY_DETAIL_BRIEF
));
assertTrue(systemMessage.contains("旧模板提示词")); assertTrue(systemMessage.contains("旧模板提示词"));
assertTrue(systemMessage.contains("BRIEF"));
assertTrue(systemMessage.contains("简洁"));
} }
@Test @Test
@ -89,8 +102,8 @@ class MeetingSummaryPromptAssemblerTest {
meeting.setParticipants("Alice,Bob"); meeting.setParticipants("Alice,Bob");
MeetingSummarySource source = MeetingSummarySource.builder() MeetingSummarySource source = MeetingSummarySource.builder()
.chapterOutlineText("第1章 预算评审\n章节摘要讨论320万预算") .chapterOutlineText("第1章 预算评审\n章节摘要讨论 20 万预算")
.rawTranscriptText("Alice: 今天讨论320万预算时间定在2026年5月7日15:30。") .rawTranscriptText("Alice: 今天讨论 320 万预算,时间定在 2026 年 5 月 8 日 15:30。")
.build(); .build();
String userMessage = assembler.buildUserMessage(meeting, source, "关注预算结论"); String userMessage = assembler.buildUserMessage(meeting, source, "关注预算结论");

View File

@ -4,6 +4,14 @@ import axios from "axios";
const MEETING_UPLOAD_FLOW_TIMEOUT = 600000; const MEETING_UPLOAD_FLOW_TIMEOUT = 600000;
const MEETING_DETAIL_TIMEOUT = 120000; const MEETING_DETAIL_TIMEOUT = 120000;
export type SummaryDetailLevel = "DETAILED" | "STANDARD" | "BRIEF";
export interface MeetingCreateConfig {
offlineEnabled: boolean;
realtimeEnabled: boolean;
offlineAudioMaxSizeMb: number;
}
export interface MeetingVO { export interface MeetingVO {
id: number; id: number;
tenantId: number; tenantId: number;
@ -20,6 +28,7 @@ export interface MeetingVO {
playbackAudioUrl?: string; playbackAudioUrl?: string;
meetingType?: "OFFLINE" | "REALTIME"; meetingType?: "OFFLINE" | "REALTIME";
meetingSource?: "WEB" | "ANDROID"; meetingSource?: "WEB" | "ANDROID";
summaryDetailLevel?: SummaryDetailLevel;
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED"; audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
audioSaveMessage?: string; audioSaveMessage?: string;
accessPassword?: string; accessPassword?: string;
@ -80,6 +89,7 @@ export interface CreateMeetingCommand {
promptId: number; promptId: number;
hotWordGroupId?: number; hotWordGroupId?: number;
userPrompt?: string; userPrompt?: string;
summaryDetailLevel?: SummaryDetailLevel;
useSpkId?: number; useSpkId?: number;
enableTextRefine?: boolean; enableTextRefine?: boolean;
hotWords?: string[]; hotWords?: string[];
@ -99,6 +109,7 @@ export interface CreateRealtimeMeetingCommand {
promptId: number; promptId: number;
hotWordGroupId?: number; hotWordGroupId?: number;
userPrompt?: string; userPrompt?: string;
summaryDetailLevel?: SummaryDetailLevel;
mode?: string; mode?: string;
language?: string; language?: string;
useSpkId?: number; useSpkId?: number;
@ -285,6 +296,12 @@ export const getMeetingDetail = (id: number, options?: { suppressErrorToast?: bo
); );
}; };
export const getMeetingCreateConfig = () => {
return http.get<{ code: string; data: MeetingCreateConfig; msg: string }>(
"/api/biz/meeting/create-config"
);
};
export const getTranscripts = (id: number) => { export const getTranscripts = (id: number) => {
return http.get<{ code: string; data: MeetingTranscriptVO[]; msg: string }>( return http.get<{ code: string; data: MeetingTranscriptVO[]; msg: string }>(
`/api/biz/meeting/${id}/transcripts` `/api/biz/meeting/${id}/transcripts`
@ -345,6 +362,7 @@ export interface MeetingResummaryDTO {
summaryModelId: number; summaryModelId: number;
promptId: number; promptId: number;
userPrompt?: string; userPrompt?: string;
summaryDetailLevel?: SummaryDetailLevel;
} }
export const reSummary = (params: MeetingResummaryDTO) => { export const reSummary = (params: MeetingResummaryDTO) => {

View File

@ -4,6 +4,11 @@ import {
PermissionNode PermissionNode
} from "../types"; } from "../types";
export interface RoleAuthorizationPayload {
permissionIds?: number[];
dataScope?: RoleDataScope;
}
export async function pageParams(params: SysParamQuery) { export async function pageParams(params: SysParamQuery) {
const resp = await http.get("/sys/api/params/page", { params }); const resp = await http.get("/sys/api/params/page", { params });
return resp.data.data as PageResult<SysParamVO[]>; return resp.data.data as PageResult<SysParamVO[]>;
@ -189,6 +194,11 @@ export async function saveRolePermissions(roleId: number, permIds: number[]) {
return resp.data.data as boolean; return resp.data.data as boolean;
} }
export async function saveRoleAuthorization(roleId: number, payload: RoleAuthorizationPayload) {
const resp = await http.post(`/sys/api/roles/${roleId}/authorization`, payload);
return resp.data.data as boolean;
}
export async function getRoleDataScope(roleId: number) { export async function getRoleDataScope(roleId: number) {
const resp = await http.get(`/sys/api/roles/${roleId}/data-scope`); const resp = await http.get(`/sys/api/roles/${roleId}/data-scope`);
return resp.data.data as RoleDataScope; return resp.data.data as RoleDataScope;

View File

@ -1,25 +1,65 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useEffect, useMemo, useState } from "react";
import { Drawer, Form, Input, Select, DatePicker, Switch, Upload, Progress, Space, Avatar, Row, Col, Radio, Typography, Tooltip, App, Tag, Button, Collapse } from 'antd'; import {
import { UserOutlined, CloudUploadOutlined, AudioOutlined, QuestionCircleOutlined, CheckOutlined, LinkOutlined, SettingOutlined } from '@ant-design/icons'; App,
import dayjs from 'dayjs'; Avatar,
import { useNavigate } from 'react-router-dom'; Button,
Col,
Collapse,
DatePicker,
Drawer,
Form,
Input,
Progress,
Radio,
Row,
Select,
Skeleton,
Space,
Switch,
Tag,
Tooltip,
Typography,
Upload,
} from "antd";
import {
AudioOutlined,
CheckOutlined,
CloudUploadOutlined,
QuestionCircleOutlined,
SettingOutlined,
UserOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { useNavigate } from "react-router-dom";
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel'; import { listUsers } from "../../api";
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel";
import { getHotWordPage, HotWordVO } from '../../api/business/hotword'; import { getHotWordPage, type HotWordVO } from "../../api/business/hotword";
import { getHotWordGroupOptions, HotWordGroupVO } from '../../api/business/hotwordGroup'; import { getHotWordGroupOptions, type HotWordGroupVO } from "../../api/business/hotwordGroup";
import { listUsers, pageParams } from '../../api'; import {
import { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting'; createMeeting,
import { SysUser } from '../../types'; createRealtimeMeeting,
getMeetingCreateConfig,
type CreateRealtimeMeetingCommand,
type MeetingCreateConfig,
type SummaryDetailLevel,
uploadAudio,
} from "../../api/business/meeting";
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
import type { SysUser } from "../../types";
const { Option } = Select;
const { Dragger } = Upload; const { Dragger } = Upload;
const { Option } = Select;
const { Text, Title } = Typography; const { Text, Title } = Typography;
export type MeetingCreateType = 'upload' | 'realtime'; export type MeetingCreateType = "upload" | "realtime";
const DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB = 1024; const DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB = 1024;
const OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY = 'meeting.offline_audio.max_size_mb'; const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
offlineEnabled: true,
realtimeEnabled: true,
offlineAudioMaxSizeMb: DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB,
};
interface MeetingCreateDrawerProps { interface MeetingCreateDrawerProps {
open: boolean; open: boolean;
@ -44,29 +84,54 @@ type RealtimeMeetingSessionDraft = {
hotwords: Array<{ hotword: string; weight: number }>; hotwords: Array<{ hotword: string; weight: number }>;
}; };
function resolveAvailableCreateTypes(config: MeetingCreateConfig): MeetingCreateType[] {
const types: MeetingCreateType[] = [];
if (config.offlineEnabled) {
types.push("upload");
}
if (config.realtimeEnabled) {
types.push("realtime");
}
return types;
}
function resolveAvailableCreateType(initialType: MeetingCreateType, config: MeetingCreateConfig): MeetingCreateType {
const availableTypes = resolveAvailableCreateTypes(config);
if (availableTypes.includes(initialType)) {
return initialType;
}
return availableTypes[0] || initialType;
}
function resolveWsUrl(model?: AiModelVO | null) { function resolveWsUrl(model?: AiModelVO | null) {
if (model?.wsUrl) return model.wsUrl; if (model?.wsUrl) return model.wsUrl;
if (model?.baseUrl) return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://"); if (model?.baseUrl) return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
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}`;
} }
export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open, initialType = 'upload', onCancel, onSuccess }) => { export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
open,
initialType = "upload",
onCancel,
onSuccess,
}) => {
const { message } = App.useApp(); const { message } = App.useApp();
const navigate = useNavigate(); const navigate = useNavigate();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [type, setType] = useState<MeetingCreateType>(initialType); const [type, setType] = useState<MeetingCreateType>(initialType);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [configLoaded, setConfigLoaded] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>({
offlineEnabled: false,
realtimeEnabled: false,
offlineAudioMaxSizeMb: DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB,
});
const [asrModels, setAsrModels] = useState<AiModelVO[]>([]); const [asrModels, setAsrModels] = useState<AiModelVO[]>([]);
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]); const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
@ -74,76 +139,83 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]); const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
const [hotWordGroups, setHotWordGroups] = useState<HotWordGroupVO[]>([]); const [hotWordGroups, setHotWordGroups] = useState<HotWordGroupVO[]>([]);
const [userList, setUserList] = useState<SysUser[]>([]); const [userList, setUserList] = useState<SysUser[]>([]);
const [hotWordGroupTouched, setHotWordGroupTouched] = useState(false);
const [audioUrl, setAudioUrl] = useState(''); const [audioUrl, setAudioUrl] = useState("");
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [fileList, setFileList] = useState<any[]>([]); const [fileList, setFileList] = useState<any[]>([]);
const [offlineAudioMaxSizeMb, setOfflineAudioMaxSizeMb] = useState(DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB);
const watchedAsrModelId = Form.useWatch("asrModelId", form); const watchedAsrModelId = Form.useWatch("asrModelId", form);
const watchedSummaryModelId = Form.useWatch("summaryModelId", form);
const watchedPromptId = Form.useWatch("promptId", form); const watchedPromptId = Form.useWatch("promptId", form);
const watchedHotWordGroupId = Form.useWatch("hotWordGroupId", form); const watchedHotWordGroupId = Form.useWatch("hotWordGroupId", form);
const watchedSummaryModelId = Form.useWatch("summaryModelId", form);
const selectedAsrModel = useMemo(() => asrModels.find((item) => item.id === watchedAsrModelId) || null, [asrModels, watchedAsrModelId]); const selectedAsrModel = useMemo(
const selectedSummaryModel = useMemo(() => llmModels.find((item) => item.id === watchedSummaryModelId) || null, [llmModels, watchedSummaryModelId]); () => asrModels.find((item) => item.id === watchedAsrModelId) || null,
const selectedPrompt = useMemo(() => prompts.find((item) => item.id === watchedPromptId) || null, [prompts, watchedPromptId]); [asrModels, watchedAsrModelId]
const offlineAudioMaxSizeBytes = useMemo(() => offlineAudioMaxSizeMb * 1024 * 1024, [offlineAudioMaxSizeMb]); );
const selectedSummaryModel = useMemo(
() => llmModels.find((item) => item.id === watchedSummaryModelId) || null,
[llmModels, watchedSummaryModelId]
);
const selectedPrompt = useMemo(
() => prompts.find((item) => item.id === watchedPromptId) || null,
[prompts, watchedPromptId]
);
const availableTypes = useMemo(() => resolveAvailableCreateTypes(createConfig), [createConfig]);
const offlineAudioMaxSizeBytes = useMemo(
() => (createConfig.offlineAudioMaxSizeMb || DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB) * 1024 * 1024,
[createConfig]
);
useEffect(() => { useEffect(() => {
if (open) { if (!open) {
setType(initialType);
setHotWordGroupTouched(false);
loadInitialData();
setAudioUrl('');
setUploadProgress(0);
setFileList([]);
}
}, [open, initialType]);
useEffect(() => {
if (!open || hotWordGroupTouched) {
return; return;
} }
form.setFieldValue('hotWordGroupId', selectedPrompt?.hotWordGroupId); setAudioUrl("");
}, [form, hotWordGroupTouched, open, selectedPrompt]); setUploadProgress(0);
setFileList([]);
void loadInitialData();
}, [open, initialType]);
const loadInitialData = async () => { const loadInitialData = async () => {
setLoading(true); setLoading(true);
try { try {
const [asrRes, llmRes, promptRes, hotwordRes, hotWordGroupRes, users, defaultAsr, defaultLlm] = await Promise.all([ const [asrRes, llmRes, promptRes, hotwordRes, hotWordGroupRes, users, defaultAsr, defaultLlm, createConfigRes] = await Promise.all([
getAiModelPage({ current: 1, size: 100, type: 'ASR' }), getAiModelPage({ current: 1, size: 100, type: "ASR" }),
getAiModelPage({ current: 1, size: 100, type: 'LLM' }), getAiModelPage({ current: 1, size: 100, type: "LLM" }),
getPromptPage({ current: 1, size: 100 }), getPromptPage({ current: 1, size: 100 }),
getHotWordPage({ current: 1, size: 1000 }), getHotWordPage({ current: 1, size: 1000 }),
getHotWordGroupOptions(), getHotWordGroupOptions(),
listUsers(), listUsers(),
getAiModelDefault("ASR"), getAiModelDefault("ASR"),
getAiModelDefault("LLM"), getAiModelDefault("LLM"),
getMeetingCreateConfig(),
]); ]);
const activeAsrModels = asrRes.data.data.records.filter((m: AiModelVO) => m.status === 1); const nextConfig = createConfigRes.data.data || DEFAULT_CREATE_CONFIG;
const activeLlmModels = llmRes.data.data.records.filter((m: AiModelVO) => m.status === 1); const nextType = resolveAvailableCreateType(initialType, nextConfig);
const activePrompts = promptRes.data.data.records.filter((p: PromptTemplateVO) => p.status === 1); const activePrompts = promptRes.data.data.records.filter((item: PromptTemplateVO) => item.status === 1);
const activeHotwords = hotwordRes.data.data.records.filter((h: HotWordVO) => h.status === 1);
setAsrModels(activeAsrModels); setCreateConfig(nextConfig);
setLlmModels(activeLlmModels); setConfigLoaded(true);
setType(nextType);
setAsrModels(asrRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
setLlmModels(llmRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
setPrompts(activePrompts); setPrompts(activePrompts);
setHotwordList(activeHotwords); setHotwordList(hotwordRes.data.data.records.filter((item: HotWordVO) => item.status === 1));
setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1)); setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1));
setUserList(users || []); setUserList(users || []);
setOfflineAudioMaxSizeMb(await loadOfflineAudioMaxSizeMb());
const defaultPrompt = activePrompts.length > 0 ? activePrompts[0] : undefined;
const defaultPrompt = activePrompts[0];
form.setFieldsValue({ form.setFieldsValue({
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`, title: nextType === "upload" ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
meetingTime: dayjs(), meetingTime: dayjs(),
asrModelId: defaultAsr.data.data?.id, asrModelId: defaultAsr.data.data?.id,
summaryModelId: defaultLlm.data.data?.id, summaryModelId: defaultLlm.data.data?.id,
promptId: defaultPrompt?.id, promptId: defaultPrompt?.id,
hotWordGroupId: defaultPrompt?.hotWordGroupId, hotWordGroupId: defaultPrompt?.hotWordGroupId ?? 0,
summaryDetailLevel: "STANDARD",
useSpkId: 1, useSpkId: 1,
enableTextRefine: false, enableTextRefine: false,
mode: "2pass", mode: "2pass",
@ -152,24 +224,36 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
enableItn: true, enableItn: true,
saveAudio: false, saveAudio: false,
}); });
} catch (err) { } catch {
message.error("加载配置失败"); message.error("加载配置失败");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// Sync title when type changes
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) {
const currentTitle = form.getFieldValue('title'); return;
if (currentTitle && (currentTitle.startsWith('文件会议') || currentTitle.startsWith('实时会议'))) { }
const currentTitle = form.getFieldValue("title");
if (typeof currentTitle !== "string") {
return;
}
if (currentTitle.startsWith("文件会议") || currentTitle.startsWith("实时会议")) {
form.setFieldsValue({ form.setFieldsValue({
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`, title: type === "upload" ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
useSpkId: 1
}); });
} }
}, [type, form, open]); }, [form, open, type]);
const beforeAudioUpload = (file: File) => {
if (file.size > offlineAudioMaxSizeBytes) {
message.error(`录音文件大小不能超过 ${createConfig.offlineAudioMaxSizeMb}MB`);
setUploadProgress(0);
return Upload.LIST_IGNORE;
}
return true;
};
const customUpload = async (options: any) => { const customUpload = async (options: any) => {
const { file, onSuccess: uploadSuccess, onError, onProgress } = options; const { file, onSuccess: uploadSuccess, onError, onProgress } = options;
@ -177,62 +261,45 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
try { try {
const res = await uploadAudio(file, (progressEvent) => { const res = await uploadAudio(file, (progressEvent) => {
if (progressEvent.total) { if (progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); const percent = Math.min(99, Math.round((progressEvent.loaded * 100) / progressEvent.total));
// Only show up to 99% during upload, save 100% for actual completion setUploadProgress(percent);
const displayPercent = percentCompleted > 99 ? 99 : percentCompleted; onProgress({ percent });
setUploadProgress(displayPercent);
onProgress({ percent: displayPercent });
} }
}); });
setUploadProgress(100); setUploadProgress(100);
onProgress({ percent: 100 }); onProgress({ percent: 100 });
setAudioUrl(res.data.data); setAudioUrl(res.data.data);
uploadSuccess(res.data.data); uploadSuccess(res.data.data);
message.success('录音上传成功'); message.success("录音上传成功");
} catch (err) { } catch (err) {
onError(err); onError(err);
if (!(err instanceof Error) || !err.message) { message.error("文件上传失败");
message.error('文件上传失败');
}
}
};
const beforeAudioUpload = (file: File) => {
if (file.size > offlineAudioMaxSizeBytes) {
message.error(`录音文件大小不能超过 ${offlineAudioMaxSizeMb}MB`);
setUploadProgress(0);
return Upload.LIST_IGNORE;
}
return true;
};
const loadOfflineAudioMaxSizeMb = async () => {
try {
const result = await pageParams({
paramKey: OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY,
pageNum: 1,
pageSize: 10,
});
const matched = (result.records || []).find((item) => item.paramKey === OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY);
const parsed = Number(matched?.paramValue);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB;
} catch {
return DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB;
} }
}; };
const handleOk = async () => { const handleOk = async () => {
if (type === 'upload' && !audioUrl) { if (availableTypes.length === 0) {
message.error('请先上传录音文件'); message.error("当前系统参数已关闭全部会议创建入口");
return;
}
if (!availableTypes.includes(type)) {
const fallbackType = availableTypes[0];
if (fallbackType) {
setType(fallbackType);
}
message.warning("当前入口已关闭,已切换到可用创建方式");
return;
}
if (type === "upload" && !audioUrl) {
message.error("请先上传录音文件");
return; return;
} }
const values = await form.validateFields(); const values = await form.validateFields();
if (type === "realtime") {
if (type === 'realtime') {
const wsUrl = resolveWsUrl(selectedAsrModel); const wsUrl = resolveWsUrl(selectedAsrModel);
if (!wsUrl) { if (!wsUrl) {
message.error("当前 ASR 模型没有配置 WebSocket 地址"); message.error("当前 ASR 模型未配置 WebSocket 地址");
return; return;
} }
} }
@ -247,20 +314,23 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
.map((item) => item.word) .map((item) => item.word)
.filter((word) => !!word?.trim()); .filter((word) => !!word?.trim());
if (type === 'upload') { if (type === "upload") {
await createMeeting({ await createMeeting({
...meetingValues, ...meetingValues,
...(hostUserId != null ? { hostUserId } : {}), ...(hostUserId != null ? { hostUserId } : {}),
meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'), meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
audioUrl, audioUrl,
participants: meetingValues.participants?.join(','), participants: meetingValues.participants?.join(","),
tags: meetingValues.tags?.join(','), tags: meetingValues.tags?.join(","),
hotWords: selectedHotWords summaryDetailLevel: meetingValues.summaryDetailLevel as SummaryDetailLevel,
hotWords: selectedHotWords,
}); });
message.success('会议发起成功'); message.success("会议发起成功");
onSuccess(); onSuccess();
onCancel(); onCancel();
} else { return;
}
const selectedHotwords = hotwordList const selectedHotwords = hotwordList
.filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId && meetingValues.hotWordGroupId !== 0) .filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId && meetingValues.hotWordGroupId !== 0)
.map((item) => ({ .map((item) => ({
@ -274,6 +344,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"), meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
participants: meetingValues.participants?.join(",") || "", participants: meetingValues.participants?.join(",") || "",
tags: meetingValues.tags?.join(",") || "", tags: meetingValues.tags?.join(",") || "",
summaryDetailLevel: meetingValues.summaryDetailLevel as SummaryDetailLevel,
mode: meetingValues.mode || "2pass", mode: meetingValues.mode || "2pass",
language: meetingValues.language || "auto", language: meetingValues.language || "auto",
useSpkId: meetingValues.useSpkId == null ? 1 : (meetingValues.useSpkId ? 1 : 0), useSpkId: meetingValues.useSpkId == null ? 1 : (meetingValues.useSpkId ? 1 : 0),
@ -286,7 +357,6 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
const res = await createRealtimeMeeting(payload); const res = await createRealtimeMeeting(payload);
const createdMeeting = res.data.data; const createdMeeting = res.data.data;
const sessionDraft: RealtimeMeetingSessionDraft = { const sessionDraft: RealtimeMeetingSessionDraft = {
meetingId: createdMeeting.id, meetingId: createdMeeting.id,
meetingTitle: createdMeeting.title, meetingTitle: createdMeeting.title,
@ -308,9 +378,8 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
onSuccess(); onSuccess();
onCancel(); onCancel();
navigate(`/meeting-live-session/${createdMeeting.id}`); navigate(`/meeting-live-session/${createdMeeting.id}`);
} } catch {
} catch (err) { message.error(type === "upload" ? "创建会议失败" : "创建实时会议失败");
message.error(type === 'upload' ? '创建会议失败' : '创建实时会议失败');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -327,47 +396,59 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
placement="right" placement="right"
closable={false} closable={false}
footer={ footer={
<div style={{ textAlign: 'right', padding: '16px 32px' }}> configLoaded ? (
<div style={{ textAlign: "right", padding: "16px 32px" }}>
<Space size={16}> <Space size={16}>
<Button onClick={onCancel} size="large" style={{ borderRadius: 8, minWidth: 120 }}></Button> <Button onClick={onCancel} size="large" style={{ borderRadius: 8, minWidth: 120 }}></Button>
<Button type="primary" onClick={handleOk} loading={submitting} size="large" style={{ borderRadius: 8, minWidth: 140, fontWeight: 500 }}> <Button type="primary" onClick={handleOk} loading={submitting} size="large" style={{ borderRadius: 8, minWidth: 140, fontWeight: 500 }}>
{type === 'upload' ? (audioUrl ? '开始分析' : '创建并上传') : '创建并进入识别'} {type === "upload" ? (audioUrl ? "完成并分析" : "上传并分析") : "创建并进入识别"}
</Button> </Button>
</Space> </Space>
</div> </div>
) : null
} }
styles={{ styles={{
header: { display: 'none' }, header: { display: "none" },
body: { padding: 0, display: 'flex', flexDirection: 'column', background: 'var(--app-bg-layout)' }, body: { padding: 0, display: "flex", flexDirection: "column", background: "var(--app-bg-layout)" },
footer: { padding: 0, borderTop: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface)' } footer: { padding: 0, borderTop: "1px solid var(--app-border-color)", background: "var(--app-bg-surface)", minHeight: configLoaded ? 72 : 0 }
}} }}
> >
<div style={{ background: 'var(--app-bg-surface)', padding: '24px 32px', borderBottom: '1px solid var(--app-border-color)' }}> {!configLoaded ? (
<div style={{ padding: "40px" }}>
<Skeleton active paragraph={{ rows: 12 }} />
</div>
) : (
<>
<div style={{ background: "var(--app-bg-surface)", padding: "24px 32px", borderBottom: "1px solid var(--app-border-color)" }}>
<Row justify="space-between" align="middle"> <Row justify="space-between" align="middle">
<Col> <Col>
<Space size={16}> <Space size={16}>
<div style={{ width: 48, height: 48, borderRadius: 12, background: 'var(--app-bg-surface-strong)', color: 'var(--app-text-main)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, border: '1px solid var(--app-border-color)' }}> <div style={{ width: 48, height: 48, borderRadius: 12, background: "var(--app-bg-surface-strong)", color: "var(--app-text-main)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24, border: "1px solid var(--app-border-color)" }}>
{type === 'upload' ? <CloudUploadOutlined /> : <AudioOutlined />} {type === "upload" ? <CloudUploadOutlined /> : <AudioOutlined />}
</div> </div>
<div> <div>
<Title level={4} style={{ margin: 0, fontWeight: 600 }}>{type === 'upload' ? '上传录音分析' : '创建实时会议'}</Title> <Title level={4} style={{ margin: 0, fontWeight: 600 }}>{type === "upload" ? "上传录音发起分析" : "创建实时会议"}</Title>
<Text type="secondary" style={{ fontSize: 13 }}>{type === 'upload' ? '上传已有录音文件进行转写和总结' : '创建会议实时进行语音转写和内容分析'}</Text> <Text type="secondary" style={{ fontSize: 13 }}>{type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"}</Text>
</div> </div>
</Space> </Space>
</Col> </Col>
<Col> <Col>
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" size="large"> <Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" size="large">
<Radio.Button value="upload" style={{ padding: '0 24px' }}><CloudUploadOutlined style={{ marginRight: 6 }} /> </Radio.Button> {createConfig.offlineEnabled && (
<Radio.Button value="realtime" style={{ padding: '0 24px' }}><AudioOutlined style={{ marginRight: 6 }} /> </Radio.Button> <Radio.Button value="upload" style={{ padding: "0 24px" }}><CloudUploadOutlined style={{ marginRight: 6 }} /> </Radio.Button>
)}
{createConfig.realtimeEnabled && (
<Radio.Button value="realtime" style={{ padding: "0 24px" }}><AudioOutlined style={{ marginRight: 6 }} /> </Radio.Button>
)}
</Radio.Group> </Radio.Group>
</Col> </Col>
</Row> </Row>
</div> </div>
<div style={{ padding: '32px 40px', flex: 1, overflowY: 'auto', background: 'var(--app-bg-layout)' }}> <div style={{ padding: "32px 40px", flex: 1, overflowY: "auto", background: "var(--app-bg-layout)" }}>
<Form form={form} layout="vertical" disabled={loading}> <Form form={form} layout="vertical" disabled={loading}>
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}> <div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} /> <div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
<Title level={5} style={{ margin: 0 }}></Title> <Title level={5} style={{ margin: 0 }}></Title>
</div> </div>
@ -379,7 +460,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]}> <Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]}>
<DatePicker showTime style={{ width: '100%' }} size="large" /> <DatePicker showTime style={{ width: "100%" }} size="large" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
@ -387,14 +468,14 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
<Row gutter={32}> <Row gutter={32}>
<Col span={12}> <Col span={12}>
<Form.Item name="participants" label="参会人员"> <Form.Item name="participants" label="参会人员">
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children" size="large"> <Select mode="multiple" placeholder="选择参会人员" showSearch optionFilterProp="children" size="large">
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))} {userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="hostUserId" label="会议主持人"> <Form.Item name="hostUserId" label="会议主持人">
<Select allowClear placeholder="不选择则默认为创建人" showSearch optionFilterProp="children" size="large"> <Select allowClear placeholder="默认为创建人" showSearch optionFilterProp="children" size="large">
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))} {userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
</Select> </Select>
</Form.Item> </Form.Item>
@ -409,21 +490,21 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
</Col> </Col>
</Row> </Row>
<div style={{ margin: '32px 0', borderTop: '1px solid var(--app-border-color)' }} /> <div style={{ margin: "32px 0", borderTop: "1px solid var(--app-border-color)" }} />
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}> <div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} /> <div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
<Title level={5} style={{ margin: 0 }}>AI </Title> <Title level={5} style={{ margin: 0 }}></Title>
</div> </div>
<Row gutter={32}> <Row gutter={32}>
<Col span={12}> <Col span={12}>
<Form.Item name="asrModelId" label="语音识别 (ASR)" rules={[{ required: true }]}> <Form.Item name="asrModelId" label="语音识别模型 (ASR)" rules={[{ required: true }]}>
<Select placeholder="选择 ASR 模型" size="large">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select> <Select placeholder="选择 ASR 模型" size="large">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="summaryModelId" label="内容总结 (LLM)" rules={[{ required: true }]}> <Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
<Select placeholder="选择总结模型" size="large">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select> <Select placeholder="选择总结模型" size="large">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
</Form.Item> </Form.Item>
</Col> </Col>
@ -431,19 +512,19 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]}> <Form.Item name="promptId" label="总结模板" rules={[{ required: true }]}>
{prompts.length > 15 ? ( {prompts.length > 15 ? (
<Select placeholder="请选择模板" showSearch optionFilterProp="children" size="large"> <Select placeholder="请选择总结模板" showSearch optionFilterProp="children" size="large">
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)} {prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
</Select> </Select>
) : ( ) : (
<div style={{ padding: '2px' }}> <div style={{ padding: "2px" }}>
<Row gutter={[12, 12]}> <Row gutter={[12, 12]}>
{prompts.map(p => { {prompts.map(p => {
const isSelected = watchedPromptId === p.id; const isSelected = watchedPromptId === p.id;
return ( return (
<Col span={8} key={p.id}> <Col span={8} key={p.id}>
<div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: '12px 16px', borderRadius: 8, border: `1px solid ${isSelected ? '#1890ff' : 'var(--app-border-color)'}`, background: isSelected ? '#e6f7ff' : 'var(--app-bg-surface)', cursor: 'pointer', position: 'relative', transition: 'all 0.2s', display: 'flex', alignItems: 'center', height: '100%' }}> <div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: "12px 16px", borderRadius: 8, border: `1px solid ${isSelected ? "#1890ff" : "var(--app-border-color)"}`, background: isSelected ? "#e6f7ff" : "var(--app-bg-surface)", cursor: "pointer", position: "relative", transition: "all 0.2s", display: "flex", alignItems: "center", height: "100%" }}>
<div style={{ fontSize: '14px', color: isSelected ? '#1890ff' : 'var(--app-text-main)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSelected ? 500 : 400 }}>{p.templateName}</div> <div style={{ fontSize: "14px", color: isSelected ? "#1890ff" : "var(--app-text-main)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isSelected ? 500 : 400 }}>{p.templateName}</div>
{isSelected && <div style={{ position: 'absolute', top: -1, right: -1, width: 20, height: 20, background: '#1890ff', borderRadius: '0 8px 0 8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CheckOutlined style={{ color: '#fff', fontSize: 12 }} /></div>} {isSelected && <div style={{ position: "absolute", top: -1, right: -1, width: 20, height: 20, background: "#1890ff", borderRadius: "0 8px 0 8px", display: "flex", alignItems: "center", justifyContent: "center" }}><CheckOutlined style={{ color: "#fff", fontSize: 12 }} /></div>}
</div> </div>
</Col> </Col>
); );
@ -453,83 +534,35 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
)} )}
</Form.Item> </Form.Item>
{/* <Row gutter={32}>
<Form.Item <Col span={12}>
name="hotWordGroupId" <Form.Item name="hotWordGroupId" label="热词组" tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : "模板未绑定热词组时可手动选择"} extra={watchedHotWordGroupId != null ? "创建会议时会优先使用这里选中的热词组" : undefined}>
label="热词组" <Select placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"} size="large" options={[{ label: "不使用热词组", value: 0 }, ...hotWordGroups.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} />
tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : '模板未绑定热词组时可手动选择'}
extra={watchedHotWordGroupId != null ? '创建会议时会优先使用这里选中的热词组' : undefined}
>
<Select
placeholder={selectedPrompt?.hotWordGroupId ? '默认已带出模板热词组,可按需修改' : '请选择热词组'}
size="large"
options={[
{ label: '不使用热词组', value: 0 },
...hotWordGroups.map((item) => ({
label: `${item.groupName} (${item.hotWordCount}/200)`,
value: item.id,
})),
]}
onChange={() => setHotWordGroupTouched(true)}
/>
</Form.Item> </Form.Item>
*/} </Col>
<Col span={12}>
{/* <Form.Item name="summaryDetailLevel" label="总结详细程度" rules={[{ required: true }]}>
<Radio.Group size="large">
<Form.Item <Radio value="DETAILED"></Radio>
name="hotWordGroupId" <Radio value="STANDARD"></Radio>
label="热词组" <Radio value="BRIEF"></Radio>
tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : '模板未绑定热词组时可手动选择'} </Radio.Group>
extra={watchedHotWordGroupId != null ? '创建会议时会优先使用这里选中的热词组' : undefined}
>
<Select
placeholder={selectedPrompt?.hotWordGroupId ? '默认已带出模板热词组,可按需修改' : '请选择热词组'}
size="large"
options={[
{ label: '不使用热词组', value: 0 },
...hotWordGroups.map((item) => ({
label: `${item.groupName} (${item.hotWordCount}/200)`,
value: item.id,
})),
]}
onChange={() => setHotWordGroupTouched(true)}
/>
</Form.Item>
*/}
<Form.Item
name="hotWordGroupId"
label={"\u70ed\u8bcd\u7ec4"}
tooltip={selectedPrompt?.hotWordGroupName ? `\u9ed8\u8ba4\u8ddf\u968f\u6a21\u677f\uff1a${selectedPrompt.hotWordGroupName}` : '\u6a21\u677f\u672a\u7ed1\u5b9a\u70ed\u8bcd\u7ec4\u65f6\u53ef\u624b\u52a8\u9009\u62e9'}
extra={watchedHotWordGroupId != null ? '\u521b\u5efa\u4f1a\u8bae\u65f6\u4f1a\u4f18\u5148\u4f7f\u7528\u8fd9\u91cc\u9009\u4e2d\u7684\u70ed\u8bcd\u7ec4' : undefined}
>
<Select
placeholder={selectedPrompt?.hotWordGroupId ? '\u9ed8\u8ba4\u5df2\u5e26\u51fa\u6a21\u677f\u70ed\u8bcd\u7ec4\uff0c\u53ef\u6309\u9700\u4fee\u6539' : '\u8bf7\u9009\u62e9\u70ed\u8bcd\u7ec4'}
size="large"
options={[
{ label: '\u4e0d\u4f7f\u7528\u70ed\u8bcd\u7ec4', value: 0 },
...hotWordGroups.map((item) => ({
label: `${item.groupName} (${item.hotWordCount}/200)`,
value: item.id,
})),
]}
onChange={() => setHotWordGroupTouched(true)}
/>
</Form.Item> </Form.Item>
</Col>
</Row>
<Collapse <Collapse
ghost ghost
expandIconPosition="end" expandIconPosition="end"
style={{ marginBottom: 24, background: 'var(--app-bg-surface)', border: '1px solid var(--app-border-color)', borderRadius: 8, overflow: 'hidden' }} style={{ marginBottom: 24, background: "var(--app-bg-surface)", border: "1px solid var(--app-border-color)", borderRadius: 8, overflow: "hidden" }}
items={[ items={[
{ {
key: 'advanced', key: "advanced",
forceRender: true, forceRender: true,
label: ( label: (
<div style={{ display: 'flex', alignItems: 'center', width: '100%', height: '32px' }}> <div style={{ display: "flex", alignItems: "center", width: "100%", height: "32px" }}>
<div style={{ display: 'flex', alignItems: 'center', fontWeight: 600, color: 'var(--app-text-main)', fontSize: 15 }}> <div style={{ display: "flex", alignItems: "center", fontWeight: 600, color: "var(--app-text-main)", fontSize: 15 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: '#f0f5ff', color: '#1677ff', display: 'flex', alignItems: 'center', justifyContent: 'center', marginRight: 12 }}> <div style={{ width: 32, height: 32, borderRadius: 8, background: "#f0f5ff", color: "#1677ff", display: "flex", alignItems: "center", justifyContent: "center", marginRight: 12 }}>
<SettingOutlined style={{ fontSize: 16 }} /> <SettingOutlined style={{ fontSize: 16 }} />
</div> </div>
@ -537,40 +570,38 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
</div> </div>
), ),
children: ( children: (
<div style={{ paddingTop: 8, borderTop: '1px dashed var(--app-border-color)' }}> <div style={{ paddingTop: 8, borderTop: "1px dashed var(--app-border-color)" }}>
<Row gutter={32}> <Row gutter={32}>
<Col span={8}> <Col span={8}>
<Form.Item name="useSpkId" label={<span> <Tooltip title="开启后将尝试区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}> <Form.Item name="useSpkId" label={<span> <Tooltip title="自动识别录音中的不同发言者并进行角色分离"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}>
<Switch /> <Switch />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Form.Item name="enableTextRefine" label={<span> <Tooltip title="开启后将尝试对识别文本进行修正"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked"> <Form.Item name="enableTextRefine" label={<span> <Tooltip title="自动识别并修正语音转写中的口语词、语气助词等"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
</Col> </Col>
{type === 'realtime' && ( {type === "realtime" && (
<>
<Col span={8}> <Col span={8}>
<Form.Item name="saveAudio" label="保存录音" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="mode" label="识别模式"> <Form.Item name="mode" label="识别模式">
<Select size="large"> <Radio.Group>
<Option value="2pass">2pass (+线)</Option> <Radio value="2pass">2pass + 线</Radio>
<Option value="online">online ()</Option> <Radio value="online">online</Radio>
</Select> </Radio.Group>
</Form.Item> </Form.Item>
</Col> </Col>
</>
)} )}
</Row> </Row>
<Form.Item <Form.Item name="userPrompt" label="用户提示词" extra="提供补充信息以指导 AI 在生成会议总结时更侧重于特定方面或遵循特定风格">
name="userPrompt" <Input.TextArea placeholder="请输入额外说明,例如:请重点关注会议中的待办事项、以摘要形式输出、或者采用严谨的公文风格等" autoSize={{ minRows: 3, maxRows: 6 }} showCount maxLength={1000} />
label="用户提示词"
extra="可选,用于补充本次会议总结的关注重点、表达偏好或输出侧重点"
>
<Input.TextArea
placeholder="例如:请重点关注待办事项、风险点,并用适合汇报的表达方式输出"
autoSize={{ minRows: 3, maxRows: 6 }}
showCount
maxLength={1000}
/>
</Form.Item> </Form.Item>
</div> </div>
), ),
@ -578,20 +609,11 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
]} ]}
/> />
{type === 'realtime' && ( {type === "upload" && (
<> <>
<Form.Item name="language" hidden><Input /></Form.Item> <div style={{ margin: "32px 0", borderTop: "1px solid var(--app-border-color)" }} />
<Form.Item name="enablePunctuation" hidden valuePropName="checked"><Switch /></Form.Item> <div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
<Form.Item name="enableItn" hidden valuePropName="checked"><Switch /></Form.Item> <div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
<Form.Item name="saveAudio" hidden valuePropName="checked"><Switch /></Form.Item>
</>
)}
{type === 'upload' && (
<>
<div style={{ margin: '32px 0', borderTop: '1px solid var(--app-border-color)' }} />
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
<Title level={5} style={{ margin: 0 }}></Title> <Title level={5} style={{ margin: 0 }}></Title>
</div> </div>
<Dragger <Dragger
@ -601,25 +623,22 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
customRequest={customUpload} customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))} onChange={info => setFileList(info.fileList.slice(-1))}
maxCount={1} maxCount={1}
style={{ borderRadius: 12, padding: '32px 0', background: 'var(--app-bg-surface)', border: '1px dashed var(--app-border-color)' }} style={{ borderRadius: 12, padding: "32px 0", background: "var(--app-bg-surface)", border: "1px dashed var(--app-border-color)" }}
> >
<div> <div>
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: '#1890ff' }} /></p> <p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: "#1890ff" }} /></p>
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: 'var(--app-text-main)' }}></p> <p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: "var(--app-text-main)" }}></p>
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: 'var(--app-text-secondary)' }}> .mp3.wav AAC .m4a </p> <p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: "var(--app-text-secondary)" }}> mp3wavm4a {createConfig.offlineAudioMaxSizeMb}MB</p>
<p className="ant-upload-hint" style={{ fontSize: 13, marginTop: 8, color: 'var(--app-text-secondary)' }}> {offlineAudioMaxSizeMb}MB</p>
{uploadProgress > 0 && uploadProgress < 100 && ( {uploadProgress > 0 && uploadProgress < 100 && (
<div style={{ width: '60%', margin: '32px auto 0' }}> <div style={{ width: "60%", margin: "32px auto 0" }}>
<Progress percent={uploadProgress} size="small" /> <Progress percent={uploadProgress} size="small" />
<div style={{ fontSize: 13, color: '#1890ff', marginTop: 8 }}>...</div> <div style={{ fontSize: 13, color: "#1890ff", marginTop: 8 }}>...</div>
</div> </div>
)} )}
{audioUrl && ( {audioUrl && (
<Tag color="processing" style={{ marginTop: 24, padding: '6px 16px', fontSize: 14, borderRadius: 6, maxWidth: '90%', display: 'inline-flex', alignItems: 'center' }}> <Tag color="processing" style={{ marginTop: 24, padding: "6px 16px", fontSize: 14, borderRadius: 6, maxWidth: "90%", display: "inline-flex", alignItems: "center" }}>
<span style={{ flexShrink: 0 }}>:</span> <span></span>
<span style={{ marginLeft: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ marginLeft: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{audioUrl.split("/").pop()}</span>
{audioUrl.split('/').pop()}
</span>
</Tag> </Tag>
)} )}
</div> </div>
@ -628,6 +647,8 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
)} )}
</Form> </Form>
</div> </div>
</>
)}
</Drawer> </Drawer>
); );
}; };

View File

@ -3,17 +3,23 @@ import { UserProfile } from "../types";
export function useAuth() { export function useAuth() {
const [accessToken, setAccessToken] = useState<string | null>(() => localStorage.getItem("accessToken")); const [accessToken, setAccessToken] = useState<string | null>(() => localStorage.getItem("accessToken"));
const [profileVersion, setProfileVersion] = useState(0);
useEffect(() => { useEffect(() => {
const handler = () => setAccessToken(localStorage.getItem("accessToken")); const syncAuthState = () => setAccessToken(localStorage.getItem("accessToken"));
window.addEventListener("storage", handler); const syncProfileState = () => setProfileVersion((value) => value + 1);
return () => window.removeEventListener("storage", handler); window.addEventListener("storage", syncAuthState);
window.addEventListener("user-profile-updated", syncProfileState);
return () => {
window.removeEventListener("storage", syncAuthState);
window.removeEventListener("user-profile-updated", syncProfileState);
};
}, []); }, []);
const profile = useMemo<UserProfile | null>(() => { const profile = useMemo<UserProfile | null>(() => {
const data = sessionStorage.getItem("userProfile"); const data = sessionStorage.getItem("userProfile");
return data ? JSON.parse(data) : null; return data ? JSON.parse(data) : null;
}, [accessToken]); }, [accessToken, profileVersion]);
const isAuthed = !!accessToken; const isAuthed = !!accessToken;
const logout = () => { const logout = () => {

View File

@ -28,15 +28,13 @@ import {
listTenants, listTenants,
listUsers, listUsers,
pageRoles, pageRoles,
saveRoleDataScope, saveRoleAuthorization,
saveRolePermissions,
unbindUserFromRole, unbindUserFromRole,
updateRole updateRole
} from "@/api"; } from "@/api";
import { useDict } from "@/hooks/useDict"; import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission"; import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader"; import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import { getStandardPagination } from "@/utils/pagination"; import { getStandardPagination } from "@/utils/pagination";
import type { RoleDataScope, SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types"; import type { RoleDataScope, SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types";
import "./index.less"; import "./index.less";
@ -192,6 +190,8 @@ export default function Roles() {
const [dataScopeType, setDataScopeType] = useState("SELF"); const [dataScopeType, setDataScopeType] = useState("SELF");
const [scopeOrgIds, setScopeOrgIds] = useState<number[]>([]); const [scopeOrgIds, setScopeOrgIds] = useState<number[]>([]);
const [scopeOrgTree, setScopeOrgTree] = useState<DataNode[]>([]); const [scopeOrgTree, setScopeOrgTree] = useState<DataNode[]>([]);
const [permissionsDirty, setPermissionsDirty] = useState(false);
const [dataScopeDirty, setDataScopeDirty] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const isPlatformMode = useMemo(() => { const isPlatformMode = useMemo(() => {
@ -246,6 +246,8 @@ export default function Roles() {
setDataScopeType(dataScope?.scopeType || role.dataScopeType || "SELF"); setDataScopeType(dataScope?.scopeType || role.dataScopeType || "SELF");
setScopeOrgIds((dataScope?.orgIds || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id))); setScopeOrgIds((dataScope?.orgIds || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id)));
setScopeOrgTree(toOrgTreeData(buildOrgTree(orgs || []))); setScopeOrgTree(toOrgTreeData(buildOrgTree(orgs || [])));
setPermissionsDirty(false);
setDataScopeDirty(false);
} finally { } finally {
setLoadingUsers(false); setLoadingUsers(false);
} }
@ -272,6 +274,8 @@ export default function Roles() {
setDataScopeType("SELF"); setDataScopeType("SELF");
setScopeOrgIds([]); setScopeOrgIds([]);
setScopeOrgTree([]); setScopeOrgTree([]);
setPermissionsDirty(false);
setDataScopeDirty(false);
} else if (!selectedRole) { } else if (!selectedRole) {
await selectRole(roles[0], permissionList); await selectRole(roles[0], permissionList);
} else { } else {
@ -376,65 +380,57 @@ export default function Roles() {
setRolePage((prev) => ({ ...prev, current: page, size: pageSize })); setRolePage((prev) => ({ ...prev, current: page, size: pageSize }));
}; };
const savePermissions = async () => { const saveAuthorizationChanges = async () => {
if (!selectedRole) return; if (!selectedRole || (!permissionsDirty && !dataScopeDirty)) return;
setSaving(true); if (dataScopeDirty && dataScopeType === "CUSTOM" && scopeOrgIds.length === 0) {
try {
await saveRolePermissions(selectedRole.roleId, Array.from(new Set([...selectedPermIds, ...halfCheckedIds])));
message.success("操作成功");
} finally {
setSaving(false);
}
};
const saveDataScope = async () => {
if (!selectedRole) return;
if (dataScopeType === "CUSTOM" && scopeOrgIds.length === 0) {
message.warning("请选择至少一个部门"); message.warning("请选择至少一个部门");
return; return;
} }
setSaving(true); setSaving(true);
try { try {
const payload: RoleDataScope = { await saveRoleAuthorization(selectedRole.roleId, {
permissionIds: Array.from(new Set([...selectedPermIds, ...halfCheckedIds])),
dataScope: {
roleId: selectedRole.roleId, roleId: selectedRole.roleId,
scopeType: dataScopeType, scopeType: dataScopeType,
orgIds: dataScopeType === "CUSTOM" ? scopeOrgIds : [] orgIds: dataScopeType === "CUSTOM" ? scopeOrgIds : []
}; }
await saveRoleDataScope(selectedRole.roleId, payload); });
message.success("操作成功"); setPermissionsDirty(false);
setDataScopeDirty(false);
setSelectedRole((prev) => (prev ? { ...prev, dataScopeType } : prev)); setSelectedRole((prev) => (prev ? { ...prev, dataScopeType } : prev));
setData((prev) => prev.map((item) => item.roleId === selectedRole.roleId ? { ...item, dataScopeType } : item)); setData((prev) => prev.map((item) => item.roleId === selectedRole.roleId ? { ...item, dataScopeType } : item));
await selectRole({ ...selectedRole, dataScopeType });
message.success("操作成功");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handlePrimarySave = () => { const handlePrimarySave = () => {
if (activeTab === "permissions") { if (activeTab === "users") return;
void savePermissions(); void saveAuthorizationChanges();
return;
}
if (activeTab === "dataScope") {
void saveDataScope();
}
}; };
const saveDisabled = !selectedRole || activeTab === "users" || (activeTab === "permissions" && !can("sys:role:permission:save")) || (activeTab === "dataScope" && !can("sys:role:update")); const needsSave = permissionsDirty || dataScopeDirty;
const saveLabel = activeTab === "dataScope" ? "保存数据权限" : "保存"; const saveDisabled = !selectedRole
|| activeTab === "users"
|| !needsSave
|| (permissionsDirty && !can("sys:role:permission:save"))
|| (dataScopeDirty && !can("sys:role:update"));
const saveLabel = "同步保存";
return ( return (
<PageContainer <div className="app-page roles-page-v2">
title="角色管理" <PageHeader title="角色管理" subtitle="维护角色基础信息、功能权限、数据权限与成员绑定" />
subtitle="维护角色基础信息、功能权限、数据权限与成员绑定"
headerExtra={ <div className="app-page__page-actions">
can("sys:role:create") && ( {can("sys:role:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{"新增角色"}</Button>}
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> </div>
</Button> <div className="roles-layout">
) <Row gutter={24} className="roles-layout__row">
}
>
<Row gutter={24} className="roles-layout__row" style={{ flex: 1, minHeight: 0 }}>
<Col span={7} className="roles-layout__side"> <Col span={7} className="roles-layout__side">
<Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} bordered={false} className="app-page__panel-card roles-side-card"> <Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} bordered={false} className="app-page__panel-card roles-side-card">
<div className="role-search-panel"> <div className="role-search-panel">
@ -526,6 +522,7 @@ export default function Roles() {
const halfChecked = info.halfCheckedKeys || []; const halfChecked = info.halfCheckedKeys || [];
setSelectedPermIds(checked.map((key) => Number(key))); setSelectedPermIds(checked.map((key) => Number(key)));
setHalfCheckedIds(halfChecked.map((key) => Number(key))); setHalfCheckedIds(halfChecked.map((key) => Number(key)));
setPermissionsDirty(true);
}} }}
defaultExpandAll defaultExpandAll
/> />
@ -535,7 +532,15 @@ export default function Roles() {
<Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope"> <Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
<div className="role-detail-pane"> <div className="role-detail-pane">
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Radio.Group value={dataScopeType} onChange={(event) => setDataScopeType(event.target.value)} optionType="button" buttonStyle="solid"> <Radio.Group
value={dataScopeType}
onChange={(event) => {
setDataScopeType(event.target.value);
setDataScopeDirty(true);
}}
optionType="button"
buttonStyle="solid"
>
{DATA_SCOPE_OPTIONS.map((item) => ( {DATA_SCOPE_OPTIONS.map((item) => (
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button> <Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
))} ))}
@ -552,6 +557,7 @@ export default function Roles() {
onCheck={(keys) => { onCheck={(keys) => {
const checked = Array.isArray(keys) ? keys : keys.checked; const checked = Array.isArray(keys) ? keys : keys.checked;
setScopeOrgIds(checked.map((key) => Number(key))); setScopeOrgIds(checked.map((key) => Number(key)));
setDataScopeDirty(true);
}} }}
defaultExpandAll defaultExpandAll
/> />
@ -609,6 +615,7 @@ export default function Roles() {
)} )}
</Col> </Col>
</Row> </Row>
</div>
<Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnClose> <Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnClose>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
@ -636,6 +643,6 @@ export default function Roles() {
</Form.Item> </Form.Item>
</Form> </Form>
</Drawer> </Drawer>
</PageContainer> </div>
); );
} }

View File

@ -2,7 +2,7 @@ import { Button, Card, Form, Input, Layout, Typography, message } from "antd";
import { LockOutlined, LogoutOutlined } from "@ant-design/icons"; import { LockOutlined, LogoutOutlined } from "@ant-design/icons";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { updateMyPassword } from "@/api"; import { getCurrentUser, updateMyPassword } from "@/api";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -18,9 +18,14 @@ export default function ResetPassword() {
const [form] = Form.useForm<ResetPasswordFormValues>(); const [form] = Form.useForm<ResetPasswordFormValues>();
const goToLogin = () => { const goToLogin = () => {
localStorage.clear(); localStorage.removeItem("accessToken");
sessionStorage.clear(); localStorage.removeItem("refreshToken");
navigate("/login"); localStorage.removeItem("displayName");
localStorage.removeItem("username");
localStorage.removeItem("availableTenants");
localStorage.removeItem("activeTenantId");
sessionStorage.removeItem("userProfile");
navigate("/login", { replace: true });
}; };
const onFinish = async (values: ResetPasswordFormValues) => { const onFinish = async (values: ResetPasswordFormValues) => {
@ -30,8 +35,11 @@ export default function ResetPassword() {
oldPassword: values.oldPassword, oldPassword: values.oldPassword,
newPassword: values.newPassword newPassword: values.newPassword
}); });
message.success("密码已更新,请重新登录"); const profile = await getCurrentUser();
goToLogin(); sessionStorage.setItem("userProfile", JSON.stringify(profile));
window.dispatchEvent(new Event("user-profile-updated"));
message.success("密码已更新");
navigate("/", { replace: true });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -45,7 +53,7 @@ export default function ResetPassword() {
<Title level={3} style={{ marginTop: 16 }}> <Title level={3} style={{ marginTop: 16 }}>
</Title> </Title>
<Text type="secondary"></Text> <Text type="secondary"></Text>
</div> </div>
<Form form={form} layout="vertical" onFinish={onFinish}> <Form form={form} layout="vertical" onFinish={onFinish}>
@ -77,7 +85,7 @@ export default function ResetPassword() {
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit" block size="large" loading={loading} style={{ marginTop: 8 }}> <Button type="primary" htmlType="submit" block size="large" loading={loading} style={{ marginTop: 8 }}>
</Button> </Button>
<Button type="link" block icon={<LogoutOutlined />} onClick={goToLogin} style={{ marginTop: 8 }}> <Button type="link" block icon={<LogoutOutlined />} onClick={goToLogin} style={{ marginTop: 8 }}>

View File

@ -155,7 +155,7 @@ const AiModels: React.FC = () => {
isDefaultChecked: false, isDefaultChecked: false,
statusChecked: true, statusChecked: true,
sortOrder: 0, sortOrder: 0,
temperature: 0.7, temperature: 0.2,
topP: 0.9, topP: 0.9,
apiPath: "/v1/chat/completions", apiPath: "/v1/chat/completions",
svThreshold: 0.45, svThreshold: 0.45,

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Alert, Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd'; import { Alert, Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Radio, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd';
import { import {
AudioOutlined, AudioOutlined,
CaretRightFilled, CaretRightFilled,
@ -21,7 +21,7 @@ import {
CheckCircleFilled, CheckCircleFilled,
FilePdfOutlined, FilePdfOutlined,
FileWordOutlined, FileWordOutlined,
ShareAltOutlined, ShareAltOutlined, InfoCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
@ -34,6 +34,7 @@ import {
getTranscripts, getTranscripts,
MeetingChapterVO, MeetingChapterVO,
MeetingProgress, MeetingProgress,
SummaryDetailLevel,
MeetingTranscriptVO, MeetingTranscriptVO,
MeetingVO, MeetingVO,
reSummary, reSummary,
@ -52,7 +53,7 @@ import { listUsers } from '../../api';
import { useDict } from '../../hooks/useDict'; import { useDict } from '../../hooks/useDict';
import { SysUser } from '../../types'; import { SysUser } from '../../types';
import PageHeader from '../../components/shared/PageHeader'; import PageHeader from '../../components/shared/PageHeader';
import PageContainer from "@/components/shared/PageContainer"; import PageContainer from "../../components/shared/PageContainer";
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Option } = Select; const { Option } = Select;
@ -615,7 +616,7 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
highlightKeyword = '', highlightKeyword = '',
}) => { }) => {
const [draftValue, setDraftValue] = useState(item.content); const [draftValue, setDraftValue] = useState(item.content);
const rowRef = useRef<HTMLDivElement>(null); const rowRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
if (isEditing) { if (isEditing) {
@ -751,7 +752,7 @@ const MeetingDetail: React.FC = () => {
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null); const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]); const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]); const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [, setUserList] = useState<SysUser[]>([]); const [userList, setUserList] = useState<SysUser[]>([]);
const { items: speakerLabels } = useDict('biz_speaker_label'); const { items: speakerLabels } = useDict('biz_speaker_label');
const [sharePopoverOpen, setSharePopoverOpen] = useState(false); const [sharePopoverOpen, setSharePopoverOpen] = useState(false);
const [shareSaving, setShareSaving] = useState(false); const [shareSaving, setShareSaving] = useState(false);
@ -839,24 +840,24 @@ const MeetingDetail: React.FC = () => {
); );
const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => { const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => {
const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index])); const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index]));
const sourceChapters = meetingChapters.length const sourceChapters: MeetingChapterVO[] = meetingChapters.length
? meetingChapters ? meetingChapters
: analysis.chapters.map((item) => ({ : analysis.chapters.map((item) => ({
title: item.title, title: item.title,
time: item.time, time: item.time,
})); } as MeetingChapterVO));
return sourceChapters.map((chapter, index) => { return sourceChapters.map((chapter, index) => {
let matchedTranscripts: MeetingTranscriptVO[] = []; let matchedTranscripts: MeetingTranscriptVO[] = [];
const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds) const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds)
? chapter.sourceTranscriptIds ? chapter.sourceTranscriptIds
.map((item) => Number(item)) .map((item: number) => Number(item))
.filter((item) => Number.isFinite(item) && transcriptIdToIndex.has(item)) .filter((item: number) => Number.isFinite(item) && transcriptIdToIndex.has(item))
: []; : [];
if (sourceTranscriptIds.length) { if (sourceTranscriptIds.length) {
matchedTranscripts = sourceTranscriptIds matchedTranscripts = sourceTranscriptIds
.map((item) => transcripts[transcriptIdToIndex.get(item)!]) .map((item: number) => transcripts[transcriptIdToIndex.get(item)!])
.filter(Boolean); .filter(Boolean);
} else if (chapter.startTranscriptId && chapter.endTranscriptId) { } else if (chapter.startTranscriptId && chapter.endTranscriptId) {
const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId)); const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId));
@ -868,7 +869,7 @@ const MeetingDetail: React.FC = () => {
const startMs = typeof chapter.startTime === 'number' ? chapter.startTime : parseChapterTimeToMs(chapter.time); const startMs = typeof chapter.startTime === 'number' ? chapter.startTime : parseChapterTimeToMs(chapter.time);
const nextChapterStartMs = sourceChapters const nextChapterStartMs = sourceChapters
.slice(index + 1) .slice(index + 1)
.map((item) => (typeof item.startTime === 'number' ? item.startTime : parseChapterTimeToMs(item.time))) .map((item: MeetingChapterVO) => (typeof item.startTime === 'number' ? item.startTime : parseChapterTimeToMs(item.time)))
.find((item): item is number => item !== null && startMs !== null && item > startMs); .find((item): item is number => item !== null && startMs !== null && item > startMs);
if (startMs !== null) { if (startMs !== null) {
@ -946,7 +947,12 @@ const MeetingDetail: React.FC = () => {
}; };
} }
return null; return {
key: 'general-failure',
title: '会议处理异常',
description: '会议在处理过程中遇到了问题。您可以尝试重新发起识别或总结。',
hasFallbackContent,
};
}, [meeting, meetingChapters.length]); }, [meeting, meetingChapters.length]);
const emptyTranscriptFailureNotice = useMemo(() => { const emptyTranscriptFailureNotice = useMemo(() => {
if (!meeting || meeting.status !== 4 || transcripts.length > 0) { if (!meeting || meeting.status !== 4 || transcripts.length > 0) {
@ -962,24 +968,6 @@ const MeetingDetail: React.FC = () => {
}; };
}, [canRetryTranscription, meeting, transcripts.length]); }, [canRetryTranscription, meeting, transcripts.length]);
useEffect(() => {
if (!generationFailureNotice) {
return;
}
const acknowledgedKey = `meeting-failure-ack:${generationFailureNotice.key}`;
if (sessionStorage.getItem(acknowledgedKey) === '1') {
return;
}
Modal.warning({
title: generationFailureNotice.title,
content: generationFailureNotice.description,
okText: '我知道了',
onOk: () => {
sessionStorage.setItem(acknowledgedKey, '1');
},
});
}, [generationFailureNotice]);
useEffect(() => { useEffect(() => {
if (!playbackAudioUrl) { if (!playbackAudioUrl) {
setShowFloatingTranscriptPlayer(false); setShowFloatingTranscriptPlayer(false);
@ -1174,6 +1162,7 @@ const MeetingDetail: React.FC = () => {
summaryModelId: values.summaryModelId, summaryModelId: values.summaryModelId,
promptId: values.promptId, promptId: values.promptId,
userPrompt: values.userPrompt, userPrompt: values.userPrompt,
summaryDetailLevel: values.summaryDetailLevel as SummaryDetailLevel,
}); });
message.success('已重新发起总结任务'); message.success('已重新发起总结任务');
setSummaryVisible(false); setSummaryVisible(false);
@ -1193,6 +1182,10 @@ const MeetingDetail: React.FC = () => {
llmModels[0]?.id, llmModels[0]?.id,
promptId: summaryForm.getFieldValue('promptId') ?? prompts[0]?.id, promptId: summaryForm.getFieldValue('promptId') ?? prompts[0]?.id,
userPrompt: meeting?.lastUserPrompt ?? '', userPrompt: meeting?.lastUserPrompt ?? '',
summaryDetailLevel:
summaryForm.getFieldValue('summaryDetailLevel') ??
meeting?.summaryDetailLevel ??
'STANDARD',
}); });
setSummaryVisible(true); setSummaryVisible(true);
}; };
@ -1860,50 +1853,35 @@ const MeetingDetail: React.FC = () => {
<Row gutter={24} style={{ height: '100%' }}> <Row gutter={24} style={{ height: '100%' }}>
<Col xs={24} xl={13} style={{ height: '100%' }}> <Col xs={24} xl={13} style={{ height: '100%' }}>
<div className="detail-side-column detail-left-column"> <div className="detail-side-column detail-left-column">
<Card className="left-flow-card summary-panel" variant="borderless"> {(generationFailureNotice || emptyTranscriptFailureNotice || meeting.audioSaveStatus === 'FAILED') && (
<div className="summary-head">
<div className="summary-title">
<RobotOutlined style={{ color: '#5f51ff', fontSize: 20 }} />
<span>AI </span>
</div>
<div className="summary-head-actions">
{meeting.summaryContent && (
<span className="summary-head-link summary-head-link--static">
<ClockCircleOutlined />
</span>
)}
{meeting.summaryContent && isOwner && (
<span
className="summary-head-link"
onClick={() => {
if (isEditingSummary) {
handleSaveSummary();
} else {
setSummaryDraft(meeting.summaryContent || '');
setIsEditingSummary(true);
}
}}
>
{isEditingSummary ? <><CheckCircleFilled /> </> : <><EditOutlined /> </>}
</span>
)}
{isEditingSummary && (
<span className="summary-head-link" onClick={() => setIsEditingSummary(false)}>
</span>
)}
</div>
</div>
{generationFailureNotice && (
<Alert <Alert
type="warning" type="warning"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16, borderRadius: 12 }}
message={generationFailureNotice.title} message={
description={generationFailureNotice.description} <div style={{ fontWeight: 600 }}>
{generationFailureNotice?.title || emptyTranscriptFailureNotice?.title || '会议处理提醒'}
</div>
}
description={
<div style={{ fontSize: 13, marginTop: 4 }}>
<div>{generationFailureNotice?.description || emptyTranscriptFailureNotice?.description}</div>
{meeting.audioSaveStatus === 'FAILED' && (
<div style={{ marginTop: 4, color: '#d48806' }}>
<InfoCircleOutlined style={{ marginRight: 4 }} />
{meeting.audioSaveMessage || '录音保存失败,当前无法播放'}
</div>
)}
{canRetryTranscription && (
<div style={{ marginTop: 8 }}>
{emptyTranscriptFailureNotice?.hint}
</div>
)}
</div>
}
/> />
)} )}
<Card className="left-flow-card summary-panel" variant="borderless">
{meeting.status === 2 ? ( {meeting.status === 2 ? (
<div className="summary-progress-shell"> <div className="summary-progress-shell">
@ -2029,14 +2007,6 @@ const MeetingDetail: React.FC = () => {
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div> <div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
</div> </div>
)} )}
{meeting.audioSaveStatus === 'FAILED' && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
/>
)}
<List <List
className="transcript-list" className="transcript-list"
dataSource={transcripts} dataSource={transcripts}
@ -2160,14 +2130,6 @@ const MeetingDetail: React.FC = () => {
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div> <div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
</div> </div>
)} )}
{meeting.audioSaveStatus === 'FAILED' && (
<Alert
type="warning"
showIcon
style={{ margin: '0 18px 16px' }}
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
/>
)}
<List <List
className="transcript-list" className="transcript-list"
dataSource={transcripts} dataSource={transcripts}
@ -3560,7 +3522,18 @@ const MeetingDetail: React.FC = () => {
<Form.Item name="tags" label="业务标签"> <Form.Item name="tags" label="业务标签">
<Select mode="tags" placeholder="输入标签后回车" /> <Select mode="tags" placeholder="输入标签后回车" />
</Form.Item> </Form.Item>
<Text type="warning"> ID </Text> <Form.Item name="participantIds" label="参会人员">
<Select mode="multiple" placeholder="请选择参会人" showSearch optionFilterProp="children">
{userList.map((u) => (
<Option key={u.userId} value={u.userId}>
<Space>
<Avatar size="small" icon={<UserOutlined />} />
{u.displayName || u.username}
</Space>
</Option>
))}
</Select>
</Form.Item>
</Form> </Form>
</Modal> </Modal>
@ -3647,6 +3620,13 @@ const MeetingDetail: React.FC = () => {
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item name="summaryDetailLevel" label="总结详细程度" rules={[{ required: true }]}>
<Radio.Group>
<Radio value="DETAILED"></Radio>
<Radio value="STANDARD"></Radio>
<Radio value="BRIEF"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item <Form.Item
name="userPrompt" name="userPrompt"
label="用户提示词" label="用户提示词"

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ import { Button, Empty, Skeleton, Tag, Typography } from "antd";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { getRecentTasks } from "@/api/business/dashboard"; import { getRecentTasks } from "@/api/business/dashboard";
import type { MeetingVO } from "@/api/business/meeting"; import { getMeetingCreateConfig, type MeetingCreateConfig, type MeetingVO } from "@/api/business/meeting";
import "./index.less"; import "./index.less";
import RightVisual from "./RightVisual"; import RightVisual from "./RightVisual";
import { MeetingCreateDrawer, MeetingCreateType } from "@/components/business/MeetingCreateDrawer"; import { MeetingCreateDrawer, MeetingCreateType } from "@/components/business/MeetingCreateDrawer";
@ -76,6 +76,12 @@ export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]); const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [configLoaded, setConfigLoaded] = useState(false);
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>({
offlineEnabled: false,
realtimeEnabled: false,
offlineAudioMaxSizeMb: 1024,
});
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerType, setDrawerType] = useState<MeetingCreateType>('realtime'); const [drawerType, setDrawerType] = useState<MeetingCreateType>('realtime');
const [readCardIds, setReadCardIds] = useState<string[]>(() => { const [readCardIds, setReadCardIds] = useState<string[]>(() => {
@ -108,11 +114,21 @@ export default function HomePage() {
useEffect(() => { useEffect(() => {
const fetchRecentTasks = async () => { const fetchRecentTasks = async () => {
try { try {
const response = await getRecentTasks(); const [response, configResponse] = await Promise.all([
getRecentTasks(),
getMeetingCreateConfig(),
]);
const payload: any = (response as any).data || response; const payload: any = (response as any).data || response;
setRecentTasks(payload?.data || payload || []); setRecentTasks(payload?.data || payload || []);
setCreateConfig(configResponse.data.data || {
offlineEnabled: true,
realtimeEnabled: true,
offlineAudioMaxSizeMb: 1024,
});
setConfigLoaded(true);
} catch (error) { } catch (error) {
console.error("Home recent tasks load failed", error); console.error("Home recent tasks load failed", error);
setConfigLoaded(true);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -129,9 +145,10 @@ export default function HomePage() {
window.localStorage.setItem(RECENT_CARD_READ_STORAGE_KEY, JSON.stringify(readCardIds)); window.localStorage.setItem(RECENT_CARD_READ_STORAGE_KEY, JSON.stringify(readCardIds));
}, [readCardIds]); }, [readCardIds]);
const quickEntries = useMemo<QuickEntry[]>( const quickEntries = useMemo<(QuickEntry & { key: string })[]>(
() => [ () => [
{ {
key: "realtime",
title: "开启实时记录", title: "开启实时记录",
icon: <AudioOutlined />, icon: <AudioOutlined />,
description: ["实时语音转文字", "同步翻译,智能总结要点"], description: ["实时语音转文字", "同步翻译,智能总结要点"],
@ -143,6 +160,7 @@ export default function HomePage() {
} }
}, },
{ {
key: "offline",
title: "上传音视频", title: "上传音视频",
icon: <VideoCameraAddOutlined />, icon: <VideoCameraAddOutlined />,
description: ["音视频转文字", "区分发言人,一键导出"], description: ["音视频转文字", "区分发言人,一键导出"],
@ -157,7 +175,13 @@ export default function HomePage() {
[] []
); );
const visibleQuickEntries = useMemo(
() => quickEntries.filter((item) => (item.key === "realtime" ? createConfig.realtimeEnabled : createConfig.offlineEnabled)),
[quickEntries, createConfig]
);
const recentCards = useMemo(() => buildRecentCards(recentTasks), [recentTasks]); const recentCards = useMemo(() => buildRecentCards(recentTasks), [recentTasks]);
const recentTaskMap = useMemo(() => new Map(recentTasks.map((item) => [String(item.id), item])), [recentTasks]);
const handleRecentCardClick = (card: RecentCard) => { const handleRecentCardClick = (card: RecentCard) => {
const cardId = String(card.id); const cardId = String(card.id);
@ -195,7 +219,8 @@ export default function HomePage() {
</Title> </Title>
<div className="home-quick-actions"> <div className="home-quick-actions">
{quickEntries.map((entry) => ( {configLoaded ? (
visibleQuickEntries.length > 0 ? visibleQuickEntries.map((entry) => (
<div <div
className={`home-action-item home-action-item--${entry.accent}`} className={`home-action-item home-action-item--${entry.accent}`}
onClick={entry.onClick} onClick={entry.onClick}
@ -216,7 +241,28 @@ export default function HomePage() {
))} ))}
</div> </div>
</div> </div>
))} )) : (
<div className="home-action-item home-action-item--cyan" style={{ cursor: "default", opacity: 0.7 }}>
<div className="home-action-badge"></div>
<div className="home-action-icon-wrapper">
<div className="home-action-icon"><AudioOutlined /></div>
<div className="home-action-icon-glow" />
<div className="home-action-icon-circle" />
</div>
<Title level={3} className="home-action-title"></Title>
<div className="home-action-desc">
<Text className="home-action-line">线</Text>
</div>
</div>
)
) : (
// Loading state for quick actions to prevent jump
[...Array(2)].map((_, i) => (
<div key={i} className="home-action-item home-action-item--loading" style={{ background: 'rgba(255,255,255,0.4)', border: '1px dashed rgba(0,0,0,0.05)' }}>
<Skeleton active avatar paragraph={{ rows: 2 }} />
</div>
))
)}
</div> </div>
</header> </header>
@ -260,6 +306,11 @@ export default function HomePage() {
</div> </div>
</div> </div>
<div className="home-recent-card-tags"> <div className="home-recent-card-tags">
{recentTaskMap.get(String(card.id))?.meetingSource && (
<Tag key={`${card.id}-source`} className="home-recent-card-tag" bordered={false}>
{recentTaskMap.get(String(card.id))?.meetingSource === "ANDROID" ? "安卓端" : "Web端"}
</Tag>
)}
{card.tags.slice(0, 4).map((tag) => ( {card.tags.slice(0, 4).map((tag) => (
<Tag key={`${card.id}-${tag}`} className="home-recent-card-tag" bordered={false}> <Tag key={`${card.id}-${tag}`} className="home-recent-card-tag" bordered={false}>
{tag} {tag}

View File

@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api"; import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api";
import PageHeader from "@/components/shared/PageHeader"; import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import type { BotCredential, UserProfile } from "@/types"; import type { BotCredential, UserProfile } from "@/types";
import AvatarCropDialog, { type CropModalState } from "./AvatarCropDialog"; import AvatarCropDialog, { type CropModalState } from "./AvatarCropDialog";
@ -106,6 +105,10 @@ export default function Profile() {
const values = await pwdForm.validateFields(); const values = await pwdForm.validateFields();
setSaving(true); setSaving(true);
await updateMyPassword(values); await updateMyPassword(values);
const profile = await getCurrentUser();
setUser(profile);
sessionStorage.setItem("userProfile", JSON.stringify(profile));
window.dispatchEvent(new Event("user-profile-updated"));
message.success(t("common.success")); message.success(t("common.success"));
pwdForm.resetFields(); pwdForm.resetFields();
} finally { } finally {
@ -129,11 +132,9 @@ export default function Profile() {
const avatarUrl = avatarUrlValue?.trim() || undefined; const avatarUrl = avatarUrlValue?.trim() || undefined;
return ( return (
<PageContainer <div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}>
title={t("profile.title")} <PageHeader title={t("profile.title")} subtitle={t("profile.subtitle")} />
subtitle={t("profile.subtitle")}
style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}
>
<Row gutter={24}> <Row gutter={24}>
<Col xs={24} lg={8}> <Col xs={24} lg={8}>
<Card className="app-page__content-card text-center" loading={loading}> <Card className="app-page__content-card text-center" loading={loading}>
@ -307,8 +308,7 @@ export default function Profile() {
onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))} onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))}
onConfirm={handleUploadCroppedImage} onConfirm={handleUploadCroppedImage}
/> />
</PageContainer> </div>
); );
} }