feat: 添加腾讯离线和实时 ASR 模型配置并优化相关逻辑

- 在 `AiModelServiceImpl` 和 `AiTaskServiceImpl` 中添加处理腾讯离线和实时 ASR 模型的逻辑
- 更新前端 `AiModels` 页面,支持腾讯 ASR 配置表单字段
- 在 `RealtimeMeetingSocketSessionServiceImpl` 中添加解析腾讯实时模型代码的方法
- 更新单元测试以验证腾讯 ASR 模型保存逻辑
dev_na
chenhao 2026-06-29 09:00:12 +08:00
parent 982425e21e
commit 57f343ddfd
7 changed files with 133 additions and 17 deletions

View File

@ -205,7 +205,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
null,
null,
null,
null,
true,
null,
null,
List.of()
@ -281,7 +281,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
null,
null,
null,
null,
true,
null,
null,
List.of()

View File

@ -49,6 +49,8 @@ public class AiModelServiceImpl implements AiModelService {
private static final String MEDIA_TENCENT_APP_ID = "tencentAppId";
private static final String MEDIA_TENCENT_SECRET_ID = "tencentSecretId";
private static final String MEDIA_TENCENT_SECRET_KEY = "tencentSecretKey";
private static final String MEDIA_TENCENT_OFFLINE_MODEL_CODE = "tencentOfflineModelCode";
private static final String MEDIA_TENCENT_REALTIME_MODEL_CODE = "tencentRealtimeModelCode";
private static final int DEFAULT_SORT_ORDER = 0;
private static final String DEFAULT_LLM_API_PATH = "/v1/chat/completions";
private static final String DEFAULT_ANTHROPIC_API_PATH = "/messages";
@ -964,8 +966,11 @@ public class AiModelServiceImpl implements AiModelService {
if (readConfigString(mediaConfig.get(MEDIA_TENCENT_SECRET_KEY)) == null) {
throw new RuntimeException("腾讯 ASR 模型必须配置 mediaConfig.tencentSecretKey");
}
if (dto.getModelCode() == null || dto.getModelCode().isBlank()) {
throw new RuntimeException("腾讯 ASR 模型必须配置 modelCode");
if (readConfigString(mediaConfig.get(MEDIA_TENCENT_OFFLINE_MODEL_CODE)) == null) {
throw new RuntimeException("腾讯 ASR 模型必须配置 mediaConfig.tencentOfflineModelCode");
}
if (readConfigString(mediaConfig.get(MEDIA_TENCENT_REALTIME_MODEL_CODE)) == null) {
throw new RuntimeException("腾讯 ASR 模型必须配置 mediaConfig.tencentRealtimeModelCode");
}
}

View File

@ -84,6 +84,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private static final String MEDIA_TENCENT_APP_ID = "tencentAppId";
private static final String MEDIA_TENCENT_SECRET_ID = "tencentSecretId";
private static final String MEDIA_TENCENT_SECRET_KEY = "tencentSecretKey";
private static final String MEDIA_TENCENT_OFFLINE_MODEL_CODE = "tencentOfflineModelCode";
private static final String TENCENT_ASR_REGION = "ap-guangzhou";
private final MeetingMapper meetingMapper;
@ -756,7 +757,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
protected Map<String, Object> buildTencentOfflineCreateRequest(Meeting meeting, AiTask taskRecord, AiModelVO asrModel) {
Map<String, Object> req = new HashMap<>();
req.put("engineModelType", asrModel.getModelCode());
req.put("engineModelType", resolveTencentOfflineModelCode(asrModel));
req.put("channelNum", 1L);
req.put("resTextFormat", 2L);
req.put("sourceType", 0L);
@ -1646,6 +1647,15 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
transcript.setEndTime(endTime);
}
private String resolveTencentOfflineModelCode(AiModelVO asrModel) {
Map<String, Object> mediaConfig = asrModel == null || asrModel.getMediaConfig() == null ? Map.of() : asrModel.getMediaConfig();
String offlineModelCode = stringValue(mediaConfig.get(MEDIA_TENCENT_OFFLINE_MODEL_CODE));
if (offlineModelCode != null && !offlineModelCode.isBlank()) {
return offlineModelCode.trim();
}
return asrModel == null ? null : asrModel.getModelCode();
}
private AiModelVO resolveAsrModelForRevision(AiTask asrTask) {
if (asrTask == null || asrTask.getTaskConfig() == null) {
return null;

View File

@ -26,6 +26,8 @@ import java.util.UUID;
public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingSocketSessionService {
private static final String WS_PATH = "/ws/meeting/realtime";
private static final String TENCENT_PROVIDER = "tencent";
private static final String MEDIA_TENCENT_REALTIME_MODEL_CODE = "tencentRealtimeModelCode";
private final RealtimeMeetingSocketSessionCache socketSessionCache;
private final MeetingAccessService meetingAccessService;
@ -87,7 +89,7 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
sessionData.setAsrModelId(asrModelId);
sessionData.setProvider(realtimeAsrChannelFactory.normalizeProvider(asrModel.getProvider()));
sessionData.setTargetWsUrl(targetWsUrl);
sessionData.setModelCode(asrModel.getModelCode());
sessionData.setModelCode(resolveRealtimeModelCode(asrModel));
sessionData.setMediaConfig(asrModel.getMediaConfig());
String sessionToken = UUID.randomUUID().toString().replace("-", "");
@ -111,6 +113,25 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
return vo;
}
private String resolveRealtimeModelCode(AiModelVO asrModel) {
if (asrModel == null) {
return null;
}
if (!TENCENT_PROVIDER.equalsIgnoreCase(asrModel.getProvider())) {
return asrModel.getModelCode();
}
Map<String, Object> mediaConfig = asrModel.getMediaConfig();
if (mediaConfig == null) {
return asrModel.getModelCode();
}
Object realtimeModelCode = mediaConfig.get(MEDIA_TENCENT_REALTIME_MODEL_CODE);
if (realtimeModelCode == null) {
return asrModel.getModelCode();
}
String value = String.valueOf(realtimeModelCode).trim();
return value.isEmpty() ? asrModel.getModelCode() : value;
}
@Override
public RealtimeSocketSessionData getSessionData(String sessionToken) {
return socketSessionCache.get(sessionToken);

View File

@ -397,6 +397,31 @@ class AiModelServiceImplTest {
assertEquals("腾讯 ASR 模型必须配置 mediaConfig.tencentSecretKey", ex.getMessage());
}
@Test
void saveModelShouldRejectTencentAsrWithoutRealtimeModelCode() {
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
AiModelDTO dto = new AiModelDTO();
dto.setModelType("ASR");
dto.setModelName("tencent-asr");
dto.setProvider("tencent");
dto.setIsDefault(0);
dto.setStatus(1);
dto.setMediaConfig(Map.of(
"tencentAppId", "app-id",
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key",
"tencentOfflineModelCode", "16k_zh"
));
RuntimeException ex = assertThrows(RuntimeException.class, () -> service.saveModel(dto));
assertEquals("腾讯 ASR 模型必须配置 mediaConfig.tencentRealtimeModelCode", ex.getMessage());
}
@Test
void saveModelShouldPersistTencentAsrWithoutBaseUrl() {
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
@ -412,13 +437,14 @@ class AiModelServiceImplTest {
dto.setModelType("ASR");
dto.setModelName("tencent-asr");
dto.setProvider("tencent");
dto.setModelCode("16k_zh");
dto.setIsDefault(0);
dto.setStatus(1);
dto.setMediaConfig(Map.of(
"tencentAppId", "app-id",
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key"
"tencentSecretKey", "secret-key",
"tencentOfflineModelCode", "16k_zh",
"tencentRealtimeModelCode", "16k_zh_realtime"
));
service.saveModel(dto);
@ -426,7 +452,9 @@ class AiModelServiceImplTest {
ArgumentCaptor<AsrModel> captor = ArgumentCaptor.forClass(AsrModel.class);
verify(asrModelMapper, times(1)).insert(captor.capture());
assertEquals("tencent", captor.getValue().getProvider());
assertEquals("16k_zh", captor.getValue().getModelCode());
assertNull(captor.getValue().getModelCode());
assertEquals("16k_zh", captor.getValue().getMediaConfig().get("tencentOfflineModelCode"));
assertEquals("16k_zh_realtime", captor.getValue().getMediaConfig().get("tencentRealtimeModelCode"));
assertEquals("secret-key", captor.getValue().getMediaConfig().get("tencentSecretKey"));
assertNull(captor.getValue().getBaseUrl());
}

View File

@ -133,11 +133,13 @@ class AiTaskServiceImplTest {
)));
AiModelVO model = new AiModelVO();
model.setModelCode("16k_zh");
model.setModelCode("legacy-model-code");
model.setMediaConfig(Map.of(
"tencentAppId", "123456",
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key"
"tencentSecretKey", "secret-key",
"tencentOfflineModelCode", "16k_zh",
"tencentRealtimeModelCode", "16k_zh_realtime"
));
@SuppressWarnings("unchecked")
@ -211,11 +213,13 @@ class AiTaskServiceImplTest {
AiModelVO model = new AiModelVO();
model.setProvider("tencent");
model.setModelCode("16k_zh");
model.setModelCode("legacy-model-code");
model.setMediaConfig(Map.of(
"tencentAppId", "123456",
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key"
"tencentSecretKey", "secret-key",
"tencentOfflineModelCode", "16k_zh",
"tencentRealtimeModelCode", "16k_zh_realtime"
));
TaskStatus doing = new TaskStatus();

View File

@ -24,6 +24,7 @@ import {
testLocalModelConnectivity,
updateAiModel,
} from "../../api/business/aimodel";
import {getMeetingCreateConfig, type MeetingCreateConfig} from "../../api/business/meeting";
import AppPagination from "../../components/shared/AppPagination";
const { Option } = Select;
@ -31,6 +32,12 @@ const { Title } = Typography;
type ModelType = "ASR" | "LLM";
const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
offlineEnabled: true,
realtimeEnabled: false,
offlineAudioMaxSizeMb: 1024,
};
const PROVIDER_BASE_URL_MAP: Record<string, string> = {
openai: "https://api.openai.com",
deepseek: "https://api.deepseek.com",
@ -64,6 +71,7 @@ const AiModels: React.FC = () => {
const [connectivityLoading, setConnectivityLoading] = useState(false);
const [remoteModels, setRemoteModels] = useState<string[]>([]);
const [speakerModels, setSpeakerModels] = useState<string[]>([]);
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>(DEFAULT_CREATE_CONFIG);
const modelNameAutoFilledRef = useRef(false);
const localProfileLoadedRef = useRef(false);
@ -86,6 +94,20 @@ const AiModels: React.FC = () => {
void fetchData();
}, [current, size, searchName, activeType]);
useEffect(() => {
getMeetingCreateConfig()
.then((res) => {
const config = (res as any)?.data?.data ?? (res as any);
setCreateConfig({
...DEFAULT_CREATE_CONFIG,
...(config || {}),
});
})
.catch(() => {
setCreateConfig(DEFAULT_CREATE_CONFIG);
});
}, []);
useEffect(() => {
if (!drawerVisible || !provider) {
return;
@ -137,6 +159,8 @@ const AiModels: React.FC = () => {
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,
@ -145,6 +169,8 @@ const AiModels: React.FC = () => {
tencentAppId,
tencentSecretId,
tencentSecretKey,
tencentOfflineModelCode,
tencentRealtimeModelCode,
isDefaultChecked: record.isDefault === 1,
statusChecked: record.status === 1,
});
@ -261,8 +287,8 @@ const AiModels: React.FC = () => {
baseUrl: values.baseUrl,
apiPath: values.apiPath,
apiKey: values.apiKey,
modelCode: values.modelCode,
wsUrl: values.wsUrl,
modelCode: activeType === "ASR" && isTencentProvider ? values.tencentOfflineModelCode : values.modelCode,
wsUrl: activeType === "ASR" ? values.wsUrl : undefined,
mediaConfig:
activeType === "ASR" && isLocalProvider
? {
@ -274,6 +300,8 @@ const AiModels: React.FC = () => {
tencentAppId: values.tencentAppId,
tencentSecretId: values.tencentSecretId,
tencentSecretKey: values.tencentSecretKey,
tencentOfflineModelCode: values.tencentOfflineModelCode,
tencentRealtimeModelCode: values.tencentRealtimeModelCode,
}
: undefined,
temperature: values.temperature,
@ -567,13 +595,14 @@ const AiModels: React.FC = () => {
<Form.Item
label="模型名称"
required={activeType === "LLM"}
hidden={activeType === "ASR" && isTencentProvider}
tooltip="可从远程列表选择,也可手动输入;值将作为模型 code 传给后端"
>
<Space.Compact style={{ width: "100%" }}>
<Form.Item
name="modelCode"
noStyle
rules={activeType === "LLM" || isTencentProvider ? [{
rules={activeType === "LLM" ? [{
required: true,
message: "请输入或选择模型名称"
}] : []}
@ -602,7 +631,8 @@ const AiModels: React.FC = () => {
</Space.Compact>
</Form.Item>
<Form.Item name="wsUrl" label="WebSocket 地址" hidden>
<Form.Item name="wsUrl" label="WebSocket 地址"
hidden={!(activeType === "ASR" && createConfig.realtimeEnabled)}>
<Input placeholder="wss://api.example.com/v1/ws" />
</Form.Item>
@ -660,6 +690,24 @@ const AiModels: React.FC = () => {
<Input.Password/>
</Form.Item>
</Col>
<Col span={12}>
<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>
</Col>
</Row>
)}