配置ASR模型的页面优化

main
kangwenjing 2026-07-24 17:47:15 +08:00
parent 349cdf26e6
commit 2d502751f5
11 changed files with 952 additions and 112 deletions

View File

@ -21,10 +21,45 @@ public class SpeechRecognitionSchemaInitializer implements ApplicationRunner {
public void run(ApplicationArguments args) {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
statement.execute("""
create table if not exists speech_recognition_service (
id bigint generated by default as identity primary key,
service_type varchar(64) not null,
service_name varchar(128) not null,
built_in boolean not null default true,
enabled boolean not null default true,
base_url varchar(512),
transcribe_path varchar(255) not null default '/v1/audio/transcriptions',
task_status_path varchar(255) not null default '/v1/tasks/{task_id}',
file_field_name varchar(64) not null default 'file',
language varchar(32),
response_format varchar(32) not null default 'json',
model varchar(128),
prompt text,
hotwords text,
timestamp_granularities varchar(128),
enable_speaker_diarization boolean,
enable_speaker_identification boolean,
enable_text_cleanup boolean,
temperature double precision,
api_key varchar(512),
api_key_header varchar(128) not null default 'X-API-Key',
connect_timeout_seconds integer not null default 10,
read_timeout_seconds integer not null default 300,
task_timeout_seconds integer not null default 300,
task_poll_interval_millis integer not null default 1500,
sort_order integer not null default 100,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint uk_speech_recognition_service_type unique (service_type)
)
""");
statement.execute("alter table if exists speech_recognition_service add column if not exists built_in boolean not null default true");
statement.execute("""
create table if not exists speech_recognition_config (
id bigint generated by default as identity primary key,
tenant_id bigint not null,
service_type varchar(64) not null default 'CUSTOM',
enabled boolean not null default true,
base_url varchar(512),
transcribe_path varchar(255) not null default '/v1/audio/transcriptions',
@ -51,6 +86,7 @@ public class SpeechRecognitionSchemaInitializer implements ApplicationRunner {
constraint uk_speech_recognition_config_tenant unique (tenant_id)
)
""");
statement.execute("alter table if exists speech_recognition_config add column if not exists service_type varchar(64) not null default 'CUSTOM'");
statement.execute("alter table if exists speech_recognition_config add column if not exists response_format varchar(32) not null default 'json'");
statement.execute("alter table if exists speech_recognition_config add column if not exists model varchar(128)");
statement.execute("alter table if exists speech_recognition_config add column if not exists prompt text");

View File

@ -2,9 +2,14 @@ package com.unis.crm.controller;
import com.unis.crm.common.ApiResponse;
import com.unis.crm.dto.speech.SpeechRecognitionConfigDTO;
import com.unis.crm.dto.speech.SpeechRecognitionServiceDTO;
import com.unis.crm.service.SpeechRecognitionConfigService;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@ -27,6 +32,22 @@ public class SpeechRecognitionAdminController {
return ApiResponse.success(speechRecognitionConfigService.getConfig(tenantId));
}
@GetMapping("/speech-recognition-services")
public ApiResponse<List<SpeechRecognitionServiceDTO>> listSpeechRecognitionServices() {
return ApiResponse.success(speechRecognitionConfigService.listServices());
}
@PostMapping("/speech-recognition-services")
public ApiResponse<SpeechRecognitionServiceDTO> createSpeechRecognitionService(
@Valid @RequestBody SpeechRecognitionServiceDTO payload) {
return ApiResponse.success(speechRecognitionConfigService.saveCustomService(payload));
}
@DeleteMapping("/speech-recognition-services/{serviceType}")
public ApiResponse<Boolean> deleteSpeechRecognitionService(@PathVariable String serviceType) {
return ApiResponse.success(speechRecognitionConfigService.deleteCustomService(serviceType));
}
@PutMapping("/speech-recognition-config")
public ApiResponse<Boolean> updateSpeechRecognitionConfig(@Valid @RequestBody SpeechRecognitionConfigDTO payload) {
return ApiResponse.success(speechRecognitionConfigService.saveConfig(payload));

View File

@ -6,6 +6,7 @@ import jakarta.validation.constraints.Min;
public class SpeechRecognitionConfigDTO {
private Long tenantId;
private String serviceType = "CUSTOM";
private boolean enabled = true;
private String baseUrl;
private String transcribePath = "/v1/audio/transcriptions";
@ -48,6 +49,14 @@ public class SpeechRecognitionConfigDTO {
this.tenantId = tenantId;
}
public String getServiceType() {
return serviceType;
}
public void setServiceType(String serviceType) {
this.serviceType = serviceType;
}
public boolean isEnabled() {
return enabled;
}

View File

@ -0,0 +1,32 @@
package com.unis.crm.dto.speech;
public class SpeechRecognitionServiceDTO extends SpeechRecognitionConfigDTO {
private String serviceName;
private boolean builtIn = true;
private Integer sortOrder;
public String getServiceName() {
return serviceName;
}
public void setServiceName(String serviceName) {
this.serviceName = serviceName;
}
public boolean isBuiltIn() {
return builtIn;
}
public void setBuiltIn(boolean builtIn) {
this.builtIn = builtIn;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
}

View File

@ -3,11 +3,14 @@ package com.unis.crm.service;
import com.unis.crm.common.BusinessException;
import com.unis.crm.common.UnauthorizedException;
import com.unis.crm.dto.speech.SpeechRecognitionConfigDTO;
import com.unis.crm.dto.speech.SpeechRecognitionServiceDTO;
import com.unisbase.security.PermissionService;
import com.unisbase.security.SpringSecurityTenantProvider;
import java.net.URI;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@ -20,6 +23,7 @@ public class SpeechRecognitionConfigService {
private static final String VIEW_PERM = "speech_recognition_config:view";
private static final String UPDATE_PERM = "speech_recognition_config:update";
private static final long GLOBAL_TENANT_ID = 0L;
private static final String DEFAULT_SERVICE_TYPE = "CUSTOM";
private final JdbcTemplate jdbcTemplate;
private final PermissionService permissionService;
@ -38,7 +42,151 @@ public class SpeechRecognitionConfigService {
requirePermission(VIEW_PERM, "无权查看语音识别配置");
Long resolvedTenantId = resolveTenantId(tenantId);
SpeechRecognitionConfigDTO config = findConfig(resolvedTenantId);
return config == null ? defaultConfig(resolvedTenantId) : config;
return applyServicePreset(config == null ? defaultConfig(resolvedTenantId) : config);
}
public List<SpeechRecognitionServiceDTO> listServices() {
requirePermission(VIEW_PERM, "无权查看语音识别配置");
return jdbcTemplate.query("""
select service_type,
service_name,
built_in,
enabled,
base_url,
transcribe_path,
task_status_path,
file_field_name,
language,
response_format,
model,
prompt,
hotwords,
timestamp_granularities,
enable_speaker_diarization,
enable_speaker_identification,
enable_text_cleanup,
temperature,
api_key,
api_key_header,
connect_timeout_seconds,
read_timeout_seconds,
task_timeout_seconds,
task_poll_interval_millis,
sort_order
from speech_recognition_service
where enabled = true
order by sort_order, service_name, service_type
""", serviceRowMapper());
}
@Transactional
public SpeechRecognitionServiceDTO saveCustomService(SpeechRecognitionServiceDTO payload) {
requirePermission(UPDATE_PERM, "无权修改语音识别配置");
SpeechRecognitionServiceDTO normalized = normalizeCustomService(payload);
SpeechRecognitionServiceDTO existing = findAnyService(normalized.getServiceType());
if (existing != null && existing.isBuiltIn()) {
throw new BusinessException("预设语音服务不允许修改");
}
jdbcTemplate.update("""
insert into speech_recognition_service (
service_type,
service_name,
built_in,
enabled,
base_url,
transcribe_path,
task_status_path,
file_field_name,
language,
response_format,
model,
prompt,
hotwords,
timestamp_granularities,
enable_speaker_diarization,
enable_speaker_identification,
enable_text_cleanup,
temperature,
api_key,
api_key_header,
connect_timeout_seconds,
read_timeout_seconds,
task_timeout_seconds,
task_poll_interval_millis,
sort_order,
created_at,
updated_at
)
values (?, ?, false, true, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
on conflict (service_type) do update
set service_name = excluded.service_name,
enabled = excluded.enabled,
base_url = excluded.base_url,
transcribe_path = excluded.transcribe_path,
task_status_path = excluded.task_status_path,
file_field_name = excluded.file_field_name,
language = excluded.language,
response_format = excluded.response_format,
model = excluded.model,
prompt = excluded.prompt,
hotwords = excluded.hotwords,
timestamp_granularities = excluded.timestamp_granularities,
enable_speaker_diarization = excluded.enable_speaker_diarization,
enable_speaker_identification = excluded.enable_speaker_identification,
enable_text_cleanup = excluded.enable_text_cleanup,
temperature = excluded.temperature,
api_key = excluded.api_key,
api_key_header = excluded.api_key_header,
connect_timeout_seconds = excluded.connect_timeout_seconds,
read_timeout_seconds = excluded.read_timeout_seconds,
task_timeout_seconds = excluded.task_timeout_seconds,
task_poll_interval_millis = excluded.task_poll_interval_millis,
sort_order = excluded.sort_order,
updated_at = now()
""",
normalized.getServiceType(),
normalized.getServiceName(),
normalized.getBaseUrl(),
normalized.getTranscribePath(),
normalized.getTaskStatusPath(),
normalized.getFileFieldName(),
normalized.getLanguage(),
normalized.getResponseFormat(),
normalized.getModel(),
normalized.getPrompt(),
normalized.getHotwords(),
normalized.getTimestampGranularities(),
normalized.getEnableSpeakerDiarization(),
normalized.getEnableSpeakerIdentification(),
normalized.getEnableTextCleanup(),
normalized.getTemperature(),
normalized.getApiKey(),
normalized.getApiKeyHeader(),
normalized.getConnectTimeoutSeconds(),
normalized.getReadTimeoutSeconds(),
normalized.getTaskTimeoutSeconds(),
normalized.getTaskPollIntervalMillis(),
normalized.getSortOrder());
return findAnyService(normalized.getServiceType());
}
@Transactional
public boolean deleteCustomService(String serviceType) {
requirePermission(UPDATE_PERM, "无权修改语音识别配置");
String normalizedServiceType = trimToNull(serviceType);
if (normalizedServiceType == null || "CUSTOM".equalsIgnoreCase(normalizedServiceType)) {
return true;
}
SpeechRecognitionServiceDTO existing = findAnyService(normalizedServiceType);
if (existing == null) {
return true;
}
if (existing.isBuiltIn()) {
throw new BusinessException("预设语音服务不允许删除");
}
jdbcTemplate.update("delete from speech_recognition_service where service_type = ? and built_in = false", normalizedServiceType);
jdbcTemplate.update("update speech_recognition_config set service_type = 'CUSTOM', updated_at = now() where service_type = ?", normalizedServiceType);
return true;
}
@Transactional
@ -49,6 +197,7 @@ public class SpeechRecognitionConfigService {
jdbcTemplate.update("""
insert into speech_recognition_config (
tenant_id,
service_type,
enabled,
base_url,
transcribe_path,
@ -73,9 +222,10 @@ public class SpeechRecognitionConfigService {
created_at,
updated_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
on conflict (tenant_id) do update
set enabled = excluded.enabled,
set service_type = excluded.service_type,
enabled = excluded.enabled,
base_url = excluded.base_url,
transcribe_path = excluded.transcribe_path,
task_status_path = excluded.task_status_path,
@ -99,6 +249,7 @@ public class SpeechRecognitionConfigService {
updated_at = now()
""",
tenantId,
normalized.getServiceType(),
normalized.isEnabled(),
normalized.getBaseUrl(),
normalized.getTranscribePath(),
@ -131,17 +282,17 @@ public class SpeechRecognitionConfigService {
SpeechRecognitionConfigDTO tenantConfig = findConfig(resolvedTenantId);
if (tenantConfig != null) {
return tenantConfig;
return applyServicePreset(tenantConfig);
}
if (resolvedTenantId != GLOBAL_TENANT_ID) {
SpeechRecognitionConfigDTO globalConfig = findConfig(GLOBAL_TENANT_ID);
if (globalConfig != null) {
return globalConfig;
return applyServicePreset(globalConfig);
}
}
return defaultConfig(resolvedTenantId);
return applyServicePreset(defaultConfig(resolvedTenantId));
}
private SpeechRecognitionConfigDTO normalizeConfig(SpeechRecognitionConfigDTO payload, Long tenantId) {
@ -150,6 +301,7 @@ public class SpeechRecognitionConfigService {
SpeechRecognitionConfigDTO normalized = new SpeechRecognitionConfigDTO();
normalized.setTenantId(tenantId);
normalized.setServiceType(defaultIfBlank(config.getServiceType(), defaults.getServiceType()));
normalized.setEnabled(config.isEnabled());
normalized.setBaseUrl(defaultIfBlank(config.getBaseUrl(), defaults.getBaseUrl()));
normalized.setTranscribePath(defaultIfBlank(config.getTranscribePath(), defaults.getTranscribePath()));
@ -171,6 +323,7 @@ public class SpeechRecognitionConfigService {
normalized.setReadTimeoutSeconds(clamp(config.getReadTimeoutSeconds(), 1, 1800, defaults.getReadTimeoutSeconds()));
normalized.setTaskTimeoutSeconds(clamp(config.getTaskTimeoutSeconds(), 1, 1800, defaults.getTaskTimeoutSeconds()));
normalized.setTaskPollIntervalMillis(clamp(config.getTaskPollIntervalMillis(), 300, 60000, defaults.getTaskPollIntervalMillis()));
applyServicePreset(normalized);
if (normalized.isEnabled()) {
validateBaseUrl(normalized.getBaseUrl());
@ -181,6 +334,7 @@ public class SpeechRecognitionConfigService {
private SpeechRecognitionConfigDTO defaultConfig(Long tenantId) {
SpeechRecognitionConfigDTO config = new SpeechRecognitionConfigDTO();
config.setTenantId(tenantId);
config.setServiceType(DEFAULT_SERVICE_TYPE);
config.setEnabled(true);
config.setBaseUrl(null);
config.setTranscribePath("/v1/audio/transcriptions");
@ -203,6 +357,7 @@ public class SpeechRecognitionConfigService {
}
List<SpeechRecognitionConfigDTO> rows = jdbcTemplate.query("""
select tenant_id,
service_type,
enabled,
base_url,
transcribe_path,
@ -231,10 +386,132 @@ public class SpeechRecognitionConfigService {
return rows.isEmpty() ? null : rows.get(0);
}
private SpeechRecognitionServiceDTO findServicePreset(String serviceType) {
String normalizedServiceType = trimToNull(serviceType);
if (normalizedServiceType == null || "CUSTOM".equalsIgnoreCase(normalizedServiceType)) {
return null;
}
List<SpeechRecognitionServiceDTO> rows = jdbcTemplate.query("""
select service_type,
service_name,
built_in,
enabled,
base_url,
transcribe_path,
task_status_path,
file_field_name,
language,
response_format,
model,
prompt,
hotwords,
timestamp_granularities,
enable_speaker_diarization,
enable_speaker_identification,
enable_text_cleanup,
temperature,
api_key,
api_key_header,
connect_timeout_seconds,
read_timeout_seconds,
task_timeout_seconds,
task_poll_interval_millis,
sort_order
from speech_recognition_service
where service_type = ?
and enabled = true
limit 1
""", serviceRowMapper(), normalizedServiceType);
return rows.isEmpty() ? null : rows.get(0);
}
private SpeechRecognitionServiceDTO findAnyService(String serviceType) {
String normalizedServiceType = trimToNull(serviceType);
if (normalizedServiceType == null || "CUSTOM".equalsIgnoreCase(normalizedServiceType)) {
return null;
}
List<SpeechRecognitionServiceDTO> rows = jdbcTemplate.query("""
select service_type,
service_name,
built_in,
enabled,
base_url,
transcribe_path,
task_status_path,
file_field_name,
language,
response_format,
model,
prompt,
hotwords,
timestamp_granularities,
enable_speaker_diarization,
enable_speaker_identification,
enable_text_cleanup,
temperature,
api_key,
api_key_header,
connect_timeout_seconds,
read_timeout_seconds,
task_timeout_seconds,
task_poll_interval_millis,
sort_order
from speech_recognition_service
where service_type = ?
limit 1
""", serviceRowMapper(), normalizedServiceType);
return rows.isEmpty() ? null : rows.get(0);
}
private SpeechRecognitionConfigDTO applyServicePreset(SpeechRecognitionConfigDTO config) {
if (config == null) {
return null;
}
config.setServiceType(defaultIfBlank(config.getServiceType(), DEFAULT_SERVICE_TYPE));
SpeechRecognitionServiceDTO preset = findServicePreset(config.getServiceType());
if (preset == null) {
return config;
}
boolean forcePreset = preset.isBuiltIn();
config.setBaseUrl(forcePreset ? preset.getBaseUrl() : defaultIfBlank(config.getBaseUrl(), preset.getBaseUrl()));
config.setTranscribePath(forcePreset ? preset.getTranscribePath() : defaultIfBlank(config.getTranscribePath(), preset.getTranscribePath()));
config.setTaskStatusPath(forcePreset ? preset.getTaskStatusPath() : defaultIfBlank(config.getTaskStatusPath(), preset.getTaskStatusPath()));
config.setFileFieldName("file");
config.setLanguage(forcePreset ? preset.getLanguage() : defaultIfBlank(config.getLanguage(), preset.getLanguage()));
config.setResponseFormat(forcePreset ? preset.getResponseFormat() : defaultIfBlank(config.getResponseFormat(), preset.getResponseFormat()));
config.setModel(forcePreset ? preset.getModel() : defaultIfBlank(config.getModel(), preset.getModel()));
config.setPrompt(forcePreset ? preset.getPrompt() : defaultIfBlank(config.getPrompt(), preset.getPrompt()));
config.setHotwords(forcePreset ? preset.getHotwords() : defaultIfBlank(config.getHotwords(), preset.getHotwords()));
config.setTimestampGranularities(forcePreset ? preset.getTimestampGranularities() : defaultIfBlank(config.getTimestampGranularities(), preset.getTimestampGranularities()));
if (forcePreset || config.getEnableSpeakerDiarization() == null) {
config.setEnableSpeakerDiarization(preset.getEnableSpeakerDiarization());
}
if (forcePreset || config.getEnableSpeakerIdentification() == null) {
config.setEnableSpeakerIdentification(preset.getEnableSpeakerIdentification());
}
if (forcePreset || config.getEnableTextCleanup() == null) {
config.setEnableTextCleanup(preset.getEnableTextCleanup());
}
if (forcePreset || config.getTemperature() == null) {
config.setTemperature(preset.getTemperature());
}
config.setApiKey(forcePreset ? preset.getApiKey() : defaultIfBlank(config.getApiKey(), preset.getApiKey()));
config.setApiKeyHeader(forcePreset ? preset.getApiKeyHeader() : defaultIfBlank(config.getApiKeyHeader(), preset.getApiKeyHeader()));
if (forcePreset) {
config.setConnectTimeoutSeconds(preset.getConnectTimeoutSeconds());
config.setReadTimeoutSeconds(preset.getReadTimeoutSeconds());
config.setTaskTimeoutSeconds(preset.getTaskTimeoutSeconds());
config.setTaskPollIntervalMillis(preset.getTaskPollIntervalMillis());
}
return config;
}
private RowMapper<SpeechRecognitionConfigDTO> configRowMapper() {
return (resultSet, rowNum) -> {
SpeechRecognitionConfigDTO dto = new SpeechRecognitionConfigDTO();
dto.setTenantId(resultSet.getLong("tenant_id"));
dto.setServiceType(defaultIfBlank(resultSet.getString("service_type"), DEFAULT_SERVICE_TYPE));
dto.setEnabled(resultSet.getBoolean("enabled"));
dto.setBaseUrl(trimToNull(resultSet.getString("base_url")));
dto.setTranscribePath(trimToNull(resultSet.getString("transcribe_path")));
@ -260,6 +537,92 @@ public class SpeechRecognitionConfigService {
};
}
private RowMapper<SpeechRecognitionServiceDTO> serviceRowMapper() {
return (resultSet, rowNum) -> {
SpeechRecognitionServiceDTO dto = new SpeechRecognitionServiceDTO();
dto.setServiceType(defaultIfBlank(resultSet.getString("service_type"), DEFAULT_SERVICE_TYPE));
dto.setServiceName(defaultIfBlank(resultSet.getString("service_name"), dto.getServiceType()));
dto.setBuiltIn(resultSet.getBoolean("built_in"));
dto.setEnabled(resultSet.getBoolean("enabled"));
dto.setBaseUrl(trimToNull(resultSet.getString("base_url")));
dto.setTranscribePath(defaultIfBlank(resultSet.getString("transcribe_path"), "/v1/audio/transcriptions"));
dto.setTaskStatusPath(defaultIfBlank(resultSet.getString("task_status_path"), "/v1/tasks/{task_id}"));
dto.setFileFieldName("file");
dto.setLanguage(trimToNull(resultSet.getString("language")));
dto.setResponseFormat(defaultIfBlank(resultSet.getString("response_format"), "json"));
dto.setModel(trimToNull(resultSet.getString("model")));
dto.setPrompt(trimToNull(resultSet.getString("prompt")));
dto.setHotwords(trimToNull(resultSet.getString("hotwords")));
dto.setTimestampGranularities(trimToNull(resultSet.getString("timestamp_granularities")));
dto.setEnableSpeakerDiarization((Boolean) resultSet.getObject("enable_speaker_diarization"));
dto.setEnableSpeakerIdentification((Boolean) resultSet.getObject("enable_speaker_identification"));
dto.setEnableTextCleanup((Boolean) resultSet.getObject("enable_text_cleanup"));
dto.setTemperature((Double) resultSet.getObject("temperature"));
dto.setApiKey(trimToNull(resultSet.getString("api_key")));
dto.setApiKeyHeader(defaultIfBlank(resultSet.getString("api_key_header"), "X-API-Key"));
dto.setConnectTimeoutSeconds(resultSet.getLong("connect_timeout_seconds"));
dto.setReadTimeoutSeconds(resultSet.getLong("read_timeout_seconds"));
dto.setTaskTimeoutSeconds(resultSet.getLong("task_timeout_seconds"));
dto.setTaskPollIntervalMillis(resultSet.getLong("task_poll_interval_millis"));
dto.setSortOrder(resultSet.getInt("sort_order"));
return dto;
};
}
private SpeechRecognitionServiceDTO normalizeCustomService(SpeechRecognitionServiceDTO payload) {
if (payload == null) {
throw new BusinessException("语音服务不能为空");
}
String serviceName = trimToNull(payload.getServiceName());
if (serviceName == null) {
throw new BusinessException("请输入语音服务名称");
}
SpeechRecognitionConfigDTO defaults = defaultConfig(GLOBAL_TENANT_ID);
SpeechRecognitionServiceDTO normalized = new SpeechRecognitionServiceDTO();
normalized.setServiceType(normalizeCustomServiceType(payload.getServiceType()));
normalized.setServiceName(serviceName);
normalized.setBuiltIn(false);
normalized.setEnabled(true);
normalized.setBaseUrl(defaultIfBlank(payload.getBaseUrl(), defaults.getBaseUrl()));
normalized.setTranscribePath(defaultIfBlank(payload.getTranscribePath(), defaults.getTranscribePath()));
normalized.setTaskStatusPath(defaultIfBlank(payload.getTaskStatusPath(), defaults.getTaskStatusPath()));
normalized.setFileFieldName("file");
normalized.setLanguage(trimToNull(payload.getLanguage()));
normalized.setResponseFormat(defaultIfBlank(payload.getResponseFormat(), defaults.getResponseFormat()));
normalized.setModel(trimToNull(payload.getModel()));
normalized.setPrompt(trimToNull(payload.getPrompt()));
normalized.setHotwords(trimToNull(payload.getHotwords()));
normalized.setTimestampGranularities(trimToNull(payload.getTimestampGranularities()));
normalized.setEnableSpeakerDiarization(payload.getEnableSpeakerDiarization());
normalized.setEnableSpeakerIdentification(payload.getEnableSpeakerIdentification());
normalized.setEnableTextCleanup(payload.getEnableTextCleanup());
normalized.setTemperature(payload.getTemperature());
normalized.setApiKey(trimToNull(payload.getApiKey()));
normalized.setApiKeyHeader(defaultIfBlank(payload.getApiKeyHeader(), defaults.getApiKeyHeader()));
normalized.setConnectTimeoutSeconds(clamp(payload.getConnectTimeoutSeconds(), 1, 120, defaults.getConnectTimeoutSeconds()));
normalized.setReadTimeoutSeconds(clamp(payload.getReadTimeoutSeconds(), 1, 1800, defaults.getReadTimeoutSeconds()));
normalized.setTaskTimeoutSeconds(clamp(payload.getTaskTimeoutSeconds(), 1, 1800, defaults.getTaskTimeoutSeconds()));
normalized.setTaskPollIntervalMillis(clamp(payload.getTaskPollIntervalMillis(), 300, 60000, defaults.getTaskPollIntervalMillis()));
normalized.setSortOrder(payload.getSortOrder() == null || payload.getSortOrder() <= 0 ? 100 : payload.getSortOrder());
if (StringUtils.hasText(normalized.getBaseUrl())) {
validateBaseUrl(normalized.getBaseUrl());
}
return normalized;
}
private String normalizeCustomServiceType(String serviceType) {
String normalized = trimToNull(serviceType);
if (normalized == null || "CUSTOM".equalsIgnoreCase(normalized)) {
return "CUSTOM_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
}
normalized = normalized.trim().toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9_-]", "_");
if (!normalized.startsWith("CUSTOM_")) {
normalized = "CUSTOM_" + normalized;
}
return normalized.length() > 64 ? normalized.substring(0, 64) : normalized;
}
private void validateBaseUrl(String baseUrl) {
if (!StringUtils.hasText(baseUrl)) {
throw new BusinessException("语音识别服务地址不能为空");

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UnisBase - 智能会议系统</title>
<script type="module" crossorigin src="/assets/index-BBoOWaLk.js"></script>
<script type="module" crossorigin src="/assets/index-CdI-T26o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
</head>
<body>

View File

@ -1,11 +1,26 @@
import http from "@/api/http";
import type { SpeechRecognitionConfig } from "./types";
import type { SpeechRecognitionConfig, SpeechRecognitionService } from "./types";
export async function getSpeechRecognitionConfig(tenantId?: number) {
const resp = await http.get("/sys/api/admin/speech-recognition-config", { params: { tenantId } });
return resp.data.data as SpeechRecognitionConfig;
}
export async function getSpeechRecognitionServices() {
const resp = await http.get("/sys/api/admin/speech-recognition-services");
return resp.data.data as SpeechRecognitionService[];
}
export async function createSpeechRecognitionService(payload: Partial<SpeechRecognitionService> & Pick<SpeechRecognitionService, "serviceName">) {
const resp = await http.post("/sys/api/admin/speech-recognition-services", payload);
return resp.data.data as SpeechRecognitionService;
}
export async function deleteSpeechRecognitionService(serviceType: string) {
const resp = await http.delete(`/sys/api/admin/speech-recognition-services/${encodeURIComponent(serviceType)}`);
return resp.data.data as boolean;
}
export async function updateSpeechRecognitionConfig(payload: SpeechRecognitionConfig) {
const resp = await http.put("/sys/api/admin/speech-recognition-config", payload);
return resp.data.data as boolean;

View File

@ -56,6 +56,29 @@
width: 100%;
}
.speech-recognition-service-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.speech-recognition-service-option > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.speech-recognition-service-divider {
margin: 8px 0;
}
.speech-recognition-service-add {
width: 100%;
justify-content: flex-start;
}
.speech-recognition-hint {
margin: 4px 0 0;
}

View File

@ -5,9 +5,11 @@ import {
Card,
Col,
Collapse,
Divider,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Skeleton,
@ -18,23 +20,34 @@ import {
import {
ApiOutlined,
AudioOutlined,
ClockCircleOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
SaveOutlined,
SecurityScanOutlined
SaveOutlined
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import PageHeader from "@/components/shared/PageHeader";
import { getSpeechRecognitionConfig, updateSpeechRecognitionConfig } from "@/features/speech-recognition/api";
import type { SpeechRecognitionConfig } from "@/features/speech-recognition/types";
import {
createSpeechRecognitionService,
deleteSpeechRecognitionService,
getSpeechRecognitionConfig,
getSpeechRecognitionServices,
updateSpeechRecognitionConfig
} from "@/features/speech-recognition/api";
import type { SpeechRecognitionConfig, SpeechRecognitionService } from "@/features/speech-recognition/types";
import "./index.less";
const { Text, Paragraph } = Typography;
const { Text } = Typography;
const { Panel } = Collapse;
interface CustomServiceFormValues {
serviceName: string;
}
const DEFAULT_CONFIG: SpeechRecognitionConfig = {
serviceType: "",
enabled: true,
baseUrl: "http://10.211.147.11:17003/",
baseUrl: "",
transcribePath: "/v1/audio/transcriptions",
taskStatusPath: "/v1/tasks/{task_id}",
fileFieldName: "file",
@ -70,6 +83,7 @@ function normalizePayload(values: SpeechRecognitionConfig, tenantId?: number): S
...DEFAULT_CONFIG,
...values,
tenantId,
serviceType: values.serviceType?.trim() || "",
baseUrl: values.baseUrl?.trim() || "",
transcribePath: values.transcribePath?.trim() || DEFAULT_CONFIG.transcribePath,
taskStatusPath: values.taskStatusPath?.trim() || DEFAULT_CONFIG.taskStatusPath,
@ -85,19 +99,94 @@ function normalizePayload(values: SpeechRecognitionConfig, tenantId?: number): S
};
}
function applyServicePreset(
values: SpeechRecognitionConfig,
service?: SpeechRecognitionService
): SpeechRecognitionConfig {
if (!service) {
return { ...values, serviceType: values.serviceType || "" };
}
return {
...values,
serviceType: service.serviceType,
baseUrl: service.baseUrl?.trim() || values.baseUrl || "",
transcribePath: service.transcribePath || DEFAULT_CONFIG.transcribePath,
taskStatusPath: service.taskStatusPath || DEFAULT_CONFIG.taskStatusPath,
fileFieldName: "file",
language: service.language ?? values.language ?? "",
responseFormat: service.responseFormat || DEFAULT_CONFIG.responseFormat,
model: service.model ?? values.model ?? "",
prompt: service.prompt ?? values.prompt ?? "",
hotwords: service.hotwords ?? values.hotwords ?? "",
timestampGranularities: service.timestampGranularities ?? values.timestampGranularities ?? "",
enableSpeakerDiarization: service.enableSpeakerDiarization ?? values.enableSpeakerDiarization,
enableSpeakerIdentification: service.enableSpeakerIdentification ?? values.enableSpeakerIdentification,
enableTextCleanup: service.enableTextCleanup ?? values.enableTextCleanup,
temperature: service.temperature ?? values.temperature,
apiKey: service.apiKey?.trim() || values.apiKey || "",
apiKeyHeader: service.apiKeyHeader?.trim() || DEFAULT_CONFIG.apiKeyHeader,
connectTimeoutSeconds: service.connectTimeoutSeconds || DEFAULT_CONFIG.connectTimeoutSeconds,
readTimeoutSeconds: service.readTimeoutSeconds || DEFAULT_CONFIG.readTimeoutSeconds,
taskTimeoutSeconds: service.taskTimeoutSeconds || DEFAULT_CONFIG.taskTimeoutSeconds,
taskPollIntervalMillis: service.taskPollIntervalMillis || DEFAULT_CONFIG.taskPollIntervalMillis
};
}
function sortServices(services: SpeechRecognitionService[]) {
return [...services].sort((left, right) => {
const sortDelta = (left.sortOrder ?? 100) - (right.sortOrder ?? 100);
if (sortDelta !== 0) {
return sortDelta;
}
return (left.serviceName || left.serviceType).localeCompare(right.serviceName || right.serviceType, "zh-Hans-CN");
});
}
export default function SpeechRecognitionSettings() {
const { message } = App.useApp();
const { message, modal } = App.useApp();
const [form] = Form.useForm<SpeechRecognitionConfig>();
const [serviceForm] = Form.useForm<CustomServiceFormValues>();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [savingService, setSavingService] = useState(false);
const [customServiceModalOpen, setCustomServiceModalOpen] = useState(false);
const [services, setServices] = useState<SpeechRecognitionService[]>([]);
const tenantId = useMemo(() => resolveActiveTenantId(), []);
const selectedServiceType = Form.useWatch("serviceType", form);
const selectedService = useMemo(
() => services.find((service) => service.serviceType === selectedServiceType),
[selectedServiceType, services]
);
const serviceParamsDisabled = selectedService ? selectedService.builtIn !== false : false;
const serviceOptions = useMemo(() => {
return services.map((service) => ({
label: service.serviceName || service.serviceType,
value: service.serviceType
}));
}, [services]);
const loadConfig = useCallback(async () => {
setLoading(true);
try {
const data = await getSpeechRecognitionConfig(tenantId);
form.setFieldsValue({ ...DEFAULT_CONFIG, ...data });
const [data, serviceData] = await Promise.all([
getSpeechRecognitionConfig(tenantId),
getSpeechRecognitionServices()
]);
const enabledServices = sortServices(
(serviceData || [])
.filter((service) => service.enabled)
.map((service) => ({ ...service, builtIn: service.builtIn !== false }))
);
setServices(enabledServices);
const values = {
...DEFAULT_CONFIG,
...data,
serviceType: data?.serviceType || ""
};
const service = enabledServices.find((item) => item.serviceType === values.serviceType) || enabledServices[0];
form.setFieldsValue(applyServicePreset({ ...values, serviceType: service?.serviceType || "" }, service));
} catch (error: any) {
message.error(error.message || "语音识别配置加载失败");
} finally {
@ -128,8 +217,87 @@ export default function SpeechRecognitionSettings() {
}, [form, message, tenantId]);
const handleResetDefaults = useCallback(() => {
form.setFieldsValue({ ...DEFAULT_CONFIG, tenantId });
}, [form, tenantId]);
const serviceType = form.getFieldValue("serviceType") || services[0]?.serviceType || "";
const service = services.find((item) => item.serviceType === serviceType);
form.setFieldsValue(applyServicePreset({ ...DEFAULT_CONFIG, tenantId, serviceType }, service));
}, [form, services, tenantId]);
const handleServiceChange = useCallback((serviceType: string) => {
const currentValues = {
...DEFAULT_CONFIG,
...form.getFieldsValue(true),
serviceType
};
const service = services.find((item) => item.serviceType === serviceType);
form.setFieldsValue(applyServicePreset(currentValues, service));
}, [form, services]);
const handleOpenCustomServiceModal = useCallback(() => {
serviceForm.resetFields();
setCustomServiceModalOpen(true);
}, [serviceForm]);
const handleCreateCustomService = useCallback(async () => {
try {
const { serviceName } = await serviceForm.validateFields();
const payload: Partial<SpeechRecognitionService> & Pick<SpeechRecognitionService, "serviceName"> = {
serviceType: "",
serviceName: serviceName.trim(),
builtIn: false,
enabled: true
};
setSavingService(true);
const service = await createSpeechRecognitionService(payload);
setServices((prev) => sortServices([
...prev.filter((item) => item.serviceType !== service.serviceType),
service
]));
form.setFieldsValue(applyServicePreset({
...DEFAULT_CONFIG,
tenantId,
enabled: form.getFieldValue("enabled") ?? DEFAULT_CONFIG.enabled,
serviceType: service.serviceType
}, service));
setCustomServiceModalOpen(false);
message.success("自定义语音服务已添加");
} catch (error: any) {
if (error?.errorFields) {
return;
}
message.error(error.message || "添加自定义语音服务失败");
} finally {
setSavingService(false);
}
}, [form, message, serviceForm, tenantId]);
const handleDeleteCustomService = useCallback((service: SpeechRecognitionService) => {
if (service.builtIn !== false) {
message.warning("预设语音服务不允许删除");
return;
}
modal.confirm({
title: "删除自定义语音服务",
content: `确定删除“${service.serviceName || service.serviceType}”?`,
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
await deleteSpeechRecognitionService(service.serviceType);
const nextServices = services.filter((item) => item.serviceType !== service.serviceType);
setServices(nextServices);
if (form.getFieldValue("serviceType") === service.serviceType) {
const nextService = nextServices[0];
form.setFieldsValue(applyServicePreset({
...DEFAULT_CONFIG,
tenantId,
enabled: form.getFieldValue("enabled") ?? DEFAULT_CONFIG.enabled,
serviceType: nextService?.serviceType || ""
}, nextService));
}
message.success("自定义语音服务已删除");
}
});
}, [form, message, modal, services, tenantId]);
return (
<div className="app-page speech-recognition-page">
@ -143,7 +311,7 @@ export default function SpeechRecognitionSettings() {
className="speech-recognition-alert"
type="info"
showIcon
message="保存后立即影响当前租户的日报语音转文字功能;上传字段固定为 OpenAI Audio API 兼容的 file。"
message="语音服务下拉项来自数据库预设;保存后立即影响当前租户的日报语音转文字功能。"
/>
<Card className="app-page__content-card speech-recognition-card" styles={{ body: { padding: 24 } }}>
@ -182,7 +350,57 @@ export default function SpeechRecognitionSettings() {
<span></span>
</div>
<Row gutter={[24, 8]}>
<Col xs={24}>
<Col xs={24} md={12}>
<Form.Item name="serviceType" label="语音服务" rules={[{ required: true, message: "请选择语音服务" }]}>
<Select
placeholder="请选择语音服务"
options={serviceOptions}
onChange={handleServiceChange}
optionRender={(option) => {
const service = services.find((item) => item.serviceType === option.value);
return (
<div className="speech-recognition-service-option">
<span>{option.label}</span>
{service && service.builtIn === false ? (
<Button
type="text"
danger
size="small"
title="删除自定义服务"
icon={<DeleteOutlined />}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDeleteCustomService(service);
}}
/>
) : null}
</div>
);
}}
dropdownRender={(menu) => (
<>
{menu}
<Divider className="speech-recognition-service-divider" />
<Button
type="text"
icon={<PlusOutlined />}
className="speech-recognition-service-add"
onMouseDown={(event) => event.preventDefault()}
onClick={handleOpenCustomServiceModal}
>
</Button>
</>
)}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="baseUrl"
label="服务 Base URL"
@ -191,35 +409,12 @@ export default function SpeechRecognitionSettings() {
{ type: "url", warningOnly: true, message: "建议填写完整 http/https 地址" }
]}
>
<Input placeholder="http://10.211.147.11:17003/" allowClear />
<Input placeholder="https://asr.example.com/" allowClear disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="transcribePath" label="转写接口路径" rules={[{ required: true, message: "请输入转写接口路径" }]}>
<Input placeholder="/v1/audio/transcriptions" allowClear />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="taskStatusPath" label="任务状态路径" rules={[{ required: true, message: "请输入任务状态路径" }]}>
<Input placeholder="/v1/tasks/{task_id}" allowClear />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="language" label="识别语言">
<Input placeholder="留空则不传 language 参数" allowClear />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="responseFormat" label="输出格式" rules={[{ required: true, message: "请选择输出格式" }]}>
<Select
options={[
{ label: "json", value: "json" },
{ label: "text", value: "text" },
{ label: "verbose_json", value: "verbose_json" },
{ label: "srt", value: "srt" },
{ label: "vtt", value: "vtt" }
]}
/>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="留空则不发送鉴权头" autoComplete="new-password" disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
</Row>
@ -233,104 +428,102 @@ export default function SpeechRecognitionSettings() {
<Row gutter={[24, 8]}>
<Col xs={24} md={12}>
<Form.Item name="enableSpeakerDiarization" label="说话人分离" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
</Col>
<Col xs={24}>
<Paragraph type="secondary" className="speech-recognition-hint">
</Paragraph>
</Col>
</Row>
</div>
<div className="speech-recognition-section">
<div className="speech-recognition-section__title">
<SecurityScanOutlined />
<span></span>
</div>
<Row gutter={[24, 8]}>
<Col xs={24} md={12}>
<Form.Item name="apiKeyHeader" label="API Key 请求头" rules={[{ required: true, message: "请输入请求头名称" }]}>
<Input placeholder="X-API-Key" allowClear />
<Switch checkedChildren="开启" unCheckedChildren="关闭" disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="留空则不发送鉴权头" autoComplete="new-password" />
<Form.Item name="language" label="识别语言">
<Input placeholder="留空则不传 language 参数" allowClear disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="responseFormat" label="输出格式" rules={[{ required: true, message: "请选择输出格式" }]}>
<Select
disabled={serviceParamsDisabled}
options={[
{ label: "json", value: "json" },
{ label: "text", value: "text" },
{ label: "verbose_json", value: "verbose_json" },
{ label: "srt", value: "srt" },
{ label: "vtt", value: "vtt" }
]}
/>
</Form.Item>
</Col>
</Row>
</div>
<div className="speech-recognition-section">
<div className="speech-recognition-section__title">
<ClockCircleOutlined />
<span></span>
</div>
<Row gutter={[24, 8]}>
<Col xs={24} md={12} lg={6}>
<Form.Item name="connectTimeoutSeconds" label="连接超时(秒)" rules={[{ required: true }]}>
<InputNumber min={1} max={120} precision={0} className="speech-recognition-number" />
</Form.Item>
</Col>
<Col xs={24} md={12} lg={6}>
<Form.Item name="readTimeoutSeconds" label="读取超时(秒)" rules={[{ required: true }]}>
<InputNumber min={1} max={1800} precision={0} className="speech-recognition-number" />
</Form.Item>
</Col>
<Col xs={24} md={12} lg={6}>
<Form.Item name="taskTimeoutSeconds" label="任务超时(秒)" rules={[{ required: true }]}>
<InputNumber min={1} max={1800} precision={0} className="speech-recognition-number" />
</Form.Item>
</Col>
<Col xs={24} md={12} lg={6}>
<Form.Item name="taskPollIntervalMillis" label="轮询间隔(毫秒)" rules={[{ required: true }]}>
<InputNumber min={300} max={60000} step={100} precision={0} className="speech-recognition-number" />
</Form.Item>
</Col>
</Row>
<Paragraph type="secondary" className="speech-recognition-hint">
<Text code>{"{task_id}"}</Text> <Text code>{"{taskId}"}</Text>
</Paragraph>
</div>
<Collapse ghost className="speech-recognition-advanced">
<Panel header="高级参数" key="advanced">
<Row gutter={[24, 8]}>
<Col xs={24} md={12}>
<Form.Item name="transcribePath" label="转写接口路径" rules={[{ required: true, message: "请输入转写接口路径" }]}>
<Input placeholder="/v1/audio/transcriptions" allowClear disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="taskStatusPath" label="任务状态路径" rules={[{ required: true, message: "请输入任务状态路径" }]}>
<Input placeholder="/v1/tasks/{task_id}" allowClear disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="apiKeyHeader" label="API Key 请求头" rules={[{ required: true, message: "请输入请求头名称" }]}>
<Input placeholder="X-API-Key" allowClear disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12} lg={6}>
<Form.Item name="connectTimeoutSeconds" label="连接超时(秒)" rules={[{ required: true }]}>
<InputNumber min={1} max={120} precision={0} className="speech-recognition-number" disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12} lg={6}>
<Form.Item name="readTimeoutSeconds" label="读取超时(秒)" rules={[{ required: true }]}>
<InputNumber min={1} max={1800} precision={0} className="speech-recognition-number" disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12} lg={6}>
<Form.Item name="taskTimeoutSeconds" label="任务超时(秒)" rules={[{ required: true }]}>
<InputNumber min={1} max={1800} precision={0} className="speech-recognition-number" disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12} lg={6}>
<Form.Item name="taskPollIntervalMillis" label="轮询间隔(毫秒)" rules={[{ required: true }]}>
<InputNumber min={300} max={60000} step={100} precision={0} className="speech-recognition-number" disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="model" label="模型">
<Input placeholder="留空使用服务端默认模型" allowClear />
<Input placeholder="留空使用服务端默认模型" allowClear disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="enableSpeakerIdentification" label="说话人识别" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
<Switch checkedChildren="开启" unCheckedChildren="关闭" disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="enableTextCleanup" label="文本清理" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
<Switch checkedChildren="开启" unCheckedChildren="关闭" disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="hotwords" label="热词">
<Input placeholder="热词1 权重1 热词2 权重2" allowClear />
<Input placeholder="热词1 权重1 热词2 权重2" allowClear disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="prompt" label="提示词">
<Input placeholder="可作为热词提示的兼容入口" allowClear />
<Input placeholder="可作为热词提示的兼容入口" allowClear disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="timestampGranularities" label="时间戳粒度">
<Input placeholder="例如 segment 或 word" allowClear />
<Input placeholder="例如 segment 或 word" allowClear disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="temperature" label="Temperature">
<InputNumber min={0} max={2} step={0.1} className="speech-recognition-number" />
<InputNumber min={0} max={2} step={0.1} className="speech-recognition-number" disabled={serviceParamsDisabled} />
</Form.Item>
</Col>
</Row>
@ -352,8 +545,31 @@ export default function SpeechRecognitionSettings() {
</div>
</Form>
)}
</Card>
</Card>
<Modal
title="添加自定义语音服务"
open={customServiceModalOpen}
okText="添加"
cancelText="取消"
confirmLoading={savingService}
destroyOnClose
onOk={handleCreateCustomService}
onCancel={() => setCustomServiceModalOpen(false)}
>
<Form form={serviceForm} layout="vertical">
<Form.Item
name="serviceName"
label="服务名称"
rules={[
{ required: true, message: "请输入服务名称" },
{ max: 64, message: "服务名称不能超过 64 个字符" }
]}
>
<Input placeholder="例如:我的转写服务" maxLength={64} allowClear />
</Form.Item>
</Form>
</Modal>
</div>
</div>
</div>
);
);
}

View File

@ -1,5 +1,6 @@
export interface SpeechRecognitionConfig {
tenantId?: number;
serviceType: string;
enabled: boolean;
baseUrl: string;
transcribePath: string;
@ -22,3 +23,9 @@ export interface SpeechRecognitionConfig {
taskTimeoutSeconds: number;
taskPollIntervalMillis: number;
}
export interface SpeechRecognitionService extends SpeechRecognitionConfig {
serviceName: string;
builtIn: boolean;
sortOrder?: number;
}

View File

@ -2,9 +2,44 @@ begin;
set search_path to public;
create table if not exists speech_recognition_service (
id bigint generated by default as identity primary key,
service_type varchar(64) not null,
service_name varchar(128) not null,
built_in boolean not null default true,
enabled boolean not null default true,
base_url varchar(512),
transcribe_path varchar(255) not null default '/v1/audio/transcriptions',
task_status_path varchar(255) not null default '/v1/tasks/{task_id}',
file_field_name varchar(64) not null default 'file',
language varchar(32),
response_format varchar(32) not null default 'json',
model varchar(128),
prompt text,
hotwords text,
timestamp_granularities varchar(128),
enable_speaker_diarization boolean,
enable_speaker_identification boolean,
enable_text_cleanup boolean,
temperature double precision,
api_key varchar(512),
api_key_header varchar(128) not null default 'X-API-Key',
connect_timeout_seconds integer not null default 10,
read_timeout_seconds integer not null default 300,
task_timeout_seconds integer not null default 300,
task_poll_interval_millis integer not null default 1500,
sort_order integer not null default 100,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint uk_speech_recognition_service_type unique (service_type)
);
alter table if exists speech_recognition_service add column if not exists built_in boolean not null default true;
create table if not exists speech_recognition_config (
id bigint generated by default as identity primary key,
tenant_id bigint not null,
service_type varchar(64) not null default 'CUSTOM',
enabled boolean not null default true,
base_url varchar(512),
transcribe_path varchar(255) not null default '/v1/audio/transcriptions',
@ -31,6 +66,7 @@ create table if not exists speech_recognition_config (
constraint uk_speech_recognition_config_tenant unique (tenant_id)
);
alter table if exists speech_recognition_config add column if not exists service_type varchar(64) not null default 'CUSTOM';
alter table if exists speech_recognition_config add column if not exists response_format varchar(32) not null default 'json';
alter table if exists speech_recognition_config add column if not exists model varchar(128);
alter table if exists speech_recognition_config add column if not exists prompt text;
@ -41,6 +77,88 @@ alter table if exists speech_recognition_config add column if not exists enable_
alter table if exists speech_recognition_config add column if not exists enable_text_cleanup boolean;
alter table if exists speech_recognition_config add column if not exists temperature double precision;
insert into speech_recognition_service (
service_type,
service_name,
built_in,
enabled,
base_url,
transcribe_path,
task_status_path,
file_field_name,
language,
response_format,
api_key,
api_key_header,
connect_timeout_seconds,
read_timeout_seconds,
task_timeout_seconds,
task_poll_interval_millis,
sort_order,
created_at,
updated_at
) values
(
'ASR_10_100_53_199_8000',
'语音转写服务 10.100.53.199:8000',
true,
true,
'http://10.100.53.199:8000/',
'/v1/audio/transcriptions',
'/v1/tasks/{task_id}',
'file',
'',
'json',
null,
'X-API-Key',
10,
300,
300,
1500,
10,
now(),
now()
),
(
'ASR_10_100_51_199_17003',
'语音转写服务 10.100.51.199:17003',
true,
true,
'http://10.100.51.199:17003/',
'/v1/audio/transcriptions',
'/v1/tasks/{task_id}',
'file',
'',
'json',
null,
'X-API-Key',
10,
300,
300,
1500,
20,
now(),
now()
)
on conflict (service_type) do update
set service_name = excluded.service_name,
built_in = true,
enabled = true,
base_url = excluded.base_url,
transcribe_path = excluded.transcribe_path,
task_status_path = excluded.task_status_path,
file_field_name = excluded.file_field_name,
language = excluded.language,
response_format = excluded.response_format,
api_key = excluded.api_key,
api_key_header = excluded.api_key_header,
connect_timeout_seconds = excluded.connect_timeout_seconds,
read_timeout_seconds = excluded.read_timeout_seconds,
task_timeout_seconds = excluded.task_timeout_seconds,
task_poll_interval_millis = excluded.task_poll_interval_millis,
sort_order = excluded.sort_order,
updated_at = now();
do $$
declare
v_system_parent_id bigint;