feat(core): 新增租户模型激活、ASR同步与会议导出功能
- 实现租户级AI模型激活与默认配置管理 - 新增发言人ASR状态网关及数据同步机制 - 完善会议导出服务的具体实现 - 补充AI模型与会议运行时的单元测试 - 适配前端AI模型管理与发言人注册页面dev_na
parent
f5a6a22eb1
commit
17dbeefdf8
|
|
@ -46,7 +46,7 @@ public class AndroidLlmModelController {
|
|||
AndroidRequestLogHelper.logRequest(log, "Android模型", "查询启用大模型列表接口");
|
||||
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
|
||||
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId());
|
||||
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId(), false);
|
||||
List<AiModelVO> enabledModels = result.getRecords() == null
|
||||
? List.of()
|
||||
: result.getRecords().stream()
|
||||
|
|
|
|||
|
|
@ -453,7 +453,7 @@ public class AndroidMeetingController {
|
|||
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
|
||||
.toList();
|
||||
resultVo.setTemplateList(enabledTemplates);
|
||||
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId);
|
||||
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId, false);
|
||||
List<AiModelVO> enabledModels = modelList.getRecords() == null
|
||||
? List.of()
|
||||
: modelList.getRecords().stream()
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public class LegacyLlmModelController {
|
|||
public LegacyApiResponse<List<LegacyLlmModelItemResponse>> activeModels() {
|
||||
AndroidRequestLogHelper.logRequest(log, "兼容模型", "查询启用大模型列表接口");
|
||||
LoginUser loginUser = currentLoginUser();
|
||||
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId());
|
||||
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId(), false);
|
||||
List<AiModelVO> enabledModels = result.getRecords() == null
|
||||
? List.of()
|
||||
: result.getRecords().stream()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.imeeting.controller.biz;
|
|||
import com.imeeting.dto.biz.AiLocalProfileVO;
|
||||
import com.imeeting.dto.biz.AiModelDTO;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.dto.biz.PlatformAsrStatusUpdateCommand;
|
||||
import com.imeeting.dto.biz.TenantModelDefaultCommand;
|
||||
import com.imeeting.enums.ModelProviderEnum;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.unisbase.common.ApiResponse;
|
||||
|
|
@ -90,9 +92,11 @@ public class AiModelController {
|
|||
@RequestParam(defaultValue = "1") Integer current,
|
||||
@RequestParam(defaultValue = "10") Integer size,
|
||||
@RequestParam(required = false) String name,
|
||||
@RequestParam(required = false) String type) {
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false, defaultValue = "false") Boolean tenantEnabledOnly) {
|
||||
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
return ApiResponse.ok(aiModelService.pageModels(current, size, name, type, loginUser.getTenantId()));
|
||||
boolean platformAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin());
|
||||
return ApiResponse.ok(aiModelService.pageModels(current, size, name, type, loginUser.getTenantId(), platformAdmin, Boolean.TRUE.equals(tenantEnabledOnly)));
|
||||
}
|
||||
|
||||
@Operation(summary = "拉取远程模型列表")
|
||||
|
|
@ -139,4 +143,77 @@ public class AiModelController {
|
|||
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
return ApiResponse.ok(aiModelService.getDefaultModel(type, loginUser.getTenantId()));
|
||||
}
|
||||
|
||||
@Operation(summary = "租户启用 ASR")
|
||||
@PostMapping("/{id}/tenant-enable")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> tenantEnable(@PathVariable Long id,
|
||||
@RequestParam(required = false, defaultValue = "ASR") String type) {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
if (loginUser == null || loginUser.getTenantId() == null) {
|
||||
return ApiResponse.error("未获取到用户信息");
|
||||
}
|
||||
aiModelService.enableModelForTenant(type, loginUser.getTenantId(), id);
|
||||
return ApiResponse.ok(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Operation(summary = "租户关闭 ASR")
|
||||
@PostMapping("/{id}/tenant-disable")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> tenantDisable(@PathVariable Long id,
|
||||
@RequestParam(required = false, defaultValue = "ASR") String type) {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
if (loginUser == null || loginUser.getTenantId() == null) {
|
||||
return ApiResponse.error("未获取到用户信息");
|
||||
}
|
||||
aiModelService.disableModelForTenant(type, loginUser.getTenantId(), id);
|
||||
return ApiResponse.ok(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Operation(summary = "平台更新 ASR 状态")
|
||||
@PostMapping("/{id}/platform-status")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> updatePlatformStatus(@PathVariable Long id,
|
||||
@RequestBody PlatformAsrStatusUpdateCommand command,
|
||||
@RequestParam(required = false, defaultValue = "ASR") String type) {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
if (loginUser == null) {
|
||||
return ApiResponse.error("未获取到用户信息");
|
||||
}
|
||||
aiModelService.updatePlatformModelStatus(type, id, command.getStatus(), Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()));
|
||||
return ApiResponse.ok(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Operation(summary = "同步当前 ASR 声纹")
|
||||
@PostMapping("/current/sync-speakers")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> syncCurrentSpeakers() {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
if (loginUser == null || loginUser.getTenantId() == null) {
|
||||
return ApiResponse.error("未获取到用户信息");
|
||||
}
|
||||
aiModelService.syncCurrentTenantActiveAsrSpeakers(loginUser.getTenantId());
|
||||
return ApiResponse.ok(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Operation(summary = "绉熸埛璁剧疆榛樿妯″瀷")
|
||||
@PostMapping("/{id}/tenant-default")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> setTenantDefault(@PathVariable Long id,
|
||||
@RequestBody TenantModelDefaultCommand command) {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
if (loginUser == null || loginUser.getTenantId() == null) {
|
||||
return ApiResponse.error("鏈幏鍙栧埌鐢ㄦ埛淇℃伅");
|
||||
}
|
||||
aiModelService.setDefaultModelForTenant(command.getModelType(), loginUser.getTenantId(), id);
|
||||
return ApiResponse.ok(Boolean.TRUE);
|
||||
}
|
||||
|
||||
private LoginUser getLoginUser() {
|
||||
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
if (principal instanceof LoginUser loginUser) {
|
||||
return loginUser;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ public class MeetingController {
|
|||
throw new RuntimeException("会议不存在");
|
||||
}
|
||||
|
||||
MeetingSummaryExportResult exportResult = meetingExportService.exportSummary(meeting, meetingDetail, format);
|
||||
MeetingSummaryExportResult exportResult = meetingExportService.exportSummary(meeting, meetingDetail, format, loginUser);
|
||||
String encodedFilename = URLEncoder.encode(exportResult.getFileName(), StandardCharsets.UTF_8).replace("+", "%20");
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ import io.swagger.v3.oas.annotations.Operation;
|
|||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
|
@ -64,6 +71,18 @@ public class SpeakerController {
|
|||
return ApiResponse.ok(speakerService.listVisible(loginUser));
|
||||
}
|
||||
|
||||
@Operation(summary = "同步当前 ASR 声纹")
|
||||
@PostMapping("/{id}/sync")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> sync(@PathVariable Long id) {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
if (loginUser == null || loginUser.getUserId() == null) {
|
||||
return ApiResponse.error("未获取到用户信息");
|
||||
}
|
||||
speakerService.syncCurrentAsr(id, loginUser);
|
||||
return ApiResponse.ok(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Operation(summary = "删除讲话人")
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
|
|
|
|||
|
|
@ -39,6 +39,14 @@ public class AiModelVO {
|
|||
private Integer isDefault;
|
||||
@Schema(description = "启用状态")
|
||||
private Integer status;
|
||||
@Schema(description = "当前租户是否启用")
|
||||
private Integer tenantEnabled;
|
||||
@Schema(description = "当前租户是否默认")
|
||||
private Integer tenantDefault;
|
||||
@Schema(description = "记录作用域: PLATFORM/TENANT")
|
||||
private String scope;
|
||||
@Schema(description = "当前用户是否可编辑配置")
|
||||
private Boolean canEditConfig;
|
||||
@Schema(description = "排序值,越小越靠前")
|
||||
private Integer sortOrder;
|
||||
@Schema(description = "备注")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package com.imeeting.dto.biz;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "平台级 ASR 状态更新命令")
|
||||
public class PlatformAsrStatusUpdateCommand {
|
||||
@NotNull(message = "状态不能为空")
|
||||
@Schema(description = "状态: 1-启用, 0-禁用", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer status;
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ public class SpeakerVO {
|
|||
private String voiceExt;
|
||||
private Long voiceSize;
|
||||
private Integer status;
|
||||
private String syncStatus;
|
||||
private String syncErrorMessage;
|
||||
private String remark;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
package com.imeeting.dto.biz;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "租户默认模型命令")
|
||||
public class TenantModelDefaultCommand {
|
||||
@NotNull(message = "模型 ID 不能为空")
|
||||
@Schema(description = "模型 ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long modelId;
|
||||
|
||||
@NotNull(message = "模型类型不能为空")
|
||||
@Schema(description = "模型类型: ASR/LLM", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String modelType;
|
||||
}
|
||||
|
|
@ -44,6 +44,9 @@ public class Speaker extends BaseEntity {
|
|||
@Schema(description = "备注")
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "声纹业务版本")
|
||||
private Long version;
|
||||
|
||||
// Note: status, createdAt, updatedAt, isDeleted are in BaseEntity
|
||||
// embedding is reserved for future pgvector use
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
package com.imeeting.entity.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.unisbase.entity.BaseEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "声纹-ASR 同步快照")
|
||||
@TableName("biz_speaker_asr_sync")
|
||||
public class SpeakerAsrSync extends BaseEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description = "主键 ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "租户 ID")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "声纹 ID")
|
||||
private Long speakerId;
|
||||
|
||||
@Schema(description = "ASR 模型 ID")
|
||||
private Long asrModelId;
|
||||
|
||||
@Schema(description = "声纹业务版本")
|
||||
private Long speakerVersion;
|
||||
|
||||
@Schema(description = "第三方声纹 ID")
|
||||
private String externalSpeakerId;
|
||||
|
||||
@Schema(description = "同步状态")
|
||||
private String syncStatus;
|
||||
|
||||
@Schema(description = "上次同步时间")
|
||||
private LocalDateTime lastSyncedAt;
|
||||
|
||||
@Schema(description = "最近一次错误信息")
|
||||
private String lastErrorMessage;
|
||||
|
||||
@Schema(description = "最近一次同步批次 ID")
|
||||
private String lastSyncBatchId;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.imeeting.entity.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.unisbase.entity.BaseEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "租户模型启用投影")
|
||||
@TableName("biz_tenant_model_activation")
|
||||
public class TenantModelActivation extends BaseEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description = "主键 ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "租户 ID")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "模型类型: ASR/LLM")
|
||||
private String modelType;
|
||||
|
||||
@Schema(description = "模型 ID")
|
||||
private Long modelId;
|
||||
|
||||
@Schema(description = "是否启用: 1-启用, 0-关闭")
|
||||
private Integer enabled;
|
||||
|
||||
@Schema(description = "是否默认: 1-默认, 0-非默认")
|
||||
private Integer isDefault;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.imeeting.enums;
|
||||
|
||||
public enum SpeakerAsrSyncStatusEnum {
|
||||
PENDING,
|
||||
SYNCING,
|
||||
SYNCED,
|
||||
FAILED,
|
||||
STALE,
|
||||
DELETED
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.imeeting.mapper.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.biz.SpeakerAsrSync;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SpeakerAsrSyncMapper extends BaseMapper<SpeakerAsrSync> {
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.imeeting.mapper.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.biz.TenantModelActivation;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface TenantModelActivationMapper extends BaseMapper<TenantModelActivation> {
|
||||
}
|
||||
|
|
@ -11,11 +11,30 @@ import java.util.List;
|
|||
public interface AiModelService {
|
||||
AiModelVO saveModel(AiModelDTO dto);
|
||||
AiModelVO updateModel(AiModelDTO dto);
|
||||
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId);
|
||||
|
||||
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId, boolean platformAdmin);
|
||||
|
||||
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId, boolean platformAdmin, boolean tenantEnabledOnly);
|
||||
List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey);
|
||||
AiLocalProfileVO testLocalConnectivity(String baseUrl, String apiKey);
|
||||
void testLlmConnectivity(AiModelDTO dto);
|
||||
AiModelVO getDefaultModel(String type, Long tenantId);
|
||||
AiModelVO getModelById(Long id, String type);
|
||||
boolean removeModelById(Long id, String type);
|
||||
|
||||
void enableModelForTenant(String type, Long tenantId, Long modelId);
|
||||
|
||||
void disableModelForTenant(String type, Long tenantId, Long modelId);
|
||||
|
||||
void setDefaultModelForTenant(String type, Long tenantId, Long modelId);
|
||||
|
||||
void updatePlatformModelStatus(String type, Long modelId, Integer status, boolean platformAdmin);
|
||||
|
||||
void enableAsrForTenant(Long tenantId, Long asrModelId);
|
||||
|
||||
void disableAsrForTenant(Long tenantId, Long asrModelId);
|
||||
|
||||
void updatePlatformAsrStatus(Long asrModelId, Integer status, boolean platformAdmin);
|
||||
|
||||
void syncCurrentTenantActiveAsrSpeakers(Long tenantId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ package com.imeeting.service.biz;
|
|||
import com.imeeting.dto.biz.MeetingSummaryExportResult;
|
||||
import com.imeeting.dto.biz.MeetingVO;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.unisbase.security.LoginUser;
|
||||
|
||||
public interface MeetingExportService {
|
||||
MeetingSummaryExportResult exportSummary(Meeting meeting, MeetingVO meetingDetail, String format);
|
||||
MeetingSummaryExportResult exportSummary(Meeting meeting, MeetingVO meetingDetail, String format, LoginUser loginUser);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.imeeting.service.biz;
|
||||
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.entity.biz.Speaker;
|
||||
import com.imeeting.entity.biz.SpeakerAsrSync;
|
||||
|
||||
public interface SpeakerAsrGatewayService {
|
||||
String registerSpeaker(Speaker speaker, AiModelVO asrModel);
|
||||
|
||||
void deleteSpeaker(SpeakerAsrSync snapshot, AiModelVO asrModel);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.imeeting.service.biz;
|
||||
|
||||
public interface SpeakerAsrSyncService {
|
||||
void queueSyncForCurrentAsr(Long tenantId, Long speakerId);
|
||||
|
||||
void queueCatchUpForCurrentAsr(Long tenantId);
|
||||
|
||||
void invalidateOtherAsrSnapshots(Long tenantId, Long speakerId, Long keepAsrModelId);
|
||||
|
||||
void syncSpeakerToAsr(Long tenantId, Long speakerId, Long asrModelId);
|
||||
}
|
||||
|
|
@ -16,5 +16,7 @@ public interface SpeakerService extends IService<Speaker> {
|
|||
|
||||
List<SpeakerVO> listVisible(LoginUser loginUser);
|
||||
|
||||
void syncCurrentAsr(Long id, LoginUser loginUser);
|
||||
|
||||
void deleteSpeaker(Long id, LoginUser loginUser);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
package com.imeeting.service.biz;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TenantModelActivationService {
|
||||
Long resolveActiveAsrId(Long tenantId);
|
||||
|
||||
void enableAsrForTenant(Long tenantId, Long asrModelId);
|
||||
|
||||
void disableAsrForTenant(Long tenantId, Long asrModelId);
|
||||
|
||||
List<Long> refreshPlatformInheritanceForAsr(Long asrModelId);
|
||||
|
||||
void disableAsrForAllTenants(Long asrModelId);
|
||||
|
||||
boolean isTenantEnabled(String modelType, Long tenantId, Long modelId);
|
||||
|
||||
List<Long> listEnabledModelIds(String modelType, Long tenantId);
|
||||
|
||||
void enableModelForTenant(String modelType, Long tenantId, Long modelId, boolean singleSelect);
|
||||
|
||||
void disableModelForTenant(String modelType, Long tenantId, Long modelId);
|
||||
|
||||
void disableModelForAllTenants(String modelType, Long modelId);
|
||||
|
||||
void setDefaultModelForTenant(String modelType, Long tenantId, Long modelId);
|
||||
|
||||
Long resolveDefaultModelId(String modelType, Long tenantId);
|
||||
|
||||
List<Long> refreshPlatformInheritanceForLlm(Long modelId);
|
||||
}
|
||||
|
|
@ -15,9 +15,12 @@ import com.imeeting.enums.ModelProviderEnum;
|
|||
import com.imeeting.mapper.biz.AsrModelMapper;
|
||||
import com.imeeting.mapper.biz.LlmModelMapper;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.SpeakerAsrSyncService;
|
||||
import com.imeeting.service.biz.TenantModelActivationService;
|
||||
import com.unisbase.dto.PageResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
|
@ -70,6 +73,10 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
private final ObjectMapper objectMapper;
|
||||
private final AsrModelMapper asrModelMapper;
|
||||
private final LlmModelMapper llmModelMapper;
|
||||
@Autowired
|
||||
private TenantModelActivationService tenantModelActivationService;
|
||||
@Autowired
|
||||
private SpeakerAsrSyncService speakerAsrSyncService;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(300))
|
||||
|
|
@ -127,12 +134,23 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
}
|
||||
|
||||
@Override
|
||||
public PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId) {
|
||||
public PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId, boolean platformAdmin) {
|
||||
return pageModels(current, size, name, type, tenantId, platformAdmin, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId, boolean platformAdmin, boolean tenantEnabledOnly) {
|
||||
String resolvedType = normalizeType(type);
|
||||
if (TYPE_ASR.equals(resolvedType)) {
|
||||
List<Long> enabledModelIds = resolveTenantEnabledModelIds(TYPE_ASR, tenantId, platformAdmin, tenantEnabledOnly);
|
||||
if (tenantEnabledOnly && !platformAdmin && enabledModelIds.isEmpty()) {
|
||||
return emptyModelPage();
|
||||
}
|
||||
Page<AsrModel> page = new Page<>(current, size);
|
||||
LambdaQueryWrapper<AsrModel> wrapper = new LambdaQueryWrapper<AsrModel>()
|
||||
.and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
|
||||
.and(!(tenantEnabledOnly && platformAdmin), w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
|
||||
.eq((!platformAdmin || tenantEnabledOnly), AsrModel::getStatus, 1)
|
||||
.in(tenantEnabledOnly && !platformAdmin, AsrModel::getId, enabledModelIds)
|
||||
.like(name != null && !name.isBlank(), AsrModel::getModelName, name)
|
||||
.orderByDesc(AsrModel::getIsDefault)
|
||||
.orderByAsc(AsrModel::getSortOrder)
|
||||
|
|
@ -141,17 +159,26 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
Page<AsrModel> resultPage = asrModelMapper.selectPage(page, wrapper);
|
||||
List<AiModelVO> records = new ArrayList<>();
|
||||
for (AsrModel entity : resultPage.getRecords()) {
|
||||
records.add(toAsrVO(entity));
|
||||
AiModelVO vo = toAsrVO(entity, tenantId, platformAdmin);
|
||||
if (isVisibleForTenantEnabledOnly(vo, tenantEnabledOnly, platformAdmin)) {
|
||||
records.add(vo);
|
||||
}
|
||||
}
|
||||
PageResult<List<AiModelVO>> result = new PageResult<>();
|
||||
result.setTotal(resultPage.getTotal());
|
||||
result.setTotal(tenantEnabledOnly ? records.size() : resultPage.getTotal());
|
||||
result.setRecords(records);
|
||||
return result;
|
||||
}
|
||||
|
||||
List<Long> enabledModelIds = resolveTenantEnabledModelIds(TYPE_LLM, tenantId, platformAdmin, tenantEnabledOnly);
|
||||
if (tenantEnabledOnly && !platformAdmin && enabledModelIds.isEmpty()) {
|
||||
return emptyModelPage();
|
||||
}
|
||||
Page<LlmModel> page = new Page<>(current, size);
|
||||
LambdaQueryWrapper<LlmModel> wrapper = new LambdaQueryWrapper<LlmModel>()
|
||||
.and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L))
|
||||
.and(!(tenantEnabledOnly && platformAdmin), w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L))
|
||||
.eq((!platformAdmin || tenantEnabledOnly), LlmModel::getStatus, 1)
|
||||
.in(tenantEnabledOnly && !platformAdmin, LlmModel::getId, enabledModelIds)
|
||||
.like(name != null && !name.isBlank(), LlmModel::getModelName, name)
|
||||
.orderByDesc(LlmModel::getIsDefault)
|
||||
.orderByAsc(LlmModel::getSortOrder)
|
||||
|
|
@ -160,14 +187,114 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
Page<LlmModel> resultPage = llmModelMapper.selectPage(page, wrapper);
|
||||
List<AiModelVO> records = new ArrayList<>();
|
||||
for (LlmModel entity : resultPage.getRecords()) {
|
||||
records.add(toLlmVO(entity));
|
||||
AiModelVO vo = toLlmVO(entity, tenantId, platformAdmin);
|
||||
if (isVisibleForTenantEnabledOnly(vo, tenantEnabledOnly, platformAdmin)) {
|
||||
records.add(vo);
|
||||
}
|
||||
}
|
||||
PageResult<List<AiModelVO>> result = new PageResult<>();
|
||||
result.setTotal(resultPage.getTotal());
|
||||
result.setTotal(tenantEnabledOnly ? records.size() : resultPage.getTotal());
|
||||
result.setRecords(records);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enableAsrForTenant(Long tenantId, Long asrModelId) {
|
||||
assertModelEnabled(asrModelId, TYPE_ASR);
|
||||
tenantModelActivationService.enableAsrForTenant(tenantId, asrModelId);
|
||||
speakerAsrSyncService.queueCatchUpForCurrentAsr(tenantId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAsrForTenant(Long tenantId, Long asrModelId) {
|
||||
tenantModelActivationService.disableAsrForTenant(tenantId, asrModelId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updatePlatformAsrStatus(Long asrModelId, Integer status, boolean platformAdmin) {
|
||||
if (!platformAdmin) {
|
||||
throw new RuntimeException("无权修改平台级 ASR 状态");
|
||||
}
|
||||
AsrModel entity = asrModelMapper.selectById(asrModelId);
|
||||
if (entity == null) {
|
||||
throw new RuntimeException("ASR 模型不存在");
|
||||
}
|
||||
if (!Long.valueOf(0L).equals(entity.getTenantId())) {
|
||||
throw new RuntimeException("仅平台级 ASR 支持该操作");
|
||||
}
|
||||
entity.setStatus(status);
|
||||
asrModelMapper.updateById(entity);
|
||||
if (Integer.valueOf(1).equals(status)) {
|
||||
List<Long> newlyEnabledTenantIds = tenantModelActivationService.refreshPlatformInheritanceForAsr(asrModelId);
|
||||
for (Long tenantId : newlyEnabledTenantIds) {
|
||||
speakerAsrSyncService.queueCatchUpForCurrentAsr(tenantId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
tenantModelActivationService.disableAsrForAllTenants(asrModelId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void syncCurrentTenantActiveAsrSpeakers(Long tenantId) {
|
||||
speakerAsrSyncService.queueCatchUpForCurrentAsr(tenantId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enableModelForTenant(String type, Long tenantId, Long modelId) {
|
||||
String resolvedType = normalizeType(type);
|
||||
if (TYPE_ASR.equals(resolvedType)) {
|
||||
enableAsrForTenant(tenantId, modelId);
|
||||
return;
|
||||
}
|
||||
assertModelEnabled(modelId, TYPE_LLM);
|
||||
tenantModelActivationService.enableModelForTenant(TYPE_LLM, tenantId, modelId, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableModelForTenant(String type, Long tenantId, Long modelId) {
|
||||
String resolvedType = normalizeType(type);
|
||||
if (TYPE_ASR.equals(resolvedType)) {
|
||||
disableAsrForTenant(tenantId, modelId);
|
||||
return;
|
||||
}
|
||||
tenantModelActivationService.disableModelForTenant(TYPE_LLM, tenantId, modelId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setDefaultModelForTenant(String type, Long tenantId, Long modelId) {
|
||||
String resolvedType = normalizeType(type);
|
||||
assertModelEnabled(modelId, resolvedType);
|
||||
tenantModelActivationService.setDefaultModelForTenant(resolvedType, tenantId, modelId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updatePlatformModelStatus(String type, Long modelId, Integer status, boolean platformAdmin) {
|
||||
String resolvedType = normalizeType(type);
|
||||
if (TYPE_ASR.equals(resolvedType)) {
|
||||
updatePlatformAsrStatus(modelId, status, platformAdmin);
|
||||
return;
|
||||
}
|
||||
if (!platformAdmin) {
|
||||
throw new RuntimeException("无权修改平台级模型状态");
|
||||
}
|
||||
LlmModel entity = llmModelMapper.selectById(modelId);
|
||||
if (entity == null) {
|
||||
throw new RuntimeException("LLM 模型不存在");
|
||||
}
|
||||
if (!Long.valueOf(0L).equals(entity.getTenantId())) {
|
||||
throw new RuntimeException("仅平台级 LLM 支持该操作");
|
||||
}
|
||||
entity.setStatus(status);
|
||||
llmModelMapper.updateById(entity);
|
||||
if (Integer.valueOf(1).equals(status)) {
|
||||
tenantModelActivationService.refreshPlatformInheritanceForLlm(modelId);
|
||||
return;
|
||||
}
|
||||
tenantModelActivationService.disableModelForAllTenants(TYPE_LLM, modelId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey) {
|
||||
try {
|
||||
|
|
@ -765,6 +892,30 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
public AiModelVO getDefaultModel(String type, Long tenantId) {
|
||||
String resolvedType = normalizeType(type);
|
||||
if (TYPE_ASR.equals(resolvedType)) {
|
||||
AiModelVO activeAsr = resolveActiveAsrModel(tenantId);
|
||||
if (activeAsr != null) {
|
||||
return activeAsr;
|
||||
}
|
||||
if (tenantId == null) {
|
||||
AsrModel model = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
|
||||
.eq(AsrModel::getStatus, 1)
|
||||
.eq(AsrModel::getTenantId, 0L)
|
||||
.eq(AsrModel::getIsDefault, 1)
|
||||
.orderByAsc(AsrModel::getSortOrder)
|
||||
.orderByDesc(AsrModel::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
if (model != null) {
|
||||
return toAsrVO(model);
|
||||
}
|
||||
AsrModel firstEnabled = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
|
||||
.eq(AsrModel::getStatus, 1)
|
||||
.eq(AsrModel::getTenantId, 0L)
|
||||
.orderByDesc(AsrModel::getIsDefault)
|
||||
.orderByAsc(AsrModel::getSortOrder)
|
||||
.orderByDesc(AsrModel::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
return firstEnabled == null ? null : toAsrVO(firstEnabled);
|
||||
}
|
||||
AsrModel model = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
|
||||
.eq(AsrModel::getStatus, 1)
|
||||
.eq(AsrModel::getIsDefault, 1)
|
||||
|
|
@ -773,18 +924,52 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
.orderByAsc(AsrModel::getSortOrder)
|
||||
.orderByDesc(AsrModel::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
return model == null ? null : toAsrVO(model);
|
||||
if (model != null) {
|
||||
return toAsrVO(model);
|
||||
}
|
||||
AsrModel firstEnabled = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
|
||||
.eq(AsrModel::getStatus, 1)
|
||||
.and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
|
||||
.orderByDesc(AsrModel::getTenantId)
|
||||
.orderByDesc(AsrModel::getIsDefault)
|
||||
.orderByAsc(AsrModel::getSortOrder)
|
||||
.orderByDesc(AsrModel::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
return firstEnabled == null ? null : toAsrVO(firstEnabled);
|
||||
}
|
||||
|
||||
if (tenantId == null) {
|
||||
LlmModel model = llmModelMapper.selectOne(new LambdaQueryWrapper<LlmModel>()
|
||||
.eq(LlmModel::getStatus, 1)
|
||||
.eq(LlmModel::getIsDefault, 1)
|
||||
.and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L))
|
||||
.orderByDesc(LlmModel::getTenantId)
|
||||
.orderByAsc(LlmModel::getSortOrder)
|
||||
.orderByDesc(LlmModel::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
return model == null ? null : toLlmVO(model);
|
||||
.eq(LlmModel::getStatus, 1)
|
||||
.eq(LlmModel::getIsDefault, 1)
|
||||
.eq(LlmModel::getTenantId, 0L)
|
||||
.orderByAsc(LlmModel::getSortOrder)
|
||||
.orderByDesc(LlmModel::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
if (model != null) {
|
||||
return toLlmVO(model);
|
||||
}
|
||||
LlmModel firstEnabled = llmModelMapper.selectOne(new LambdaQueryWrapper<LlmModel>()
|
||||
.eq(LlmModel::getStatus, 1)
|
||||
.eq(LlmModel::getTenantId, 0L)
|
||||
.orderByDesc(LlmModel::getIsDefault)
|
||||
.orderByAsc(LlmModel::getSortOrder)
|
||||
.orderByDesc(LlmModel::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
return firstEnabled == null ? null : toLlmVO(firstEnabled);
|
||||
}
|
||||
|
||||
AiModelVO tenantDefault = resolveTenantDefaultLlm(tenantId);
|
||||
if (tenantDefault != null) {
|
||||
return tenantDefault;
|
||||
}
|
||||
|
||||
AiModelVO inheritedDefault = findFirstTenantEnabledLlm(tenantId, true);
|
||||
if (inheritedDefault != null) {
|
||||
return inheritedDefault;
|
||||
}
|
||||
|
||||
return findFirstTenantEnabledLlm(tenantId, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -1000,6 +1185,10 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
}
|
||||
|
||||
private AiModelVO toAsrVO(AsrModel entity) {
|
||||
return toAsrVO(entity, null, true);
|
||||
}
|
||||
|
||||
private AiModelVO toAsrVO(AsrModel entity, Long tenantId, boolean platformAdmin) {
|
||||
AiModelVO vo = new AiModelVO();
|
||||
vo.setId(entity.getId());
|
||||
vo.setTenantId(entity.getTenantId());
|
||||
|
|
@ -1013,13 +1202,79 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
vo.setMediaConfig(entity.getMediaConfig());
|
||||
vo.setIsDefault(entity.getIsDefault());
|
||||
vo.setStatus(entity.getStatus());
|
||||
vo.setScope(Long.valueOf(0L).equals(entity.getTenantId()) ? "PLATFORM" : "TENANT");
|
||||
vo.setTenantEnabled(tenantId == null ? 0 : (tenantModelActivationService.isTenantEnabled(TYPE_ASR, tenantId, entity.getId()) ? 1 : 0));
|
||||
vo.setTenantDefault(0);
|
||||
vo.setCanEditConfig(platformAdmin || !Long.valueOf(0L).equals(entity.getTenantId()));
|
||||
vo.setSortOrder(entity.getSortOrder());
|
||||
vo.setRemark(entity.getRemark());
|
||||
vo.setCreatedAt(entity.getCreatedAt());
|
||||
if (!platformAdmin && Long.valueOf(0L).equals(entity.getTenantId())) {
|
||||
vo.setBaseUrl(null);
|
||||
vo.setApiKey(null);
|
||||
vo.setWsUrl(null);
|
||||
vo.setMediaConfig(null);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private AiModelVO toLlmVO(LlmModel entity) {
|
||||
return toLlmVO(entity, null, true);
|
||||
}
|
||||
|
||||
private AiModelVO resolveActiveAsrModel(Long tenantId) {
|
||||
if (tenantId == null || tenantModelActivationService == null) {
|
||||
return null;
|
||||
}
|
||||
Long activeAsrId = tenantModelActivationService.resolveActiveAsrId(tenantId);
|
||||
if (activeAsrId == null) {
|
||||
return null;
|
||||
}
|
||||
AiModelVO activeAsr = getModelById(activeAsrId, TYPE_ASR);
|
||||
if (activeAsr == null || !Integer.valueOf(1).equals(activeAsr.getStatus())) {
|
||||
return null;
|
||||
}
|
||||
return activeAsr;
|
||||
}
|
||||
|
||||
private AiModelVO resolveTenantDefaultLlm(Long tenantId) {
|
||||
if (tenantId == null || tenantModelActivationService == null) {
|
||||
return null;
|
||||
}
|
||||
Long tenantDefaultId = tenantModelActivationService.resolveDefaultModelId(TYPE_LLM, tenantId);
|
||||
if (tenantDefaultId == null || !tenantModelActivationService.isTenantEnabled(TYPE_LLM, tenantId, tenantDefaultId)) {
|
||||
return null;
|
||||
}
|
||||
AiModelVO tenantDefault = getModelById(tenantDefaultId, TYPE_LLM);
|
||||
if (tenantDefault == null || !Integer.valueOf(1).equals(tenantDefault.getStatus())) {
|
||||
return null;
|
||||
}
|
||||
return tenantDefault;
|
||||
}
|
||||
|
||||
private AiModelVO findFirstTenantEnabledLlm(Long tenantId, boolean onlyDefault) {
|
||||
if (tenantId == null || tenantModelActivationService == null) {
|
||||
return null;
|
||||
}
|
||||
LambdaQueryWrapper<LlmModel> wrapper = new LambdaQueryWrapper<LlmModel>()
|
||||
.eq(LlmModel::getStatus, 1)
|
||||
.and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L));
|
||||
if (onlyDefault) {
|
||||
wrapper.eq(LlmModel::getIsDefault, 1);
|
||||
}
|
||||
wrapper.orderByDesc(LlmModel::getTenantId)
|
||||
.orderByDesc(!onlyDefault, LlmModel::getIsDefault)
|
||||
.orderByAsc(LlmModel::getSortOrder)
|
||||
.orderByDesc(LlmModel::getCreatedAt);
|
||||
for (LlmModel candidate : llmModelMapper.selectList(wrapper)) {
|
||||
if (tenantModelActivationService.isTenantEnabled(TYPE_LLM, tenantId, candidate.getId())) {
|
||||
return toLlmVO(candidate);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private AiModelVO toLlmVO(LlmModel entity, Long tenantId, boolean platformAdmin) {
|
||||
AiModelVO vo = new AiModelVO();
|
||||
vo.setId(entity.getId());
|
||||
vo.setTenantId(entity.getTenantId());
|
||||
|
|
@ -1034,11 +1289,49 @@ public class AiModelServiceImpl implements AiModelService {
|
|||
vo.setTopP(entity.getTopP());
|
||||
vo.setIsDefault(entity.getIsDefault());
|
||||
vo.setStatus(entity.getStatus());
|
||||
vo.setScope(Long.valueOf(0L).equals(entity.getTenantId()) ? "PLATFORM" : "TENANT");
|
||||
vo.setTenantEnabled(tenantId == null ? 0 : (tenantModelActivationService.isTenantEnabled(TYPE_LLM, tenantId, entity.getId()) ? 1 : 0));
|
||||
Long tenantDefaultId = tenantId == null ? null : tenantModelActivationService.resolveDefaultModelId(TYPE_LLM, tenantId);
|
||||
vo.setTenantDefault(tenantDefaultId != null && tenantDefaultId.equals(entity.getId()) ? 1 : 0);
|
||||
vo.setCanEditConfig(platformAdmin || !Long.valueOf(0L).equals(entity.getTenantId()));
|
||||
vo.setSortOrder(entity.getSortOrder());
|
||||
vo.setRemark(entity.getRemark());
|
||||
vo.setCreatedAt(entity.getCreatedAt());
|
||||
return vo;
|
||||
if (!platformAdmin && Long.valueOf(0L).equals(entity.getTenantId())) {
|
||||
vo.setBaseUrl(null);
|
||||
vo.setApiKey(null);
|
||||
vo.setApiPath(null);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private boolean isVisibleForTenantEnabledOnly(AiModelVO vo, boolean tenantEnabledOnly, boolean platformAdmin) {
|
||||
if (!tenantEnabledOnly || platformAdmin) {
|
||||
return true;
|
||||
}
|
||||
return Integer.valueOf(1).equals(vo.getTenantEnabled());
|
||||
}
|
||||
|
||||
private List<Long> resolveTenantEnabledModelIds(String modelType, Long tenantId, boolean platformAdmin, boolean tenantEnabledOnly) {
|
||||
if (!tenantEnabledOnly || platformAdmin || tenantModelActivationService == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return tenantModelActivationService.listEnabledModelIds(modelType, tenantId);
|
||||
}
|
||||
|
||||
private PageResult<List<AiModelVO>> emptyModelPage() {
|
||||
PageResult<List<AiModelVO>> result = new PageResult<>();
|
||||
result.setTotal(0L);
|
||||
result.setRecords(Collections.emptyList());
|
||||
return result;
|
||||
}
|
||||
|
||||
private void assertModelEnabled(Long modelId, String type) {
|
||||
AiModelVO model = getModelById(modelId, type);
|
||||
if (model == null || !Integer.valueOf(1).equals(model.getStatus())) {
|
||||
throw new RuntimeException(type + " 模型不存在或未启用");
|
||||
}
|
||||
}
|
||||
|
||||
private Integer normalizeSortOrder(Integer sortOrder) {
|
||||
return sortOrder == null ? DEFAULT_SORT_ORDER : sortOrder;
|
||||
|
|
|
|||
|
|
@ -1278,7 +1278,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
log.info("LLM summary response status={}, body={}", response.statusCode(), response.body());
|
||||
JsonNode respNode = objectMapper.readTree(response.body());
|
||||
|
||||
taskRecord.setResponseData(objectMapper.convertValue(respNode, Map.class));
|
||||
if (response.statusCode() == 200 && respNode.has("choices")) {
|
||||
String content = sanitizeSummaryContent(respNode.path("choices").path(0).path("message").path("content").asText());
|
||||
Map<String, Object> summaryBundle = meetingSummaryFileService.parseSummaryBundle(content);
|
||||
|
|
|
|||
|
|
@ -6,10 +6,27 @@ import com.imeeting.entity.biz.Meeting;
|
|||
import com.imeeting.service.biz.MeetingExportService;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
||||
import com.unisbase.dto.SysOrgDTO;
|
||||
import com.unisbase.dto.SysTenantDTO;
|
||||
import com.unisbase.dto.SysTenantUserDTO;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import com.unisbase.service.SysOrgService;
|
||||
import com.unisbase.service.SysTenantUserService;
|
||||
import com.unisbase.service.TenantManagementService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.pdfbox.pdmodel.graphics.blend.BlendMode;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFRun;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.commonmark.node.Node;
|
||||
import org.commonmark.parser.Parser;
|
||||
import org.commonmark.renderer.html.HtmlRenderer;
|
||||
|
|
@ -25,8 +42,12 @@ import java.nio.charset.StandardCharsets;
|
|||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
|
@ -34,13 +55,24 @@ import java.util.regex.Pattern;
|
|||
@RequiredArgsConstructor
|
||||
public class MeetingExportServiceImpl implements MeetingExportService {
|
||||
|
||||
private static final DateTimeFormatter WATERMARK_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
private static final float WATERMARK_FONT_SIZE = 36f;
|
||||
private static final float WATERMARK_ALPHA = 0.16f;
|
||||
private static final float WATERMARK_ANGLE = (float) Math.toRadians(30);
|
||||
private static final float WATERMARK_LINE_GAP = 34f;
|
||||
private static final float WATERMARK_BASE_STEP_X = 430f;
|
||||
private static final float WATERMARK_BASE_STEP_Y = 300f;
|
||||
|
||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||
private final SysTenantUserService sysTenantUserService;
|
||||
private final SysOrgService sysOrgService;
|
||||
private final TenantManagementService tenantManagementService;
|
||||
|
||||
@Value("${unisbase.app.upload-path}")
|
||||
private String uploadPath;
|
||||
|
||||
@Override
|
||||
public MeetingSummaryExportResult exportSummary(Meeting meeting, MeetingVO meetingDetail, String format) {
|
||||
public MeetingSummaryExportResult exportSummary(Meeting meeting, MeetingVO meetingDetail, String format, LoginUser loginUser) {
|
||||
Path summarySourcePath = meetingSummaryFileService.requireSummarySourcePath(meeting);
|
||||
String safeTitle = (meetingDetail.getTitle() == null || meetingDetail.getTitle().trim().isEmpty())
|
||||
? "meeting-summary-" + meeting.getId()
|
||||
|
|
@ -64,19 +96,24 @@ public class MeetingExportServiceImpl implements MeetingExportService {
|
|||
Files.createDirectories(exportDir);
|
||||
|
||||
Path exportPath = exportDir.resolve("summary." + ext);
|
||||
boolean isPdf = "pdf".equals(ext);
|
||||
boolean needRegenerate = !Files.exists(exportPath)
|
||||
|| Files.getLastModifiedTime(exportPath).toMillis() < Files.getLastModifiedTime(summarySourcePath).toMillis();
|
||||
|
||||
byte[] bytes;
|
||||
byte[] baseBytes;
|
||||
if (needRegenerate) {
|
||||
String markdown = Files.readString(summarySourcePath, StandardCharsets.UTF_8);
|
||||
meetingDetail.setSummaryContent(meetingSummaryFileService.stripFrontMatter(markdown));
|
||||
bytes = "docx".equals(ext) ? buildWordBytes(meetingDetail) : buildPdfBytes(meetingDetail);
|
||||
Files.write(exportPath, bytes);
|
||||
baseBytes = "docx".equals(ext) ? buildWordBytes(meetingDetail) : buildPdfBytes(meetingDetail);
|
||||
Files.write(exportPath, baseBytes);
|
||||
} else {
|
||||
bytes = Files.readAllBytes(exportPath);
|
||||
baseBytes = Files.readAllBytes(exportPath);
|
||||
}
|
||||
|
||||
byte[] bytes = isPdf
|
||||
? applyPdfWatermark(baseBytes, resolveWatermarkText(loginUser))
|
||||
: baseBytes;
|
||||
|
||||
return new MeetingSummaryExportResult(bytes, contentType, safeTitle + "-AI-Summary." + ext);
|
||||
} catch (IOException ex) {
|
||||
throw new RuntimeException("导出失败:" + ex.getMessage(), ex);
|
||||
|
|
@ -198,6 +235,190 @@ public class MeetingExportServiceImpl implements MeetingExportService {
|
|||
}
|
||||
}
|
||||
|
||||
private byte[] applyPdfWatermark(byte[] pdfBytes, String watermarkText) throws IOException {
|
||||
if (watermarkText == null || watermarkText.isBlank()) {
|
||||
return pdfBytes;
|
||||
}
|
||||
try (PDDocument document = PDDocument.load(pdfBytes);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
PDFont font = loadWatermarkFont(document);
|
||||
if (font == null) {
|
||||
return pdfBytes;
|
||||
}
|
||||
|
||||
for (PDPage page : document.getPages()) {
|
||||
writeWatermarkGrid(document, page, font, watermarkText);
|
||||
}
|
||||
|
||||
document.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void writeWatermarkGrid(PDDocument document, PDPage page, PDFont font, String watermarkText) throws IOException {
|
||||
PDRectangle box = page.getMediaBox();
|
||||
float width = box.getWidth();
|
||||
float height = box.getHeight();
|
||||
List<String> lines = watermarkLines(watermarkText);
|
||||
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
AppendMode.APPEND,
|
||||
true,
|
||||
true
|
||||
)) {
|
||||
contentStream.saveGraphicsState();
|
||||
|
||||
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
|
||||
graphicsState.setNonStrokingAlphaConstant(WATERMARK_ALPHA);
|
||||
graphicsState.setBlendMode(BlendMode.MULTIPLY);
|
||||
|
||||
contentStream.setGraphicsStateParameters(graphicsState);
|
||||
contentStream.setNonStrokingColor(80, 80, 80);
|
||||
contentStream.setFont(font, WATERMARK_FONT_SIZE);
|
||||
|
||||
// 保持整体排布可读,同时用轻微错位破坏完全规则的网格特征。
|
||||
int rowIndex = 0;
|
||||
for (float y = -height * 0.25f; y < height * 1.35f; y += irregularStepY(rowIndex++)) {
|
||||
float rowOffset = rowIndex % 2 == 0 ? 0 : WATERMARK_BASE_STEP_X * 0.34f;
|
||||
int columnIndex = 0;
|
||||
for (float x = -width * 0.45f + rowOffset; x < width * 1.45f; x += irregularStepX(rowIndex, columnIndex++)) {
|
||||
float xOffset = irregularOffset(rowIndex * 31 + columnIndex * 17, WATERMARK_BASE_STEP_X * 0.05f);
|
||||
float yOffset = irregularOffset(rowIndex * 19 + columnIndex * 23, WATERMARK_BASE_STEP_Y * 0.04f);
|
||||
contentStream.beginText();
|
||||
contentStream.setTextMatrix(Matrix.getRotateInstance(WATERMARK_ANGLE, x + xOffset, y + yOffset));
|
||||
contentStream.showText(lines.get(0));
|
||||
if (lines.size() > 1) {
|
||||
contentStream.newLineAtOffset(0, -WATERMARK_LINE_GAP);
|
||||
contentStream.showText(lines.get(1));
|
||||
}
|
||||
contentStream.endText();
|
||||
}
|
||||
}
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
}
|
||||
|
||||
private float irregularStepX(int rowIndex, int columnIndex) {
|
||||
return WATERMARK_BASE_STEP_X + irregularOffset(rowIndex * 13 + columnIndex * 29, 24f);
|
||||
}
|
||||
|
||||
private float irregularStepY(int rowIndex) {
|
||||
return WATERMARK_BASE_STEP_Y + irregularOffset(rowIndex * 37, 18f);
|
||||
}
|
||||
|
||||
private float irregularOffset(int seed, float amplitude) {
|
||||
int normalized = Math.floorMod(seed * 1103515245 + 12345, 1000);
|
||||
return ((normalized / 1000f) - 0.5f) * 2f * amplitude;
|
||||
}
|
||||
|
||||
private List<String> watermarkLines(String watermarkText) {
|
||||
String[] parts = watermarkText == null ? new String[0] : watermarkText.split("\\R", 2);
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (String part : parts) {
|
||||
if (part != null && !part.isBlank()) {
|
||||
lines.add(part.trim());
|
||||
}
|
||||
}
|
||||
return lines.isEmpty() ? List.of("内部资料请勿外传") : lines;
|
||||
}
|
||||
|
||||
private PDFont loadWatermarkFont(PDDocument document) throws IOException {
|
||||
java.io.InputStream notoStream = getClass().getResourceAsStream("/fonts/NotoSansSC-VF.ttf");
|
||||
if (notoStream != null) {
|
||||
try (java.io.InputStream fontStream = notoStream) {
|
||||
return PDType0Font.load(document, fontStream, true);
|
||||
}
|
||||
}
|
||||
java.io.InputStream simsunStream = getClass().getResourceAsStream("/fonts/simsunb.ttf");
|
||||
if (simsunStream != null) {
|
||||
try (java.io.InputStream fontStream = simsunStream) {
|
||||
return PDType0Font.load(document, fontStream, true);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String resolveWatermarkText(LoginUser loginUser) {
|
||||
String scope = resolveOrgPath(loginUser);
|
||||
if (scope == null || scope.isBlank()) {
|
||||
scope = resolveTenantName(loginUser);
|
||||
}
|
||||
String userName = resolveWatermarkUserName(loginUser);
|
||||
String exportedAt = LocalDateTime.now().format(WATERMARK_TIME_FORMATTER);
|
||||
String scopeText = scope == null || scope.isBlank() ? "内部资料" : scope.trim();
|
||||
// return scopeText + " 内部资料请勿外传\n" + userName + " " + exportedAt;
|
||||
return scopeText + " 内部资料请勿外传";
|
||||
}
|
||||
|
||||
private String resolveWatermarkUserName(LoginUser loginUser) {
|
||||
if (loginUser == null) {
|
||||
return "未知用户";
|
||||
}
|
||||
if (loginUser.getDisplayName() != null && !loginUser.getDisplayName().isBlank()) {
|
||||
return loginUser.getDisplayName().trim();
|
||||
}
|
||||
if (loginUser.getUsername() != null && !loginUser.getUsername().isBlank()) {
|
||||
return loginUser.getUsername().trim();
|
||||
}
|
||||
return loginUser.getUserId() == null ? "未知用户" : "用户" + loginUser.getUserId();
|
||||
}
|
||||
|
||||
private String resolveOrgPath(LoginUser loginUser) {
|
||||
if (loginUser == null || loginUser.getUserId() == null || loginUser.getTenantId() == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
SysTenantUserDTO membership = sysTenantUserService.listByUserId(loginUser.getUserId()).stream()
|
||||
.filter(item -> loginUser.getTenantId().equals(item.getTenantId()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (membership == null) {
|
||||
return null;
|
||||
}
|
||||
if (membership.getOrgId() == null) {
|
||||
return membership.getOrgName();
|
||||
}
|
||||
|
||||
Map<Long, SysOrgDTO> orgMap = new HashMap<>();
|
||||
for (SysOrgDTO org : sysOrgService.listTree(loginUser.getTenantId())) {
|
||||
if (org != null && org.getId() != null) {
|
||||
orgMap.put(org.getId(), org);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> names = new ArrayList<>();
|
||||
SysOrgDTO current = orgMap.get(membership.getOrgId());
|
||||
while (current != null) {
|
||||
if (current.getOrgName() != null && !current.getOrgName().isBlank()) {
|
||||
names.add(0, current.getOrgName().trim());
|
||||
}
|
||||
Long parentId = current.getParentId();
|
||||
if (parentId == null || parentId <= 0 || parentId.equals(current.getId())) {
|
||||
break;
|
||||
}
|
||||
current = orgMap.get(parentId);
|
||||
}
|
||||
return names.isEmpty() ? membership.getOrgName() : String.join("/", names);
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveTenantName(LoginUser loginUser) {
|
||||
if (loginUser == null || loginUser.getTenantId() == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
SysTenantDTO tenant = tenantManagementService.getTenant(loginUser.getTenantId());
|
||||
return tenant == null ? null : tenant.getTenantName();
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void appendMarkdownRuns(XWPFParagraph paragraph, String text, boolean defaultBold, int size) {
|
||||
String input = text == null ? "" : text;
|
||||
Matcher matcher = Pattern.compile("\\*\\*(.+?)\\*\\*").matcher(input);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.entity.biz.Speaker;
|
||||
import com.imeeting.entity.biz.SpeakerAsrSync;
|
||||
import com.imeeting.service.biz.SpeakerAsrGatewayService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SpeakerAsrGatewayServiceImpl implements SpeakerAsrGatewayService {
|
||||
|
||||
@Value("${unisbase.app.server-base-url}")
|
||||
private String serverBaseUrl;
|
||||
|
||||
@Value("${unisbase.app.resource-prefix}")
|
||||
private String resourcePrefix;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
public SpeakerAsrGatewayServiceImpl(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String registerSpeaker(Speaker speaker, AiModelVO asrModel) {
|
||||
if (asrModel == null || asrModel.getBaseUrl() == null || asrModel.getBaseUrl().isBlank()) {
|
||||
throw new RuntimeException("当前 ASR 未配置 baseUrl");
|
||||
}
|
||||
if (speaker == null || speaker.getVoicePath() == null || speaker.getVoicePath().isBlank()) {
|
||||
throw new RuntimeException("声纹样本不存在");
|
||||
}
|
||||
try {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("name", speaker.getName());
|
||||
if (speaker.getUserId() != null) {
|
||||
body.put("user_id", String.valueOf(speaker.getUserId()));
|
||||
}
|
||||
body.put("audio_address", buildFileUrl(speaker.getVoicePath()));
|
||||
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(appendPath(asrModel.getBaseUrl(), "api/v1/speakers")))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body), StandardCharsets.UTF_8));
|
||||
applyAuthHeader(requestBuilder, asrModel);
|
||||
|
||||
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new RuntimeException("外部声纹注册失败: HTTP " + response.statusCode());
|
||||
}
|
||||
return readSpeakerId(response.body());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("外部声纹注册失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteSpeaker(SpeakerAsrSync snapshot, AiModelVO asrModel) {
|
||||
if (snapshot == null || snapshot.getExternalSpeakerId() == null || snapshot.getExternalSpeakerId().isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (asrModel == null || asrModel.getBaseUrl() == null || asrModel.getBaseUrl().isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(appendPath(asrModel.getBaseUrl(), "api/v1/speakers/" + snapshot.getExternalSpeakerId())))
|
||||
.DELETE();
|
||||
applyAuthHeader(requestBuilder, asrModel);
|
||||
|
||||
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new RuntimeException("外部声纹删除失败: HTTP " + response.statusCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("外部声纹删除失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyAuthHeader(HttpRequest.Builder requestBuilder, AiModelVO asrModel) {
|
||||
if (asrModel.getApiKey() != null && !asrModel.getApiKey().isBlank()) {
|
||||
requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey());
|
||||
}
|
||||
}
|
||||
|
||||
private String buildFileUrl(String voicePath) {
|
||||
String fullPath = serverBaseUrl;
|
||||
if (!fullPath.endsWith("/") && !resourcePrefix.startsWith("/")) {
|
||||
fullPath += "/";
|
||||
}
|
||||
fullPath += resourcePrefix;
|
||||
if (!fullPath.endsWith("/") && !voicePath.startsWith("/")) {
|
||||
fullPath += "/";
|
||||
}
|
||||
return fullPath + voicePath;
|
||||
}
|
||||
|
||||
private String appendPath(String baseUrl, String path) {
|
||||
return baseUrl.endsWith("/") ? baseUrl + path : baseUrl + "/" + path;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String readSpeakerId(String responseBody) {
|
||||
try {
|
||||
Map<String, Object> body = objectMapper.readValue(responseBody, Map.class);
|
||||
Object speakerId = body.get("speaker_id");
|
||||
if (speakerId == null) {
|
||||
speakerId = body.get("id");
|
||||
}
|
||||
if (speakerId != null) {
|
||||
return String.valueOf(speakerId);
|
||||
}
|
||||
Object data = body.get("data");
|
||||
if (data instanceof Map<?, ?> dataMap) {
|
||||
Object nestedSpeakerId = ((Map<String, Object>) dataMap).get("speaker_id");
|
||||
if (nestedSpeakerId == null) {
|
||||
nestedSpeakerId = ((Map<String, Object>) dataMap).get("id");
|
||||
}
|
||||
if (nestedSpeakerId != null) {
|
||||
return String.valueOf(nestedSpeakerId);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.warn("Parse external speaker id failed, body={}", responseBody, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.entity.biz.AsrModel;
|
||||
import com.imeeting.entity.biz.Speaker;
|
||||
import com.imeeting.entity.biz.SpeakerAsrSync;
|
||||
import com.imeeting.enums.SpeakerAsrSyncStatusEnum;
|
||||
import com.imeeting.mapper.biz.AsrModelMapper;
|
||||
import com.imeeting.mapper.biz.SpeakerAsrSyncMapper;
|
||||
import com.imeeting.mapper.biz.SpeakerMapper;
|
||||
import com.imeeting.service.biz.SpeakerAsrGatewayService;
|
||||
import com.imeeting.service.biz.SpeakerAsrSyncService;
|
||||
import com.imeeting.service.biz.TenantModelActivationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SpeakerAsrSyncServiceImpl implements SpeakerAsrSyncService {
|
||||
|
||||
private final SpeakerAsrSyncMapper syncMapper;
|
||||
private final SpeakerMapper speakerMapper;
|
||||
private final AsrModelMapper asrModelMapper;
|
||||
private final TenantModelActivationService activationService;
|
||||
private final SpeakerAsrGatewayService gatewayService;
|
||||
@Qualifier("asrTaskExecutor")
|
||||
private final Executor asrTaskExecutor;
|
||||
|
||||
@Override
|
||||
public void queueSyncForCurrentAsr(Long tenantId, Long speakerId) {
|
||||
Long asrModelId = activationService.resolveActiveAsrId(tenantId);
|
||||
if (asrModelId == null || speakerId == null) {
|
||||
return;
|
||||
}
|
||||
asrTaskExecutor.execute(() -> syncSpeakerToAsr(tenantId, speakerId, asrModelId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void queueCatchUpForCurrentAsr(Long tenantId) {
|
||||
Long asrModelId = activationService.resolveActiveAsrId(tenantId);
|
||||
if (tenantId == null || asrModelId == null) {
|
||||
return;
|
||||
}
|
||||
asrTaskExecutor.execute(() -> {
|
||||
List<Speaker> speakers = speakerMapper.selectList(new QueryWrapper<Speaker>()
|
||||
.eq("tenant_id", tenantId)
|
||||
.eq("is_deleted", 0));
|
||||
for (Speaker speaker : speakers) {
|
||||
syncSpeakerToAsr(tenantId, speaker.getId(), asrModelId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidateOtherAsrSnapshots(Long tenantId, Long speakerId, Long keepAsrModelId) {
|
||||
if (tenantId == null || speakerId == null) {
|
||||
return;
|
||||
}
|
||||
List<SpeakerAsrSync> snapshots = syncMapper.selectList(new QueryWrapper<SpeakerAsrSync>()
|
||||
.eq("tenant_id", tenantId)
|
||||
.eq("speaker_id", speakerId)
|
||||
.eq("is_deleted", 0));
|
||||
for (SpeakerAsrSync snapshot : snapshots) {
|
||||
if (keepAsrModelId != null && keepAsrModelId.equals(snapshot.getAsrModelId())) {
|
||||
continue;
|
||||
}
|
||||
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.STALE.name());
|
||||
snapshot.setLastErrorMessage(null);
|
||||
syncMapper.updateById(snapshot);
|
||||
Long snapshotId = snapshot.getId();
|
||||
if (snapshotId != null) {
|
||||
deleteStaleSnapshot(snapshotId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void syncSpeakerToAsr(Long tenantId, Long speakerId, Long asrModelId) {
|
||||
Speaker speaker = speakerMapper.selectById(speakerId);
|
||||
if (speaker == null || asrModelId == null) {
|
||||
return;
|
||||
}
|
||||
SpeakerAsrSync snapshot = findSnapshot(speakerId, asrModelId);
|
||||
if (snapshot != null
|
||||
&& SpeakerAsrSyncStatusEnum.SYNCED.name().equals(snapshot.getSyncStatus())
|
||||
&& speaker.getVersion() != null
|
||||
&& speaker.getVersion().equals(snapshot.getSpeakerVersion())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot == null) {
|
||||
snapshot = new SpeakerAsrSync();
|
||||
snapshot.setTenantId(tenantId);
|
||||
snapshot.setSpeakerId(speakerId);
|
||||
snapshot.setAsrModelId(asrModelId);
|
||||
}
|
||||
boolean needsDeleteFirst = SpeakerAsrSyncStatusEnum.STALE.name().equals(snapshot.getSyncStatus())
|
||||
&& snapshot.getExternalSpeakerId() != null
|
||||
&& !snapshot.getExternalSpeakerId().isBlank();
|
||||
|
||||
try {
|
||||
AiModelVO asrModel = loadAsrModel(asrModelId);
|
||||
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.SYNCING.name());
|
||||
snapshot.setLastSyncBatchId(UUID.randomUUID().toString());
|
||||
saveSnapshot(snapshot);
|
||||
|
||||
if (needsDeleteFirst) {
|
||||
gatewayService.deleteSpeaker(snapshot, asrModel);
|
||||
snapshot.setExternalSpeakerId(null);
|
||||
}
|
||||
|
||||
String externalSpeakerId = gatewayService.registerSpeaker(speaker, asrModel);
|
||||
snapshot.setSpeakerVersion(speaker.getVersion());
|
||||
snapshot.setExternalSpeakerId(externalSpeakerId);
|
||||
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.SYNCED.name());
|
||||
snapshot.setLastSyncedAt(LocalDateTime.now());
|
||||
snapshot.setLastErrorMessage(null);
|
||||
saveSnapshot(snapshot);
|
||||
} catch (Exception e) {
|
||||
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.FAILED.name());
|
||||
snapshot.setLastErrorMessage(e.getMessage());
|
||||
saveSnapshot(snapshot);
|
||||
log.warn("Sync speaker to asr failed, tenantId={}, speakerId={}, asrModelId={}", tenantId, speakerId, asrModelId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteStaleSnapshot(Long snapshotId) {
|
||||
SpeakerAsrSync snapshot = syncMapper.selectById(snapshotId);
|
||||
if (snapshot == null || !SpeakerAsrSyncStatusEnum.STALE.name().equals(snapshot.getSyncStatus())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (snapshot.getExternalSpeakerId() != null && !snapshot.getExternalSpeakerId().isBlank()) {
|
||||
AiModelVO asrModel = loadAsrModel(snapshot.getAsrModelId());
|
||||
gatewayService.deleteSpeaker(snapshot, asrModel);
|
||||
}
|
||||
snapshot.setExternalSpeakerId(null);
|
||||
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.DELETED.name());
|
||||
snapshot.setLastErrorMessage(null);
|
||||
saveSnapshot(snapshot);
|
||||
} catch (Exception e) {
|
||||
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.FAILED.name());
|
||||
snapshot.setLastErrorMessage(e.getMessage());
|
||||
saveSnapshot(snapshot);
|
||||
log.warn("Delete stale speaker snapshot failed, snapshotId={}", snapshot.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private SpeakerAsrSync findSnapshot(Long speakerId, Long asrModelId) {
|
||||
return syncMapper.selectOne(new QueryWrapper<SpeakerAsrSync>()
|
||||
.eq("speaker_id", speakerId)
|
||||
.eq("asr_model_id", asrModelId)
|
||||
.eq("is_deleted", 0)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private AiModelVO loadAsrModel(Long asrModelId) {
|
||||
AsrModel entity = asrModelMapper.selectById(asrModelId);
|
||||
if (entity == null) {
|
||||
throw new RuntimeException("ASR 模型不存在");
|
||||
}
|
||||
AiModelVO model = new AiModelVO();
|
||||
model.setId(entity.getId());
|
||||
model.setTenantId(entity.getTenantId());
|
||||
model.setModelType("ASR");
|
||||
model.setModelName(entity.getModelName());
|
||||
model.setProvider(entity.getProvider());
|
||||
model.setBaseUrl(entity.getBaseUrl());
|
||||
model.setApiKey(entity.getApiKey());
|
||||
model.setModelCode(entity.getModelCode());
|
||||
model.setWsUrl(entity.getWsUrl());
|
||||
model.setMediaConfig(entity.getMediaConfig());
|
||||
model.setIsDefault(entity.getIsDefault());
|
||||
model.setSortOrder(entity.getSortOrder());
|
||||
model.setRemark(entity.getRemark());
|
||||
model.setCreatedAt(entity.getCreatedAt());
|
||||
return model;
|
||||
}
|
||||
|
||||
private void saveSnapshot(SpeakerAsrSync snapshot) {
|
||||
if (snapshot.getId() == null) {
|
||||
syncMapper.insert(snapshot);
|
||||
return;
|
||||
}
|
||||
syncMapper.updateById(snapshot);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
|
@ -7,13 +8,16 @@ import com.imeeting.dto.biz.AiModelVO;
|
|||
import com.imeeting.dto.biz.SpeakerRegisterDTO;
|
||||
import com.imeeting.dto.biz.SpeakerVO;
|
||||
import com.imeeting.entity.biz.Speaker;
|
||||
import com.imeeting.entity.biz.SpeakerAsrSync;
|
||||
import com.imeeting.enums.SpeakerAsrSyncStatusEnum;
|
||||
import com.imeeting.mapper.biz.SpeakerAsrSyncMapper;
|
||||
import com.imeeting.mapper.biz.SpeakerMapper;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.SpeakerService;
|
||||
import com.imeeting.service.biz.*;
|
||||
import com.unisbase.annotation.DataScope;
|
||||
import com.unisbase.dto.PageResult;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
|
@ -31,10 +35,14 @@ import java.nio.file.Paths;
|
|||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
|
|
@ -51,6 +59,12 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
|||
|
||||
private final AiModelService aiModelService;
|
||||
private final ObjectMapper objectMapper;
|
||||
@Autowired
|
||||
private SpeakerAsrSyncMapper speakerAsrSyncMapper;
|
||||
@Autowired
|
||||
private TenantModelActivationService tenantAsrActivationService;
|
||||
@Autowired
|
||||
private SpeakerAsrSyncService speakerAsrSyncService;
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
|
|
@ -73,6 +87,7 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
|||
|
||||
boolean admin = isAdmin(loginUser);
|
||||
Speaker speaker = prepareSpeaker(registerDTO, loginUser, admin);
|
||||
Long previousVersion = speaker.getVersion();
|
||||
String normalizedName = registerDTO.getName().trim();
|
||||
validateDuplicateName(loginUser.getTenantId(), normalizedName, speaker.getId());
|
||||
|
||||
|
|
@ -83,6 +98,9 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
|||
}
|
||||
|
||||
speaker.setTenantId(loginUser.getTenantId());
|
||||
Long finalUserId = !admin ? loginUser.getUserId() : registerDTO.getUserId();
|
||||
boolean contentChanged = speaker.getId() != null
|
||||
&& speakerChanged(speaker, normalizedName, finalUserId, registerDTO.getRemark(), file);
|
||||
speaker.setName(normalizedName);
|
||||
speaker.setRemark(registerDTO.getRemark());
|
||||
if (speaker.getId() == null) {
|
||||
|
|
@ -99,13 +117,22 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
|||
if (file != null && !file.isEmpty()) {
|
||||
saveVoiceFile(speaker, file);
|
||||
}
|
||||
if (speaker.getId() == null) {
|
||||
speaker.setVersion(1L);
|
||||
} else if (contentChanged) {
|
||||
speaker.setVersion(previousVersion == null ? 1L : previousVersion + 1);
|
||||
} else if (speaker.getVersion() == null) {
|
||||
speaker.setVersion(1L);
|
||||
}
|
||||
|
||||
speaker.setStatus(1);
|
||||
speaker.setUpdatedAt(LocalDateTime.now());
|
||||
this.saveOrUpdate(speaker);
|
||||
|
||||
syncExternalVoiceprint(speaker, loginUser);
|
||||
return toVO(speaker);
|
||||
Long activeAsrId = tenantAsrActivationService.resolveActiveAsrId(loginUser.getTenantId());
|
||||
speakerAsrSyncService.invalidateOtherAsrSnapshots(loginUser.getTenantId(), speaker.getId(), activeAsrId);
|
||||
speakerAsrSyncService.queueSyncForCurrentAsr(loginUser.getTenantId(), speaker.getId());
|
||||
return toVO(speaker, null, activeAsrId);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -120,9 +147,11 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
|||
.orderByDesc(Speaker::getUpdatedAt)
|
||||
.page(new Page<>(current, size));
|
||||
|
||||
Long activeAsrId = tenantAsrActivationService.resolveActiveAsrId(loginUser.getTenantId());
|
||||
Map<Long, SpeakerAsrSync> syncSnapshotMap = loadSyncSnapshotMap(loginUser.getTenantId(), activeAsrId, page.getRecords());
|
||||
List<SpeakerVO> records = new ArrayList<>(page.getRecords().size());
|
||||
for (Speaker speaker : page.getRecords()) {
|
||||
records.add(toVO(speaker));
|
||||
records.add(toVO(speaker, syncSnapshotMap.get(speaker.getId()), activeAsrId));
|
||||
}
|
||||
|
||||
PageResult<List<SpeakerVO>> result = new PageResult<>();
|
||||
|
|
@ -139,13 +168,25 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
|||
.eq(!admin, Speaker::getUserId, loginUser.getUserId())
|
||||
.orderByDesc(Speaker::getUpdatedAt)
|
||||
.list();
|
||||
Long activeAsrId = tenantAsrActivationService.resolveActiveAsrId(loginUser.getTenantId());
|
||||
Map<Long, SpeakerAsrSync> syncSnapshotMap = loadSyncSnapshotMap(loginUser.getTenantId(), activeAsrId, list);
|
||||
List<SpeakerVO> vos = new ArrayList<>(list.size());
|
||||
for (Speaker speaker : list) {
|
||||
vos.add(toVO(speaker));
|
||||
vos.add(toVO(speaker, syncSnapshotMap.get(speaker.getId()), activeAsrId));
|
||||
}
|
||||
return vos;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void syncCurrentAsr(Long id, LoginUser loginUser) {
|
||||
Speaker speaker = getSpeakerForWrite(id, loginUser);
|
||||
Long activeAsrId = tenantAsrActivationService.resolveActiveAsrId(loginUser.getTenantId());
|
||||
if (activeAsrId == null) {
|
||||
throw new RuntimeException("当前未启用 ASR,无法同步声纹");
|
||||
}
|
||||
speakerAsrSyncService.queueSyncForCurrentAsr(loginUser.getTenantId(), speaker.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteSpeaker(Long id, LoginUser loginUser) {
|
||||
|
|
@ -154,6 +195,23 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
|||
this.removeById(id);
|
||||
}
|
||||
|
||||
private boolean speakerChanged(Speaker existing,
|
||||
String normalizedName,
|
||||
Long requestedUserId,
|
||||
String requestedRemark,
|
||||
MultipartFile file) {
|
||||
if (!Objects.equals(existing.getName(), normalizedName)) {
|
||||
return true;
|
||||
}
|
||||
if (!Objects.equals(existing.getUserId(), requestedUserId)) {
|
||||
return true;
|
||||
}
|
||||
if (!Objects.equals(existing.getRemark(), requestedRemark)) {
|
||||
return true;
|
||||
}
|
||||
return file != null && !file.isEmpty();
|
||||
}
|
||||
|
||||
private Speaker prepareSpeaker(SpeakerRegisterDTO registerDTO, LoginUser loginUser, boolean admin) {
|
||||
if (registerDTO.getId() != null) {
|
||||
return getSpeakerForWrite(registerDTO.getId(), loginUser);
|
||||
|
|
@ -360,7 +418,27 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
|||
return Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
|
||||
}
|
||||
|
||||
private SpeakerVO toVO(Speaker speaker) {
|
||||
private Map<Long, SpeakerAsrSync> loadSyncSnapshotMap(Long tenantId, Long activeAsrId, List<Speaker> speakers) {
|
||||
if (tenantId == null || activeAsrId == null || speakers == null || speakers.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<Long> speakerIds = speakers.stream()
|
||||
.map(Speaker::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
if (speakerIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return speakerAsrSyncMapper.selectList(new QueryWrapper<SpeakerAsrSync>()
|
||||
.eq("tenant_id", tenantId)
|
||||
.eq("asr_model_id", activeAsrId)
|
||||
.in("speaker_id", speakerIds)
|
||||
.eq("is_deleted", 0))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(SpeakerAsrSync::getSpeakerId, Function.identity(), (left, right) -> right));
|
||||
}
|
||||
|
||||
private SpeakerVO toVO(Speaker speaker, SpeakerAsrSync snapshot, Long activeAsrId) {
|
||||
SpeakerVO vo = new SpeakerVO();
|
||||
vo.setId(speaker.getId());
|
||||
vo.setTenantId(speaker.getTenantId());
|
||||
|
|
@ -372,6 +450,8 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
|||
vo.setVoiceExt(speaker.getVoiceExt());
|
||||
vo.setVoiceSize(speaker.getVoiceSize());
|
||||
vo.setStatus(speaker.getStatus());
|
||||
vo.setSyncStatus(snapshot == null ? (activeAsrId == null ? null : SpeakerAsrSyncStatusEnum.PENDING.name()) : snapshot.getSyncStatus());
|
||||
vo.setSyncErrorMessage(snapshot == null ? null : snapshot.getLastErrorMessage());
|
||||
vo.setRemark(speaker.getRemark());
|
||||
vo.setCreatedAt(speaker.getCreatedAt());
|
||||
vo.setUpdatedAt(speaker.getUpdatedAt());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,252 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.imeeting.entity.biz.TenantModelActivation;
|
||||
import com.imeeting.mapper.biz.TenantModelActivationMapper;
|
||||
import com.imeeting.service.biz.TenantModelActivationService;
|
||||
import com.unisbase.dto.PageResult;
|
||||
import com.unisbase.dto.SysTenantDTO;
|
||||
import com.unisbase.service.TenantManagementService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TenantModelActivationServiceImpl implements TenantModelActivationService {
|
||||
|
||||
private static final String TYPE_ASR = "ASR";
|
||||
private static final String TYPE_LLM = "LLM";
|
||||
|
||||
private final TenantModelActivationMapper activationMapper;
|
||||
@Autowired(required = false)
|
||||
private TenantManagementService tenantManagementService;
|
||||
|
||||
@Override
|
||||
public Long resolveActiveAsrId(Long tenantId) {
|
||||
if (tenantId == null) {
|
||||
return null;
|
||||
}
|
||||
TenantModelActivation activation = activationMapper.selectOne(new QueryWrapper<TenantModelActivation>()
|
||||
.eq("tenant_id", tenantId)
|
||||
.eq("model_type", TYPE_ASR)
|
||||
.eq("enabled", 1)
|
||||
.eq("is_deleted", 0)
|
||||
.last("LIMIT 1"));
|
||||
return activation == null ? null : activation.getModelId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enableAsrForTenant(Long tenantId, Long asrModelId) {
|
||||
enableModelForTenant(TYPE_ASR, tenantId, asrModelId, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAsrForTenant(Long tenantId, Long asrModelId) {
|
||||
disableModelForTenant(TYPE_ASR, tenantId, asrModelId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> refreshPlatformInheritanceForAsr(Long asrModelId) {
|
||||
if (asrModelId == null || tenantManagementService == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> newlyEnabledTenantIds = new ArrayList<>();
|
||||
pageTenants().forEach(tenantId -> {
|
||||
Long activeAsrId = resolveActiveAsrId(tenantId);
|
||||
if (activeAsrId == null) {
|
||||
upsert(TYPE_ASR, tenantId, asrModelId, 1, 0);
|
||||
newlyEnabledTenantIds.add(tenantId);
|
||||
return;
|
||||
}
|
||||
if (!asrModelId.equals(activeAsrId)) {
|
||||
upsert(TYPE_ASR, tenantId, asrModelId, 0, 0);
|
||||
}
|
||||
});
|
||||
return newlyEnabledTenantIds;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAsrForAllTenants(Long asrModelId) {
|
||||
if (asrModelId == null) {
|
||||
return;
|
||||
}
|
||||
activationMapper.update(null, new UpdateWrapper<TenantModelActivation>()
|
||||
.set("enabled", 0)
|
||||
.eq("model_type", TYPE_ASR)
|
||||
.eq("model_id", asrModelId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTenantEnabled(String modelType, Long tenantId, Long modelId) {
|
||||
if (tenantId == null || modelId == null) {
|
||||
return false;
|
||||
}
|
||||
return activationMapper.selectCount(new QueryWrapper<TenantModelActivation>()
|
||||
.eq("tenant_id", tenantId)
|
||||
.eq("model_type", normalizeType(modelType))
|
||||
.eq("model_id", modelId)
|
||||
.eq("enabled", 1)
|
||||
.eq("is_deleted", 0)) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> listEnabledModelIds(String modelType, Long tenantId) {
|
||||
if (tenantId == null) {
|
||||
return List.of();
|
||||
}
|
||||
return activationMapper.selectList(new QueryWrapper<TenantModelActivation>()
|
||||
.select("model_id")
|
||||
.eq("tenant_id", tenantId)
|
||||
.eq("model_type", normalizeType(modelType))
|
||||
.eq("enabled", 1)
|
||||
.eq("is_deleted", 0))
|
||||
.stream()
|
||||
.map(TenantModelActivation::getModelId)
|
||||
.filter(modelId -> modelId != null)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void enableModelForTenant(String modelType, Long tenantId, Long modelId, boolean singleSelect) {
|
||||
String normalizedType = normalizeType(modelType);
|
||||
if (tenantId == null || modelId == null) {
|
||||
return;
|
||||
}
|
||||
if (singleSelect) {
|
||||
activationMapper.update(null, new UpdateWrapper<TenantModelActivation>()
|
||||
.set("enabled", 0)
|
||||
.eq("tenant_id", tenantId)
|
||||
.eq("model_type", normalizedType)
|
||||
.eq("enabled", 1));
|
||||
}
|
||||
upsert(normalizedType, tenantId, modelId, 1, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableModelForTenant(String modelType, Long tenantId, Long modelId) {
|
||||
if (tenantId == null || modelId == null) {
|
||||
return;
|
||||
}
|
||||
activationMapper.update(null, new UpdateWrapper<TenantModelActivation>()
|
||||
.set("enabled", 0)
|
||||
.set("is_default", 0)
|
||||
.eq("tenant_id", tenantId)
|
||||
.eq("model_type", normalizeType(modelType))
|
||||
.eq("model_id", modelId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableModelForAllTenants(String modelType, Long modelId) {
|
||||
if (modelId == null) {
|
||||
return;
|
||||
}
|
||||
activationMapper.update(null, new UpdateWrapper<TenantModelActivation>()
|
||||
.set("enabled", 0)
|
||||
.set("is_default", 0)
|
||||
.eq("model_type", normalizeType(modelType))
|
||||
.eq("model_id", modelId));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void setDefaultModelForTenant(String modelType, Long tenantId, Long modelId) {
|
||||
String normalizedType = normalizeType(modelType);
|
||||
if (tenantId == null || modelId == null) {
|
||||
return;
|
||||
}
|
||||
activationMapper.update(null, new UpdateWrapper<TenantModelActivation>()
|
||||
.set("is_default", 0)
|
||||
.eq("tenant_id", tenantId)
|
||||
.eq("model_type", normalizedType)
|
||||
.eq("is_default", 1));
|
||||
upsert(normalizedType, tenantId, modelId, 1, 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long resolveDefaultModelId(String modelType, Long tenantId) {
|
||||
if (tenantId == null) {
|
||||
return null;
|
||||
}
|
||||
TenantModelActivation activation = activationMapper.selectOne(new QueryWrapper<TenantModelActivation>()
|
||||
.eq("tenant_id", tenantId)
|
||||
.eq("model_type", normalizeType(modelType))
|
||||
.eq("enabled", 1)
|
||||
.eq("is_default", 1)
|
||||
.eq("is_deleted", 0)
|
||||
.last("LIMIT 1"));
|
||||
return activation == null ? null : activation.getModelId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> refreshPlatformInheritanceForLlm(Long modelId) {
|
||||
if (modelId == null || tenantManagementService == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> tenantIds = new ArrayList<>();
|
||||
pageTenants().forEach(tenantId -> {
|
||||
upsert(TYPE_LLM, tenantId, modelId, 1, null);
|
||||
tenantIds.add(tenantId);
|
||||
});
|
||||
return tenantIds;
|
||||
}
|
||||
|
||||
private List<Long> pageTenants() {
|
||||
List<Long> tenantIds = new ArrayList<>();
|
||||
int current = 1;
|
||||
int size = 1000;
|
||||
while (true) {
|
||||
PageResult<List<SysTenantDTO>> pageResult = tenantManagementService.listTenants(current, size, null, null);
|
||||
List<SysTenantDTO> tenants = pageResult == null || pageResult.getRecords() == null ? List.of() : pageResult.getRecords();
|
||||
for (SysTenantDTO tenant : tenants) {
|
||||
if (tenant != null && tenant.getId() != null) {
|
||||
tenantIds.add(tenant.getId());
|
||||
}
|
||||
}
|
||||
if (tenants.size() < size) {
|
||||
break;
|
||||
}
|
||||
current++;
|
||||
}
|
||||
return tenantIds;
|
||||
}
|
||||
|
||||
private void upsert(String modelType, Long tenantId, Long modelId, Integer enabled, Integer isDefault) {
|
||||
TenantModelActivation record = activationMapper.selectOne(new QueryWrapper<TenantModelActivation>()
|
||||
.eq("tenant_id", tenantId)
|
||||
.eq("model_type", modelType)
|
||||
.eq("model_id", modelId)
|
||||
.eq("is_deleted", 0)
|
||||
.last("LIMIT 1"));
|
||||
if (record == null) {
|
||||
record = new TenantModelActivation();
|
||||
record.setTenantId(tenantId);
|
||||
record.setModelType(modelType);
|
||||
record.setModelId(modelId);
|
||||
record.setEnabled(enabled == null ? 0 : enabled);
|
||||
record.setIsDefault(isDefault == null ? 0 : isDefault);
|
||||
activationMapper.insert(record);
|
||||
return;
|
||||
}
|
||||
if (enabled != null) {
|
||||
record.setEnabled(enabled);
|
||||
}
|
||||
if (isDefault != null) {
|
||||
record.setIsDefault(isDefault);
|
||||
}
|
||||
activationMapper.updateById(record);
|
||||
}
|
||||
|
||||
private String normalizeType(String modelType) {
|
||||
if (modelType == null || modelType.isBlank()) {
|
||||
return TYPE_ASR;
|
||||
}
|
||||
return modelType.trim().toUpperCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +84,7 @@ unisbase:
|
|||
- biz_prompt_templates
|
||||
- biz_meeting_transcript_chapter_versions
|
||||
- biz_meeting_transcript_chapters
|
||||
- biz_speaker_asr_sync
|
||||
- biz_android_push_message
|
||||
- biz_client_downloads
|
||||
- biz_external_apps
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.dto.biz.AiModelDTO;
|
||||
import com.imeeting.dto.biz.AiLocalProfileVO;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.entity.biz.AsrModel;
|
||||
import com.imeeting.entity.biz.LlmModel;
|
||||
import com.imeeting.mapper.biz.AsrModelMapper;
|
||||
import com.imeeting.mapper.biz.LlmModelMapper;
|
||||
import com.imeeting.service.biz.TenantModelActivationService;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
|
|
@ -21,12 +24,14 @@ import java.net.InetSocketAddress;
|
|||
import java.net.http.HttpClient;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
|
@ -266,6 +271,186 @@ class AiModelServiceImplTest {
|
|||
assertNull(captor.getValue().getApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDefaultModelShouldPreferTenantActiveAsr() throws Exception {
|
||||
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
|
||||
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
|
||||
when(tenantModelActivationService.resolveActiveAsrId(88L)).thenReturn(201L);
|
||||
|
||||
AsrModel activeModel = new AsrModel();
|
||||
activeModel.setId(201L);
|
||||
activeModel.setTenantId(0L);
|
||||
activeModel.setModelName("active-asr");
|
||||
activeModel.setStatus(1);
|
||||
when(asrModelMapper.selectById(201L)).thenReturn(activeModel);
|
||||
|
||||
AiModelServiceImpl service = new AiModelServiceImpl(
|
||||
objectMapper,
|
||||
asrModelMapper,
|
||||
mock(LlmModelMapper.class)
|
||||
);
|
||||
setField(service, "tenantModelActivationService", tenantModelActivationService);
|
||||
|
||||
AiModelVO result = service.getDefaultModel("ASR", 88L);
|
||||
|
||||
assertEquals(201L, result.getId());
|
||||
assertEquals("active-asr", result.getModelName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDefaultModelShouldPreferTenantDefaultLlmOverPlatformDefault() throws Exception {
|
||||
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
|
||||
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
|
||||
when(tenantModelActivationService.resolveDefaultModelId("LLM", 88L)).thenReturn(302L);
|
||||
when(tenantModelActivationService.isTenantEnabled("LLM", 88L, 302L)).thenReturn(true);
|
||||
|
||||
LlmModel tenantDefault = new LlmModel();
|
||||
tenantDefault.setId(302L);
|
||||
tenantDefault.setTenantId(0L);
|
||||
tenantDefault.setModelName("tenant-default-llm");
|
||||
tenantDefault.setStatus(1);
|
||||
when(llmModelMapper.selectById(302L)).thenReturn(tenantDefault);
|
||||
|
||||
AiModelServiceImpl service = new AiModelServiceImpl(
|
||||
objectMapper,
|
||||
mock(AsrModelMapper.class),
|
||||
llmModelMapper
|
||||
);
|
||||
setField(service, "tenantModelActivationService", tenantModelActivationService);
|
||||
|
||||
AiModelVO result = service.getDefaultModel("LLM", 88L);
|
||||
|
||||
assertEquals(302L, result.getId());
|
||||
assertEquals("tenant-default-llm", result.getModelName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDefaultModelShouldSkipTenantDisabledPlatformDefaultLlm() throws Exception {
|
||||
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
|
||||
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
|
||||
when(tenantModelActivationService.resolveDefaultModelId("LLM", 88L)).thenReturn(null);
|
||||
when(tenantModelActivationService.isTenantEnabled("LLM", 88L, 401L)).thenReturn(false);
|
||||
when(tenantModelActivationService.isTenantEnabled("LLM", 88L, 402L)).thenReturn(true);
|
||||
|
||||
LlmModel disabledPlatformDefault = new LlmModel();
|
||||
disabledPlatformDefault.setId(401L);
|
||||
disabledPlatformDefault.setTenantId(0L);
|
||||
disabledPlatformDefault.setModelName("platform-default");
|
||||
disabledPlatformDefault.setIsDefault(1);
|
||||
disabledPlatformDefault.setStatus(1);
|
||||
|
||||
LlmModel enabledCandidate = new LlmModel();
|
||||
enabledCandidate.setId(402L);
|
||||
enabledCandidate.setTenantId(0L);
|
||||
enabledCandidate.setModelName("enabled-candidate");
|
||||
enabledCandidate.setIsDefault(0);
|
||||
enabledCandidate.setStatus(1);
|
||||
|
||||
when(llmModelMapper.selectList(any())).thenReturn(
|
||||
List.of(disabledPlatformDefault),
|
||||
List.of(disabledPlatformDefault, enabledCandidate)
|
||||
);
|
||||
|
||||
AiModelServiceImpl service = new AiModelServiceImpl(
|
||||
objectMapper,
|
||||
mock(AsrModelMapper.class),
|
||||
llmModelMapper
|
||||
);
|
||||
setField(service, "tenantModelActivationService", tenantModelActivationService);
|
||||
|
||||
AiModelVO result = service.getDefaultModel("LLM", 88L);
|
||||
|
||||
assertEquals(402L, result.getId());
|
||||
assertEquals("enabled-candidate", result.getModelName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePlatformModelStatusShouldRefreshPlatformInheritanceForLlm() throws Exception {
|
||||
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
|
||||
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
|
||||
|
||||
LlmModel platformModel = new LlmModel();
|
||||
platformModel.setId(501L);
|
||||
platformModel.setTenantId(0L);
|
||||
platformModel.setStatus(0);
|
||||
when(llmModelMapper.selectById(501L)).thenReturn(platformModel);
|
||||
|
||||
AiModelServiceImpl service = new AiModelServiceImpl(
|
||||
objectMapper,
|
||||
mock(AsrModelMapper.class),
|
||||
llmModelMapper
|
||||
);
|
||||
setField(service, "tenantModelActivationService", tenantModelActivationService);
|
||||
|
||||
service.updatePlatformModelStatus("LLM", 501L, 1, true);
|
||||
|
||||
verify(tenantModelActivationService, times(1)).refreshPlatformInheritanceForLlm(501L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageModelsShouldReturnOnlyTenantEnabledAsrWhenRequested() throws Exception {
|
||||
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
|
||||
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
|
||||
when(tenantModelActivationService.listEnabledModelIds("ASR", 88L)).thenReturn(List.of(201L, 203L));
|
||||
when(tenantModelActivationService.isTenantEnabled("ASR", 88L, 201L)).thenReturn(true);
|
||||
when(tenantModelActivationService.isTenantEnabled("ASR", 88L, 202L)).thenReturn(false);
|
||||
when(tenantModelActivationService.isTenantEnabled("ASR", 88L, 203L)).thenReturn(true);
|
||||
when(asrModelMapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> {
|
||||
Page<AsrModel> page = invocation.getArgument(0);
|
||||
page.setRecords(List.of(
|
||||
asrModel(201L, 0L, "enabled-platform-asr", 1),
|
||||
asrModel(202L, 0L, "disabled-for-tenant-asr", 1),
|
||||
asrModel(203L, 88L, "enabled-tenant-asr", 1)
|
||||
));
|
||||
page.setTotal(3);
|
||||
return page;
|
||||
});
|
||||
|
||||
AiModelServiceImpl service = new AiModelServiceImpl(
|
||||
objectMapper,
|
||||
asrModelMapper,
|
||||
mock(LlmModelMapper.class)
|
||||
);
|
||||
setField(service, "tenantModelActivationService", tenantModelActivationService);
|
||||
|
||||
List<AiModelVO> records = service.pageModels(1, 100, null, "ASR", 88L, false, true).getRecords();
|
||||
|
||||
assertEquals(2, records.size());
|
||||
assertEquals(201L, records.get(0).getId());
|
||||
assertEquals(203L, records.get(1).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageModelsShouldReturnAllEnabledLlmForPlatformAdminWhenRequested() throws Exception {
|
||||
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
|
||||
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
|
||||
when(llmModelMapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> {
|
||||
Page<LlmModel> page = invocation.getArgument(0);
|
||||
page.setRecords(List.of(
|
||||
llmModel(301L, 0L, "platform-llm", 1),
|
||||
llmModel(302L, 88L, "tenant-88-llm", 1),
|
||||
llmModel(303L, 99L, "tenant-99-llm", 1)
|
||||
));
|
||||
page.setTotal(3);
|
||||
return page;
|
||||
});
|
||||
|
||||
AiModelServiceImpl service = new AiModelServiceImpl(
|
||||
objectMapper,
|
||||
mock(AsrModelMapper.class),
|
||||
llmModelMapper
|
||||
);
|
||||
setField(service, "tenantModelActivationService", tenantModelActivationService);
|
||||
|
||||
List<AiModelVO> records = service.pageModels(1, 100, null, "LLM", 88L, true, true).getRecords();
|
||||
|
||||
assertEquals(3, records.size());
|
||||
assertEquals(301L, records.get(0).getId());
|
||||
assertEquals(302L, records.get(1).getId());
|
||||
assertEquals(303L, records.get(2).getId());
|
||||
verify(tenantModelActivationService, times(0)).listEnabledModelIds(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveModelShouldPersistSortOrderForLlm() {
|
||||
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
|
||||
|
|
@ -459,6 +644,34 @@ class AiModelServiceImplTest {
|
|||
assertNull(captor.getValue().getBaseUrl());
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = AiModelServiceImpl.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
private AsrModel asrModel(Long id, Long tenantId, String modelName, Integer status) {
|
||||
AsrModel model = new AsrModel();
|
||||
model.setId(id);
|
||||
model.setTenantId(tenantId);
|
||||
model.setModelName(modelName);
|
||||
model.setStatus(status);
|
||||
model.setIsDefault(0);
|
||||
model.setSortOrder(0);
|
||||
return model;
|
||||
}
|
||||
|
||||
private LlmModel llmModel(Long id, Long tenantId, String modelName, Integer status) {
|
||||
LlmModel model = new LlmModel();
|
||||
model.setId(id);
|
||||
model.setTenantId(tenantId);
|
||||
model.setModelName(modelName);
|
||||
model.setStatus(status);
|
||||
model.setIsDefault(0);
|
||||
model.setSortOrder(0);
|
||||
return model;
|
||||
}
|
||||
|
||||
private void captureRequest(HttpExchange exchange,
|
||||
AtomicReference<String> requestPath,
|
||||
AtomicReference<String> authorization,
|
||||
|
|
|
|||
|
|
@ -205,6 +205,45 @@ class MeetingRuntimeProfileResolverImplTest {
|
|||
assertEquals(22L, profile.getResolvedSummaryModelId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveShouldUseTenantDefaultLlmFromAiModelService() {
|
||||
AiModelService aiModelService = mock(AiModelService.class);
|
||||
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
||||
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
|
||||
HotWordService hotWordService = mock(HotWordService.class);
|
||||
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
||||
aiModelService,
|
||||
promptTemplateService,
|
||||
hotWordGroupService,
|
||||
hotWordService,
|
||||
mock(AsrModelMapper.class),
|
||||
mock(LlmModelMapper.class)
|
||||
);
|
||||
|
||||
when(aiModelService.getDefaultModel("ASR", 1L)).thenReturn(enabledModel(11L, 1L, "ASR-Model"));
|
||||
when(aiModelService.getDefaultModel("LLM", 1L)).thenReturn(enabledModel(77L, 0L, "Tenant Default LLM"));
|
||||
when(promptTemplateService.getOne(any(LambdaQueryWrapper.class))).thenReturn(enabledPrompt(33L, 1L, "Default Prompt"));
|
||||
|
||||
RealtimeMeetingRuntimeProfile profile = resolver.resolve(
|
||||
1L,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertEquals(77L, profile.getResolvedSummaryModelId());
|
||||
assertEquals("Tenant Default LLM", profile.getResolvedSummaryModelName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveShouldPreferExplicitHotWordGroupOverTemplateBinding() {
|
||||
AiModelService aiModelService = mock(AiModelService.class);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ export interface AiModelVO {
|
|||
mediaConfig?: Record<string, any>;
|
||||
isDefault: number;
|
||||
status: number;
|
||||
tenantEnabled?: number;
|
||||
tenantDefault?: number;
|
||||
scope?: "PLATFORM" | "TENANT";
|
||||
canEditConfig?: boolean;
|
||||
sortOrder?: number;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
|
|
@ -55,6 +59,7 @@ export const getAiModelPage = (params: {
|
|||
size: number;
|
||||
name?: string;
|
||||
type?: string;
|
||||
tenantEnabledOnly?: boolean;
|
||||
}) => {
|
||||
return http.get<{ code: string; data: { records: AiModelVO[]; total: number }; msg: string }>(
|
||||
"/api/biz/aimodel/page",
|
||||
|
|
@ -62,6 +67,47 @@ export const getAiModelPage = (params: {
|
|||
);
|
||||
};
|
||||
|
||||
export const tenantEnableModel = (id: number, type: 'ASR' | 'LLM' = 'ASR') => {
|
||||
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/aimodel/${id}/tenant-enable`,
|
||||
undefined,
|
||||
{params: {type}}
|
||||
);
|
||||
};
|
||||
|
||||
export const tenantDisableModel = (id: number, type: 'ASR' | 'LLM' = 'ASR') => {
|
||||
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/aimodel/${id}/tenant-disable`,
|
||||
undefined,
|
||||
{params: {type}}
|
||||
);
|
||||
};
|
||||
|
||||
export const updatePlatformModelStatus = (id: number, type: 'ASR' | 'LLM', status: number) => {
|
||||
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/aimodel/${id}/platform-status`,
|
||||
{status},
|
||||
{params: {type}}
|
||||
);
|
||||
};
|
||||
|
||||
export const setTenantDefaultModel = (id: number, type: 'ASR' | 'LLM' = 'LLM') => {
|
||||
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/aimodel/${id}/tenant-default`,
|
||||
{modelId: id, modelType: type}
|
||||
);
|
||||
};
|
||||
|
||||
export const syncCurrentAsrSpeakers = () => {
|
||||
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||
"/api/biz/aimodel/current/sync-speakers"
|
||||
);
|
||||
};
|
||||
|
||||
export const tenantEnableAsr = (id: number) => tenantEnableModel(id, 'ASR');
|
||||
export const tenantDisableAsr = (id: number) => tenantDisableModel(id, 'ASR');
|
||||
export const updatePlatformAsrStatus = (id: number, status: number) => updatePlatformModelStatus(id, 'ASR', status);
|
||||
|
||||
export const saveAiModel = (data: AiModelDTO) => {
|
||||
return http.post<{ code: string; data: AiModelVO; msg: string }>(
|
||||
"/api/biz/aimodel",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export interface SpeakerVO {
|
|||
voiceExt: string;
|
||||
voiceSize: number;
|
||||
status: number;
|
||||
syncStatus?: string;
|
||||
syncErrorMessage?: string;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
|
@ -69,3 +71,9 @@ export const deleteSpeaker = (id: number) => {
|
|||
`/api/biz/speaker/${id}`
|
||||
);
|
||||
};
|
||||
|
||||
export const syncSpeaker = (id: number) => {
|
||||
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/speaker/${id}/sync`
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -183,8 +183,8 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
|||
setLoading(true);
|
||||
try {
|
||||
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: "LLM" }),
|
||||
getAiModelPage({current: 1, size: 100, type: "ASR", tenantEnabledOnly: true}),
|
||||
getAiModelPage({current: 1, size: 100, type: "LLM", tenantEnabledOnly: true}),
|
||||
getPromptPage({ current: 1, size: 100 }),
|
||||
getHotWordPage({ current: 1, size: 1000 }),
|
||||
getHotWordGroupOptions(),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AutoComplete, Button, Col, Divider, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tabs, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import {
|
||||
App,
|
||||
AutoComplete,
|
||||
Button,
|
||||
Col,
|
||||
Divider,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
|
|
@ -13,21 +30,29 @@ import {
|
|||
SyncOutlined,
|
||||
WifiOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import DataListPanel from "@/components/shared/DataListPanel";
|
||||
import SectionCard from "@/components/shared/SectionCard";
|
||||
import AppPagination from "../../components/shared/AppPagination";
|
||||
import { useDict } from "../../hooks/useDict";
|
||||
import {
|
||||
AiModelDTO,
|
||||
AiLocalProfileVO,
|
||||
AiModelDTO,
|
||||
AiModelVO,
|
||||
deleteAiModelByType,
|
||||
getAiModelPage,
|
||||
getRemoteModelList,
|
||||
saveAiModel,
|
||||
setTenantDefaultModel,
|
||||
syncCurrentAsrSpeakers,
|
||||
tenantDisableModel,
|
||||
tenantEnableModel,
|
||||
testLlmModelConnectivity,
|
||||
testLocalModelConnectivity,
|
||||
updateAiModel,
|
||||
updatePlatformModelStatus,
|
||||
} from "../../api/business/aimodel";
|
||||
import {getMeetingCreateConfig, type MeetingCreateConfig} from "../../api/business/meeting";
|
||||
import AppPagination from "../../components/shared/AppPagination";
|
||||
import "./AiModels.css";
|
||||
|
||||
const { Option } = Select;
|
||||
|
|
@ -52,7 +77,7 @@ const PROVIDER_BASE_URL_MAP: Record<string, string> = {
|
|||
groq: "https://api.groq.com/openai",
|
||||
};
|
||||
|
||||
const DEFAULT_LLM_TEST_MESSAGE = "请回复:LLM 连通性测试成功。";
|
||||
const DEFAULT_LLM_TEST_MESSAGE = "请只返回固定成功结果,用于 LLM 连通性测试。";
|
||||
|
||||
const AiModels: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
|
|
@ -74,6 +99,7 @@ const AiModels: React.FC = () => {
|
|||
const [connectivityLoading, setConnectivityLoading] = useState(false);
|
||||
const [remoteModels, setRemoteModels] = useState<string[]>([]);
|
||||
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>(DEFAULT_CREATE_CONFIG);
|
||||
|
||||
const modelNameAutoFilledRef = useRef(false);
|
||||
const localProfileLoadedRef = useRef(false);
|
||||
|
||||
|
|
@ -87,9 +113,12 @@ const AiModels: React.FC = () => {
|
|||
if (!profileStr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const profile = JSON.parse(profileStr);
|
||||
return profile.isPlatformAdmin === true;
|
||||
try {
|
||||
const profile = JSON.parse(profileStr);
|
||||
return profile.isPlatformAdmin === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -129,7 +158,19 @@ const AiModels: React.FC = () => {
|
|||
if (!baseUrl && defaultBaseUrl) {
|
||||
form.setFieldValue("baseUrl", defaultBaseUrl);
|
||||
}
|
||||
}, [provider, drawerVisible, editingId, providers, form]);
|
||||
}, [drawerVisible, editingId, form, provider, providers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerVisible || !isLocalProvider || !editingId || localProfileLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
const values = form.getFieldsValue(["baseUrl", "apiKey"]);
|
||||
if (!values.baseUrl || !values.apiKey) {
|
||||
return;
|
||||
}
|
||||
localProfileLoadedRef.current = true;
|
||||
void handleTestConnectivity();
|
||||
}, [drawerVisible, editingId, form, isLocalProvider]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -155,31 +196,22 @@ const AiModels: React.FC = () => {
|
|||
|
||||
if (record) {
|
||||
setEditingId(record.id);
|
||||
const speakerModel = record.mediaConfig?.speakerModel;
|
||||
const svThreshold = record.mediaConfig?.svThreshold;
|
||||
const tencentAppId = record.mediaConfig?.tencentAppId;
|
||||
const tencentSecretId = record.mediaConfig?.tencentSecretId;
|
||||
const tencentSecretKey = record.mediaConfig?.tencentSecretKey;
|
||||
const tencentOfflineModelCode = record.mediaConfig?.tencentOfflineModelCode || record.modelCode;
|
||||
const tencentRealtimeModelCode = record.mediaConfig?.tencentRealtimeModelCode || record.modelCode;
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
modelType: record.modelType,
|
||||
speakerModel,
|
||||
svThreshold,
|
||||
tencentAppId,
|
||||
tencentSecretId,
|
||||
tencentSecretKey,
|
||||
tencentOfflineModelCode,
|
||||
tencentRealtimeModelCode,
|
||||
speakerModel: record.mediaConfig?.speakerModel,
|
||||
svThreshold: record.mediaConfig?.svThreshold,
|
||||
tencentAppId: record.mediaConfig?.tencentAppId,
|
||||
tencentSecretId: record.mediaConfig?.tencentSecretId,
|
||||
tencentSecretKey: record.mediaConfig?.tencentSecretKey,
|
||||
tencentOfflineModelCode: record.mediaConfig?.tencentOfflineModelCode || record.modelCode,
|
||||
tencentRealtimeModelCode: record.mediaConfig?.tencentRealtimeModelCode || record.modelCode,
|
||||
isDefaultChecked: record.isDefault === 1,
|
||||
statusChecked: record.status === 1,
|
||||
});
|
||||
if (record.modelCode) {
|
||||
setRemoteModels([record.modelCode]);
|
||||
}
|
||||
if (speakerModel) {
|
||||
}
|
||||
} else {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
|
|
@ -198,20 +230,6 @@ const AiModels: React.FC = () => {
|
|||
setDrawerVisible(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerVisible || !isLocalProvider || !editingId || localProfileLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = form.getFieldsValue(["baseUrl", "apiKey"]);
|
||||
if (!values.baseUrl || !values.apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
localProfileLoadedRef.current = true;
|
||||
void handleTestConnectivity();
|
||||
}, [drawerVisible, isLocalProvider, editingId, form]);
|
||||
|
||||
const handleFetchRemote = async () => {
|
||||
if (isLocalProvider) {
|
||||
await handleTestConnectivity();
|
||||
|
|
@ -277,6 +295,7 @@ const AiModels: React.FC = () => {
|
|||
message.warning("默认模型必须保持启用状态");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: AiModelDTO = {
|
||||
id: editingId ?? undefined,
|
||||
modelType: values.modelType,
|
||||
|
|
@ -295,13 +314,13 @@ const AiModels: React.FC = () => {
|
|||
}
|
||||
: activeType === "ASR" && isTencentProvider
|
||||
? {
|
||||
tencentAppId: values.tencentAppId,
|
||||
tencentSecretId: values.tencentSecretId,
|
||||
tencentSecretKey: values.tencentSecretKey,
|
||||
tencentOfflineModelCode: values.tencentOfflineModelCode,
|
||||
tencentRealtimeModelCode: values.tencentRealtimeModelCode,
|
||||
}
|
||||
: undefined,
|
||||
tencentAppId: values.tencentAppId,
|
||||
tencentSecretId: values.tencentSecretId,
|
||||
tencentSecretKey: values.tencentSecretKey,
|
||||
tencentOfflineModelCode: values.tencentOfflineModelCode,
|
||||
tencentRealtimeModelCode: values.tencentRealtimeModelCode,
|
||||
}
|
||||
: undefined,
|
||||
temperature: values.temperature,
|
||||
topP: values.topP,
|
||||
isDefault: values.isDefaultChecked ? 1 : 0,
|
||||
|
|
@ -351,15 +370,11 @@ const AiModels: React.FC = () => {
|
|||
|
||||
const values = await form.validateFields(["provider", "baseUrl"]);
|
||||
if (String(values.provider || "").toLowerCase() !== "local") {
|
||||
message.warning("仅本地模型支持连通性测试");
|
||||
message.warning("只有本地 ASR 支持该连通性测试");
|
||||
return;
|
||||
}
|
||||
const { apiKey } = form.getFieldsValue(["apiKey"]);
|
||||
// if (!apiKey) {
|
||||
// message.warning("请先填写 API Key 后再测试连接");
|
||||
// return;
|
||||
// }
|
||||
|
||||
const { apiKey } = form.getFieldsValue(["apiKey"]);
|
||||
setConnectivityLoading(true);
|
||||
try {
|
||||
const res = await testLocalModelConnectivity({
|
||||
|
|
@ -380,7 +395,35 @@ const AiModels: React.FC = () => {
|
|||
void fetchData();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
const handleTenantToggle = async (record: AiModelVO, checked: boolean) => {
|
||||
if (checked) {
|
||||
await tenantEnableModel(record.id, activeType);
|
||||
message.success(activeType === "ASR" ? "已切换当前 ASR" : "已启用当前 LLM");
|
||||
} else {
|
||||
await tenantDisableModel(record.id, activeType);
|
||||
message.success(activeType === "ASR" ? "已关闭当前 ASR" : "已关闭当前 LLM");
|
||||
}
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handlePlatformStatusToggle = async (record: AiModelVO, checked: boolean) => {
|
||||
await updatePlatformModelStatus(record.id, activeType, checked ? 1 : 0);
|
||||
message.success(checked ? `平台级 ${activeType} 已启用` : `平台级 ${activeType} 已禁用`);
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleSyncCurrentAsr = async () => {
|
||||
await syncCurrentAsrSpeakers();
|
||||
message.success("已提交后台同步任务");
|
||||
};
|
||||
|
||||
const handleSetTenantDefault = async (record: AiModelVO) => {
|
||||
await setTenantDefaultModel(record.id, "LLM");
|
||||
message.success("已设置为默认 LLM");
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const resolvedTableColumns = [
|
||||
{
|
||||
title: "模型名称",
|
||||
dataIndex: "modelName",
|
||||
|
|
@ -388,12 +431,18 @@ const AiModels: React.FC = () => {
|
|||
render: (text: string, record: AiModelVO) => (
|
||||
<Space>
|
||||
{text}
|
||||
{record.isDefault === 1 && <Tag color="gold">默认</Tag>}
|
||||
{record.isDefault === 1 && <Tag color="gold">系统默认</Tag>}
|
||||
{record.tenantDefault === 1 && <Tag color="blue">租户默认</Tag>}
|
||||
{record.tenantId === 0 && (
|
||||
<Tooltip title="系统预置">
|
||||
<Tooltip title="平台透传模型">
|
||||
<SafetyCertificateOutlined style={{ color: "#52c41a" }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{record.scope && (
|
||||
<Tag bordered={false} color={record.scope === "PLATFORM" ? "geekblue" : "default"}>
|
||||
{record.scope === "PLATFORM" ? "平台级" : "租户级"}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
|
@ -406,7 +455,11 @@ const AiModels: React.FC = () => {
|
|||
return item ? <Tag>{item.itemLabel}</Tag> : value;
|
||||
},
|
||||
},
|
||||
{ title: "模型名称(code)", dataIndex: "modelCode", key: "modelCode" },
|
||||
{
|
||||
title: "模型编码",
|
||||
dataIndex: "modelCode",
|
||||
key: "modelCode",
|
||||
},
|
||||
{
|
||||
title: "排序",
|
||||
dataIndex: "sortOrder",
|
||||
|
|
@ -417,16 +470,41 @@ const AiModels: React.FC = () => {
|
|||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (status: number) =>
|
||||
status === 1 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||
render: (status: number, record: AiModelVO) => {
|
||||
if (isPlatformAdmin && record.scope === "PLATFORM") {
|
||||
return (
|
||||
<Switch
|
||||
checked={status === 1}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="禁用"
|
||||
onChange={(checked) => void handlePlatformStatusToggle(record, checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Switch
|
||||
checked={record.tenantEnabled === 1}
|
||||
checkedChildren={activeType === "ASR" ? "当前生效" : "已启用"}
|
||||
unCheckedChildren={activeType === "ASR" ? "未启用" : "已关闭"}
|
||||
disabled={status !== 1}
|
||||
onChange={(checked) => void handleTenantToggle(record, checked)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
render: (_: unknown, record: AiModelVO) => {
|
||||
const canEdit = record.tenantId !== 0 || isPlatformAdmin;
|
||||
const canEdit = record.canEditConfig ?? (record.tenantId !== 0 || isPlatformAdmin);
|
||||
const canSetDefault = activeType === "LLM" && record.tenantEnabled === 1;
|
||||
return (
|
||||
<Space>
|
||||
{canSetDefault && (
|
||||
<Button type="link" onClick={() => void handleSetTenantDefault(record)}>
|
||||
{record.tenantDefault === 1 ? "默认 LLM" : "设为默认"}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => openDrawer(record)}>
|
||||
编辑
|
||||
|
|
@ -445,11 +523,21 @@ const AiModels: React.FC = () => {
|
|||
},
|
||||
];
|
||||
|
||||
const leftActions = (
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined/>} onClick={() => openDrawer()}>
|
||||
新增模型
|
||||
</Button>
|
||||
{activeType === "ASR" && (
|
||||
<Button icon={<SyncOutlined/>} onClick={() => void handleSyncCurrentAsr()}>
|
||||
同步当前 ASR 声纹
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={null}
|
||||
className="ai-models-page"
|
||||
>
|
||||
<PageContainer title={null} className="ai-models-page">
|
||||
<SectionCard
|
||||
title="AI 模型配置"
|
||||
description="管理 ASR 语音识别和 LLM 大语言模型。"
|
||||
|
|
@ -471,18 +559,17 @@ const AiModels: React.FC = () => {
|
|||
>
|
||||
<DataListPanel
|
||||
className="ai-models-data-panel"
|
||||
leftActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||
新增模型
|
||||
</Button>
|
||||
}
|
||||
leftActions={leftActions}
|
||||
rightActions={
|
||||
<Input
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索模型名称"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
onPressEnter={(event) => setSearchName((event.target as HTMLInputElement).value)}
|
||||
className="ai-models-search"
|
||||
onSearch={(value) => {
|
||||
setCurrent(1);
|
||||
setSearchName(value.trim());
|
||||
}}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
|
|
@ -499,7 +586,7 @@ const AiModels: React.FC = () => {
|
|||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
columns={resolvedTableColumns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
scroll={{ x: "max-content", y: "100%" }}
|
||||
|
|
@ -530,7 +617,7 @@ const AiModels: React.FC = () => {
|
|||
|
||||
<Form.Item label="模型类型">
|
||||
<Tag color={activeType === "ASR" ? "blue" : "purple"}>
|
||||
{activeType === "ASR" ? "语音识别 (ASR)" : "总结模型 (LLM)"}
|
||||
{activeType === "ASR" ? "语音识别 (ASR)" : "大语言模型 (LLM)"}
|
||||
</Tag>
|
||||
</Form.Item>
|
||||
|
||||
|
|
@ -541,11 +628,9 @@ const AiModels: React.FC = () => {
|
|||
label="显示名称"
|
||||
rules={[{ required: true, message: "请输入显示名称" }]}
|
||||
>
|
||||
<Input
|
||||
onChange={() => {
|
||||
modelNameAutoFilledRef.current = false;
|
||||
}}
|
||||
/>
|
||||
<Input onChange={() => {
|
||||
modelNameAutoFilledRef.current = false;
|
||||
}}/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
|
|
@ -575,15 +660,11 @@ const AiModels: React.FC = () => {
|
|||
|
||||
{!isTencentProvider && (
|
||||
<>
|
||||
<Form.Item name="baseUrl" label="Base URL" rules={[{required: true, message: "请输入 Base URL"}]}>
|
||||
<Input placeholder="https://api.example.com"/>
|
||||
<Form.Item name="baseUrl" label="Base URL" rules={[{required: true, message: "请输入 Base URL"}]}>
|
||||
<Input placeholder="https://api.example.com"/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="apiKey"
|
||||
label="API Key"
|
||||
>
|
||||
<Input.Password/>
|
||||
<Form.Item name="apiKey" label="API Key">
|
||||
<Input.Password/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -601,19 +682,16 @@ const AiModels: React.FC = () => {
|
|||
</Divider>
|
||||
|
||||
<Form.Item
|
||||
label="模型名称"
|
||||
label="模型编码"
|
||||
required={activeType === "LLM"}
|
||||
hidden={activeType === "ASR" && isTencentProvider}
|
||||
tooltip="可从远程列表选择,也可手动输入;值将作为模型 code 传给后端"
|
||||
tooltip="可从远程列表选择,也可手动输入;该值会作为模型编码传给后端"
|
||||
>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item
|
||||
name="modelCode"
|
||||
noStyle
|
||||
rules={activeType === "LLM" ? [{
|
||||
required: true,
|
||||
message: "请输入或选择模型名称"
|
||||
}] : []}
|
||||
rules={activeType === "LLM" ? [{required: true, message: "请输入或选择模型编码"}] : []}
|
||||
>
|
||||
<AutoComplete
|
||||
style={{ width: "calc(100% - 100px)" }}
|
||||
|
|
@ -624,45 +702,29 @@ const AiModels: React.FC = () => {
|
|||
}}
|
||||
options={remoteModels.map((model) => ({ value: model }))}
|
||||
filterOption={(inputValue, option) =>
|
||||
isLocalProvider ||
|
||||
String(option?.value || "").toLowerCase().includes(inputValue.toLowerCase())
|
||||
isLocalProvider || String(option?.value || "").toLowerCase().includes(inputValue.toLowerCase())
|
||||
}
|
||||
>
|
||||
<Input allowClear placeholder="可选择或自定义输入模型名称" />
|
||||
<Input allowClear placeholder="可选择或手动输入模型编码"/>
|
||||
</AutoComplete>
|
||||
</Form.Item>
|
||||
{!isTencentProvider && (
|
||||
<Button icon={<SyncOutlined spin={fetchLoading}/>} onClick={handleFetchRemote} style={{width: 100}}>
|
||||
<Button icon={<SyncOutlined spin={fetchLoading}/>} onClick={handleFetchRemote} style={{width: 100}}>
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="wsUrl" label="WebSocket 地址"
|
||||
hidden={!(activeType === "ASR" && createConfig.realtimeEnabled)}>
|
||||
<Form.Item name="wsUrl" label="WebSocket 地址"
|
||||
hidden={!(activeType === "ASR" && createConfig.realtimeEnabled)}>
|
||||
<Input placeholder="wss://api.example.com/v1/ws" />
|
||||
</Form.Item>
|
||||
|
||||
{activeType === "ASR" && isLocalProvider && (
|
||||
<Row gutter={16} hidden>
|
||||
{/*<Col span={12}>*/}
|
||||
{/* <Form.Item*/}
|
||||
{/* name="speakerModel"*/}
|
||||
{/* label="声纹模型"*/}
|
||||
{/* >*/}
|
||||
{/* <Select*/}
|
||||
{/* allowClear*/}
|
||||
{/* placeholder="请先测试连接获取声纹模型"*/}
|
||||
{/* options={speakerModels.map((model) => ({ label: model, value: model }))}*/}
|
||||
{/* />*/}
|
||||
{/* </Form.Item>*/}
|
||||
{/*</Col>*/}
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="svThreshold"
|
||||
label="声纹阈值"
|
||||
>
|
||||
<Form.Item name="svThreshold" label="声纹阈值">
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
|
@ -672,48 +734,32 @@ const AiModels: React.FC = () => {
|
|||
{activeType === "ASR" && isTencentProvider && (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="tencentAppId"
|
||||
label="App ID"
|
||||
rules={[{required: true, message: "请输入 App ID"}]}
|
||||
>
|
||||
<Input/>
|
||||
<Form.Item name="tencentAppId" label="App ID" rules={[{required: true, message: "请输入 App ID"}]}>
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="tencentSecretId"
|
||||
label="Secret ID"
|
||||
rules={[{required: true, message: "请输入 Secret ID"}]}
|
||||
>
|
||||
<Input/>
|
||||
<Form.Item name="tencentSecretId" label="Secret ID"
|
||||
rules={[{required: true, message: "请输入 Secret ID"}]}>
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="tencentSecretKey"
|
||||
label="Secret Key"
|
||||
rules={[{required: true, message: "请输入 Secret Key"}]}
|
||||
>
|
||||
<Input.Password/>
|
||||
<Form.Item name="tencentSecretKey" label="Secret Key"
|
||||
rules={[{required: true, message: "请输入 Secret Key"}]}>
|
||||
<Input.Password/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="tencentOfflineModelCode"
|
||||
label="离线识别模型名"
|
||||
rules={[{required: true, message: "请输入离线识别模型名"}]}
|
||||
>
|
||||
<Input placeholder="例如:16k_zh"/>
|
||||
<Form.Item name="tencentOfflineModelCode" label="离线识别模型"
|
||||
rules={[{required: true, message: "请输入离线识别模型"}]}>
|
||||
<Input placeholder="例如:16k_zh"/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="tencentRealtimeModelCode"
|
||||
label="实时识别模型名"
|
||||
rules={[{required: true, message: "请输入实时识别模型名"}]}
|
||||
>
|
||||
<Input placeholder="例如:16k_zh_realtime"/>
|
||||
<Form.Item name="tencentRealtimeModelCode" label="实时识别模型"
|
||||
rules={[{required: true, message: "请输入实时识别模型"}]}>
|
||||
<Input placeholder="例如:16k_zh_realtime"/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -726,12 +772,12 @@ const AiModels: React.FC = () => {
|
|||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="temperature" label="温度">
|
||||
<Form.Item name="temperature" label="Temperature">
|
||||
<InputNumber min={0} max={2} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="topP" label="Top P(核采样)">
|
||||
<Form.Item name="topP" label="Top P">
|
||||
<InputNumber min={0} max={1} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
|
|
|||
|
|
@ -1771,6 +1771,7 @@ const MeetingDetail: React.FC = () => {
|
|||
console.error(error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
setSummaryVisible(false)
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, {useEffect, useMemo, useRef, useState} from "react";
|
||||
import {
|
||||
AudioOutlined,
|
||||
CheckCircleOutlined,
|
||||
|
|
@ -6,56 +6,90 @@ import {
|
|||
DeleteOutlined,
|
||||
FormOutlined,
|
||||
SearchOutlined,
|
||||
StopOutlined,
|
||||
UserOutlined,
|
||||
SoundOutlined,
|
||||
SafetyCertificateOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { Badge, Button, Card, Col, Empty, Form, Input, Popconfirm, Progress, Row, Select, Spin, Space, Tabs, Tag, Typography, Upload, Tooltip, App } from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { listUsers } from '../../api';
|
||||
import { deleteSpeaker, getSpeakerPage, registerSpeaker, SpeakerVO } from '../../api/business/speaker';
|
||||
import AppPagination from '../../components/shared/AppPagination';
|
||||
import PageContainer from '../../components/shared/PageContainer';
|
||||
import SectionCard from '../../components/shared/SectionCard';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import type { SysUser } from '../../types';
|
||||
import './SpeakerReg.css';
|
||||
StopOutlined,
|
||||
SyncOutlined,
|
||||
UserOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
App,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Upload,
|
||||
} from "antd";
|
||||
import type {UploadProps} from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import {listUsers} from "../../api";
|
||||
import {
|
||||
deleteSpeaker,
|
||||
getSpeakerPage,
|
||||
registerSpeaker,
|
||||
SpeakerVO,
|
||||
syncSpeaker,
|
||||
} from "../../api/business/speaker";
|
||||
import AppPagination from "../../components/shared/AppPagination";
|
||||
import PageContainer from "../../components/shared/PageContainer";
|
||||
import SectionCard from "../../components/shared/SectionCard";
|
||||
import {useAuth} from "../../hooks/useAuth";
|
||||
import type {SysUser} from "../../types";
|
||||
import "./SpeakerReg.css";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Search } = Input;
|
||||
|
||||
const REG_CONTENT =
|
||||
'iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。';
|
||||
"iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。";
|
||||
const DEFAULT_DURATION = 15;
|
||||
const DEFAULT_PAGE_SIZE = 8;
|
||||
const AUDIO_EXT_PATTERN = /\.(mp3|wav|m4a|aac|ogg|flac|webm)$/i;
|
||||
|
||||
const SPEAKER_STATUS_META: Record<number, { label: string; color: string }> = {
|
||||
1: { label: '已保存', color: 'default' },
|
||||
2: { label: '注册中', color: 'processing' },
|
||||
3: { label: '已注册', color: 'success' },
|
||||
4: { label: '本地已保存,声纹同步失败', color: 'error' }
|
||||
const SPEAKER_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
PENDING: {label: "未同步", color: "default"},
|
||||
SYNCING: {label: "同步中", color: "processing"},
|
||||
SYNCED: {label: "已注册", color: "success"},
|
||||
FAILED: {label: "同步失败", color: "error"},
|
||||
STALE: {label: "待重新同步", color: "warning"},
|
||||
DELETED: {label: "待重新同步", color: "warning"},
|
||||
};
|
||||
|
||||
const getSpeakerStatusMeta = (status?: number) => {
|
||||
return SPEAKER_STATUS_META[Number(status)] || SPEAKER_STATUS_META[1];
|
||||
const getSpeakerStatusMeta = (speaker: SpeakerVO) => {
|
||||
return SPEAKER_STATUS_META[speaker.syncStatus || "PENDING"] || SPEAKER_STATUS_META.PENDING;
|
||||
};
|
||||
|
||||
const isAudioFile = (file: File) => {
|
||||
return file.type.startsWith('audio/') || AUDIO_EXT_PATTERN.test(file.name);
|
||||
return file.type.startsWith("audio/") || AUDIO_EXT_PATTERN.test(file.name);
|
||||
};
|
||||
|
||||
const buildResourceUrl = (prefix: string, resourcePath?: string) => {
|
||||
if (!resourcePath) {
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
||||
const normalizedPath = resourcePath.startsWith('/') ? resourcePath.slice(1) : resourcePath;
|
||||
const normalizedPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
|
||||
const normalizedPath = resourcePath.startsWith("/") ? resourcePath.slice(1) : resourcePath;
|
||||
return `${normalizedPrefix}${normalizedPath}`;
|
||||
};
|
||||
|
||||
const getErrorMessage = (err: unknown, fallback: string) => {
|
||||
const responseMessage = (err as any)?.response?.data?.msg || (err as any)?.response?.data?.message;
|
||||
return responseMessage || fallback;
|
||||
};
|
||||
|
||||
const SpeakerReg: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
|
|
@ -64,12 +98,13 @@ const SpeakerReg: React.FC = () => {
|
|||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [speakers, setSpeakers] = useState<SpeakerVO[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [queryName, setQueryName] = useState('');
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [queryName, setQueryName] = useState("");
|
||||
const [current, setCurrent] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [syncingSpeakerId, setSyncingSpeakerId] = useState<number | null>(null);
|
||||
const [userOptions, setUserOptions] = useState<SysUser[]>([]);
|
||||
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
|
@ -83,15 +118,15 @@ const SpeakerReg: React.FC = () => {
|
|||
|
||||
const resourcePrefix = useMemo(() => {
|
||||
try {
|
||||
const configStr = sessionStorage.getItem('platformConfig');
|
||||
const configStr = sessionStorage.getItem("platformConfig");
|
||||
if (configStr) {
|
||||
const config = JSON.parse(configStr);
|
||||
return config.resourcePrefix || '/api/static/';
|
||||
return config.resourcePrefix || "/api/static/";
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Parse platformConfig failed', err);
|
||||
console.warn("Parse platformConfig failed", err);
|
||||
}
|
||||
return '/api/static/';
|
||||
return "/api/static/";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -100,10 +135,10 @@ const SpeakerReg: React.FC = () => {
|
|||
return () => {
|
||||
mountedRef.current = false;
|
||||
stopTimer();
|
||||
if (mediaRecorderRef.current?.state === 'recording') {
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
mediaRecorderRef.current?.stream.getTracks().forEach(track => track.stop());
|
||||
mediaRecorderRef.current?.stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -124,8 +159,8 @@ const SpeakerReg: React.FC = () => {
|
|||
if (!profile?.userId || isAdmin) {
|
||||
return;
|
||||
}
|
||||
form.setFieldValue('userId', profile.userId);
|
||||
form.setFieldValue('name', profile.displayName);
|
||||
form.setFieldValue("userId", profile.userId);
|
||||
form.setFieldValue("name", profile.displayName);
|
||||
}, [form, isAdmin, profile?.displayName, profile?.userId]);
|
||||
|
||||
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {
|
||||
|
|
@ -134,7 +169,7 @@ const SpeakerReg: React.FC = () => {
|
|||
const res = await getSpeakerPage({
|
||||
current: page,
|
||||
size,
|
||||
name: name || undefined
|
||||
name: name || undefined,
|
||||
});
|
||||
const payload: any = res.data || res;
|
||||
const records = payload?.data?.records || payload?.records || [];
|
||||
|
|
@ -149,7 +184,7 @@ const SpeakerReg: React.FC = () => {
|
|||
setTotal(nextTotal);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error('加载声纹库失败');
|
||||
message.error("加载声纹库失败");
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
|
|
@ -162,7 +197,7 @@ const SpeakerReg: React.FC = () => {
|
|||
} catch (err) {
|
||||
console.error(err);
|
||||
setUserOptions([]);
|
||||
message.error('加载用户列表失败');
|
||||
message.error("加载用户列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -174,10 +209,10 @@ const SpeakerReg: React.FC = () => {
|
|||
|
||||
const resetFormState = () => {
|
||||
setEditingSpeaker(null);
|
||||
form.resetFields(['id', 'name', 'userId', 'remark']);
|
||||
form.resetFields(["id", "name", "userId", "remark"]);
|
||||
if (!isAdmin && profile?.userId) {
|
||||
form.setFieldValue('userId', profile.userId);
|
||||
form.setFieldValue('name', profile.displayName);
|
||||
form.setFieldValue("userId", profile.userId);
|
||||
form.setFieldValue("name", profile.displayName);
|
||||
}
|
||||
resetAudioState();
|
||||
};
|
||||
|
|
@ -186,7 +221,7 @@ const SpeakerReg: React.FC = () => {
|
|||
stopTimer();
|
||||
setSeconds(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setSeconds(prev => Math.min(prev + 1, DEFAULT_DURATION));
|
||||
setSeconds((prev) => Math.min(prev + 1, DEFAULT_DURATION));
|
||||
}, 1000);
|
||||
autoStopTimerRef.current = setTimeout(() => {
|
||||
setSeconds(DEFAULT_DURATION);
|
||||
|
|
@ -207,11 +242,11 @@ const SpeakerReg: React.FC = () => {
|
|||
|
||||
const startRecording = async () => {
|
||||
if (loading) {
|
||||
message.warning('声纹正在提交,请稍后再录制');
|
||||
message.warning("声纹正在提交,请稍后再录制");
|
||||
return;
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
message.error('当前浏览器不支持录音,请使用音频文件上传');
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
|
||||
message.error("当前浏览器不支持录音,请使用音频文件上传");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -220,7 +255,7 @@ const SpeakerReg: React.FC = () => {
|
|||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
|
|
@ -230,7 +265,7 @@ const SpeakerReg: React.FC = () => {
|
|||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob(audioChunksRef.current, { type: 'audio/wav' });
|
||||
const blob = new Blob(audioChunksRef.current, {type: "audio/wav"});
|
||||
resetAudioState();
|
||||
setAudioBlob(blob);
|
||||
setAudioUrl(URL.createObjectURL(blob));
|
||||
|
|
@ -243,32 +278,31 @@ const SpeakerReg: React.FC = () => {
|
|||
startTimer();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error('无法访问麦克风,请检查权限设置');
|
||||
message.error("无法访问麦克风,请检查权限设置");
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
||||
mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop());
|
||||
setRecording(false);
|
||||
stopTimer();
|
||||
}
|
||||
};
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
beforeUpload: file => {
|
||||
beforeUpload: (file) => {
|
||||
if (recording) {
|
||||
message.warning('请先停止录音,再上传音频文件');
|
||||
message.warning("请先停止录音,再上传音频文件");
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (loading) {
|
||||
message.warning('声纹正在提交,请稍后再上传');
|
||||
message.warning("声纹正在提交,请稍后再上传");
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
const isAudio = isAudioFile(file);
|
||||
if (!isAudio) {
|
||||
message.error('只能上传音频文件');
|
||||
if (!isAudioFile(file)) {
|
||||
message.error("只能上传音频文件");
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
resetAudioState();
|
||||
|
|
@ -277,12 +311,12 @@ const SpeakerReg: React.FC = () => {
|
|||
return false;
|
||||
},
|
||||
disabled: recording || loading,
|
||||
showUploadList: false
|
||||
showUploadList: false,
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!audioBlob && !editingSpeaker) {
|
||||
message.warning('请先录制或上传声纹文件');
|
||||
message.warning("请先录制或上传声纹文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -293,9 +327,9 @@ const SpeakerReg: React.FC = () => {
|
|||
name: values.name.trim(),
|
||||
userId: values.userId ? Number(values.userId) : undefined,
|
||||
remark: values.remark?.trim(),
|
||||
file: audioBlob || undefined
|
||||
file: audioBlob || undefined,
|
||||
});
|
||||
message.success(editingSpeaker ? '声纹更新成功' : '声纹录入成功');
|
||||
message.success(editingSpeaker ? "声纹保存成功,等待同步" : "声纹保存成功,等待同步");
|
||||
resetFormState();
|
||||
if (current !== 1) {
|
||||
setCurrent(1);
|
||||
|
|
@ -307,7 +341,7 @@ const SpeakerReg: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
console.error(err);
|
||||
message.error('声纹录入失败');
|
||||
message.error(getErrorMessage(err, "声纹保存失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -316,7 +350,7 @@ const SpeakerReg: React.FC = () => {
|
|||
const handleDelete = async (speaker: SpeakerVO) => {
|
||||
try {
|
||||
await deleteSpeaker(speaker.id);
|
||||
message.success('声纹已删除');
|
||||
message.success("声纹已删除");
|
||||
if (editingSpeaker?.id === speaker.id) {
|
||||
resetFormState();
|
||||
}
|
||||
|
|
@ -327,7 +361,21 @@ const SpeakerReg: React.FC = () => {
|
|||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error('删除声纹失败');
|
||||
message.error(getErrorMessage(err, "删除声纹失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async (speaker: SpeakerVO) => {
|
||||
setSyncingSpeakerId(speaker.id);
|
||||
try {
|
||||
await syncSpeaker(speaker.id);
|
||||
message.success("已提交声纹同步任务");
|
||||
void fetchSpeakers(current, pageSize, queryName);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error(getErrorMessage(err, "同步声纹失败"));
|
||||
} finally {
|
||||
setSyncingSpeakerId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -337,15 +385,15 @@ const SpeakerReg: React.FC = () => {
|
|||
id: speaker.id,
|
||||
name: speaker.name,
|
||||
userId: speaker.userId,
|
||||
remark: speaker.remark
|
||||
remark: speaker.remark,
|
||||
});
|
||||
resetAudioState();
|
||||
};
|
||||
|
||||
const handleUserChange = (userId?: number) => {
|
||||
const selectedUser = userOptions.find(u => u.userId === userId);
|
||||
const selectedUser = userOptions.find((user) => user.userId === userId);
|
||||
if (selectedUser) {
|
||||
form.setFieldValue('name', selectedUser.displayName || selectedUser.username);
|
||||
form.setFieldValue("name", selectedUser.displayName || selectedUser.username);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -359,7 +407,7 @@ const SpeakerReg: React.FC = () => {
|
|||
<PageContainer title={null} className="speaker-reg-page">
|
||||
<SectionCard
|
||||
title="声纹采集工作台"
|
||||
description="采集或上传声纹样本,并维护当前租户下的发言人声纹库。"
|
||||
description="采集或上传声纹样本,并维护当前租户下的发言人声纹库。状态以当前启用 ASR 的同步结果为准。"
|
||||
extra={<Badge status="processing" text={<Text type="secondary">声纹引擎就绪</Text>} />}
|
||||
contentClassName="speaker-reg-section-content"
|
||||
>
|
||||
|
|
@ -371,18 +419,15 @@ const SpeakerReg: React.FC = () => {
|
|||
<span className="speaker-reg-card-icon">
|
||||
<FormOutlined />
|
||||
</span>
|
||||
<span>{editingSpeaker ? '更新声纹档案' : '新建声纹档案'}</span>
|
||||
<span>{editingSpeaker ? "更新声纹档案" : "新建声纹档案"}</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="speaker-reg-form">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={14}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={<Text strong>声纹名称</Text>}
|
||||
rules={[{ required: true, message: '必填' }]}
|
||||
>
|
||||
<Form.Item name="name" label={<Text strong>声纹名称</Text>}
|
||||
rules={[{required: true, message: "必填"}]}>
|
||||
<Input size="middle" placeholder="姓名 / 职位 / 编号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
|
@ -393,23 +438,27 @@ const SpeakerReg: React.FC = () => {
|
|||
placeholder="系统关联"
|
||||
disabled={!isAdmin}
|
||||
onChange={handleUserChange}
|
||||
options={userOptions.map(u => ({ label: u.displayName || u.username, value: u.userId }))}
|
||||
options={userOptions.map((user) => ({
|
||||
label: user.displayName || user.username,
|
||||
value: user.userId,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="remark" label={<Text strong>备注 (可选)</Text>}>
|
||||
|
||||
<Form.Item name="remark" label={<Text strong>备注(可选)</Text>}>
|
||||
<Input size="middle" placeholder="记录使用场景或特征说明" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
<Tabs
|
||||
defaultActiveKey="record"
|
||||
className="speaker-reg-tabs"
|
||||
size="middle"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
key: "record",
|
||||
label: <span><AudioOutlined /> 实时录制采集</span>,
|
||||
children: (
|
||||
<div className="recording-area">
|
||||
|
|
@ -417,36 +466,34 @@ const SpeakerReg: React.FC = () => {
|
|||
<Text strong className="script-box__label">录音文本内容:</Text>
|
||||
<div className="script-box__content">{REG_CONTENT}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="record-controls">
|
||||
<button
|
||||
className={`btn-record ${recording ? 'recording' : 'idle'}`}
|
||||
className={`btn-record ${recording ? "recording" : "idle"}`}
|
||||
onClick={recording ? stopRecording : startRecording}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
aria-label={recording ? '停止录制声纹样本' : '开始录制声纹样本'}
|
||||
aria-label={recording ? "停止录制声纹样本" : "开始录制声纹样本"}
|
||||
>
|
||||
{recording ? <StopOutlined /> : <AudioOutlined />}
|
||||
</button>
|
||||
|
||||
|
||||
<div className="record-progress">
|
||||
<div className="record-progress__head">
|
||||
<Text strong className={recording ? 'is-recording' : ''}>{recording ? '正在采集声音...' : '等待录制'}</Text>
|
||||
<Text strong className={recording ? "is-recording" : ""}>
|
||||
{recording ? "正在采集声音..." : "等待录制"}
|
||||
</Text>
|
||||
<Text type="secondary">{seconds}s / {DEFAULT_DURATION}s</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={(seconds / DEFAULT_DURATION) * 100}
|
||||
showInfo={false}
|
||||
strokeColor="#3c70f5"
|
||||
size="small"
|
||||
/>
|
||||
<Progress percent={(seconds / DEFAULT_DURATION) * 100} showInfo={false} strokeColor="#3c70f5"
|
||||
size="small"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
key: "upload",
|
||||
label: <span><CloudUploadOutlined /> 离线文件上传</span>,
|
||||
children: (
|
||||
<Upload {...uploadProps} accept="audio/*" className="speaker-reg-upload">
|
||||
|
|
@ -456,8 +503,8 @@ const SpeakerReg: React.FC = () => {
|
|||
<Text type="secondary">支持 MP3 / WAV / M4A,建议时长 5-15 秒</Text>
|
||||
</div>
|
||||
</Upload>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
|
@ -465,7 +512,9 @@ const SpeakerReg: React.FC = () => {
|
|||
<div className="speaker-reg-audio-ready">
|
||||
<div className="speaker-reg-audio-ready__head">
|
||||
<Text strong><CheckCircleOutlined /> 采样文件已就绪</Text>
|
||||
<Button type="text" danger size="small" onClick={resetAudioState} icon={<DeleteOutlined />}>重新采集</Button>
|
||||
<Button type="text" danger size="small" onClick={resetAudioState} icon={<DeleteOutlined/>}>
|
||||
重新采集
|
||||
</Button>
|
||||
</div>
|
||||
<audio src={audioUrl} controls />
|
||||
</div>
|
||||
|
|
@ -480,16 +529,18 @@ const SpeakerReg: React.FC = () => {
|
|||
loading={loading}
|
||||
disabled={recording || (!audioBlob && !editingSpeaker)}
|
||||
>
|
||||
{editingSpeaker ? '确认保存声纹变更' : '提交并同步到声纹库'}
|
||||
{editingSpeaker ? "确认保存声纹变更" : "保存声纹"}
|
||||
</Button>
|
||||
{editingSpeaker && (
|
||||
<Button size="middle" block onClick={resetFormState}>取消编辑</Button>
|
||||
<Button size="middle" block onClick={resetFormState}>
|
||||
取消编辑
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="info-strip">
|
||||
<SafetyCertificateOutlined />
|
||||
<div>
|
||||
数据将加密存储,仅用于会议期间的发言人识别与角色分离。同租户内声纹名称需保持唯一。
|
||||
数据将加密存储,仅用于会议期间的发言人识别与角色分离。保存成功后如状态仍是“未同步”或“同步失败”,可在列表中点击“同步”按钮补同步。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -500,7 +551,7 @@ const SpeakerReg: React.FC = () => {
|
|||
title={
|
||||
<Space size={8}>
|
||||
<SoundOutlined />
|
||||
<span>已注册声纹库</span>
|
||||
<span>声纹列表</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
|
|
@ -508,12 +559,12 @@ const SpeakerReg: React.FC = () => {
|
|||
<Search
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
onChange={e => {
|
||||
onChange={(e) => {
|
||||
const nextValue = e.target.value;
|
||||
setSearchKeyword(nextValue);
|
||||
if (!nextValue.trim() && queryName) {
|
||||
setCurrent(1);
|
||||
setQueryName('');
|
||||
setQueryName("");
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
|
|
@ -525,61 +576,79 @@ const SpeakerReg: React.FC = () => {
|
|||
}
|
||||
>
|
||||
<Text type="secondary" className="speaker-reg-library__hint">
|
||||
按名称快速筛选当前声纹记录,支持试听、编辑和删除。
|
||||
“已注册”表示已经同步到当前启用的 ASR;仅本地保存未同步时,可手动点击“同步”。
|
||||
</Text>
|
||||
|
||||
|
||||
<div className="speaker-list">
|
||||
<Spin spinning={listLoading}>
|
||||
{speakers.length === 0 ? (
|
||||
<Empty
|
||||
description={<Text type="secondary">{queryName ? '未找到匹配的声纹记录' : '暂无声纹记录'}</Text>}
|
||||
className="speaker-reg-empty"
|
||||
/>
|
||||
) : (
|
||||
speakers.map((s) => {
|
||||
const statusMeta = getSpeakerStatusMeta(s.status);
|
||||
return (
|
||||
<div className={s.remark ? "speaker-card" : "speaker-card speaker-card--no-remark"} key={s.id}>
|
||||
<div className="speaker-card__head">
|
||||
<div className="speaker-card__identity">
|
||||
<Text strong ellipsis>{s.name}</Text>
|
||||
<div className="speaker-card__meta">
|
||||
<Tag color={statusMeta.color} bordered={false}>
|
||||
{statusMeta.label}
|
||||
</Tag>
|
||||
{s.userId && <Text type="secondary"><UserOutlined /> ID:{s.userId}</Text>}
|
||||
<Spin spinning={listLoading}>
|
||||
{speakers.length === 0 ? (
|
||||
<Empty
|
||||
description={<Text type="secondary">{queryName ? "未找到匹配的声纹记录" : "暂无声纹记录"}</Text>}
|
||||
className="speaker-reg-empty"
|
||||
/>
|
||||
) : (
|
||||
speakers.map((speaker) => {
|
||||
const statusMeta = getSpeakerStatusMeta(speaker);
|
||||
const canSync = speaker.syncStatus !== "SYNCED" && syncingSpeakerId !== speaker.id;
|
||||
const statusTag = (
|
||||
<Tag color={statusMeta.color} bordered={false}>
|
||||
{statusMeta.label}
|
||||
</Tag>
|
||||
);
|
||||
return (
|
||||
<div className={speaker.remark ? "speaker-card" : "speaker-card speaker-card--no-remark"}
|
||||
key={speaker.id}>
|
||||
<div className="speaker-card__head">
|
||||
<div className="speaker-card__identity">
|
||||
<Text strong ellipsis>{speaker.name}</Text>
|
||||
<div className="speaker-card__meta">
|
||||
{speaker.syncErrorMessage ? (
|
||||
<Tooltip title={speaker.syncErrorMessage}>{statusTag}</Tooltip>
|
||||
) : statusTag}
|
||||
{speaker.userId && <Text type="secondary"><UserOutlined/> ID:{speaker.userId}</Text>}
|
||||
{speaker.syncStatus !== "SYNCED" && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<SyncOutlined/>}
|
||||
loading={syncingSpeakerId === speaker.id}
|
||||
onClick={() => void handleSync(speaker)}
|
||||
>
|
||||
同步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Space size={0}>
|
||||
<Tooltip title="编辑档案">
|
||||
<Button type="text" size="small" icon={<FormOutlined/>}
|
||||
onClick={() => handleEdit(speaker)}/>
|
||||
</Tooltip>
|
||||
<Popconfirm title="确定要删除此声纹记录吗?" onConfirm={() => handleDelete(speaker)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined/>}/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{speaker.remark && <div className="speaker-card__remark">{speaker.remark}</div>}
|
||||
|
||||
<audio
|
||||
src={buildResourceUrl(resourcePrefix, speaker.voicePath)}
|
||||
controls
|
||||
controlsList="nodownload"
|
||||
/>
|
||||
|
||||
<div className="speaker-card__footer">
|
||||
<span>更新于 {dayjs(speaker.updatedAt).format("YYYY-MM-DD HH:mm")}</span>
|
||||
<span>{((speaker.voiceSize || 0) / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
<Space size={0}>
|
||||
<Tooltip title="编辑档案">
|
||||
<Button type="text" size="small" icon={<FormOutlined />} onClick={() => handleEdit(s)} />
|
||||
</Tooltip>
|
||||
<Popconfirm title="确定要删除此声纹记录吗?" onConfirm={() => handleDelete(s)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{s.remark && (
|
||||
<div className="speaker-card__remark">{s.remark}</div>
|
||||
)}
|
||||
|
||||
<audio
|
||||
src={buildResourceUrl(resourcePrefix, s.voicePath)}
|
||||
controls
|
||||
controlsList="nodownload"
|
||||
/>
|
||||
|
||||
<div className="speaker-card__footer">
|
||||
<span>更新于 {dayjs(s.updatedAt).format('YYYY-MM-DD HH:mm')}</span>
|
||||
<span>{((s.voiceSize || 0) / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Spin>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<AppPagination
|
||||
variant="card"
|
||||
current={current}
|
||||
|
|
|
|||
Loading…
Reference in New Issue