From 2d502751f50c8e0ce5b088dcf756c7aad72eb789 Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Fri, 24 Jul 2026 17:47:15 +0800 Subject: [PATCH] =?UTF-8?q?=E9=85=8D=E7=BD=AEASR=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9A=84=E9=A1=B5=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpeechRecognitionSchemaInitializer.java | 36 ++ .../SpeechRecognitionAdminController.java | 21 + .../speech/SpeechRecognitionConfigDTO.java | 9 + .../speech/SpeechRecognitionServiceDTO.java | 32 ++ .../SpeechRecognitionConfigService.java | 375 +++++++++++++++- frontend1/dist/index.html | 2 +- .../src/features/speech-recognition/api.ts | 17 +- .../speech-recognition-settings/index.less | 23 + .../speech-recognition-settings/index.tsx | 424 +++++++++++++----- .../src/features/speech-recognition/types.ts | 7 + sql/init_speech_recognition_config_pg17.sql | 118 +++++ 11 files changed, 952 insertions(+), 112 deletions(-) create mode 100644 backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionServiceDTO.java diff --git a/backend/src/main/java/com/unis/crm/common/SpeechRecognitionSchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/SpeechRecognitionSchemaInitializer.java index c06fb05d..262bda88 100644 --- a/backend/src/main/java/com/unis/crm/common/SpeechRecognitionSchemaInitializer.java +++ b/backend/src/main/java/com/unis/crm/common/SpeechRecognitionSchemaInitializer.java @@ -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"); diff --git a/backend/src/main/java/com/unis/crm/controller/SpeechRecognitionAdminController.java b/backend/src/main/java/com/unis/crm/controller/SpeechRecognitionAdminController.java index f1e654de..3df4033e 100644 --- a/backend/src/main/java/com/unis/crm/controller/SpeechRecognitionAdminController.java +++ b/backend/src/main/java/com/unis/crm/controller/SpeechRecognitionAdminController.java @@ -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> listSpeechRecognitionServices() { + return ApiResponse.success(speechRecognitionConfigService.listServices()); + } + + @PostMapping("/speech-recognition-services") + public ApiResponse createSpeechRecognitionService( + @Valid @RequestBody SpeechRecognitionServiceDTO payload) { + return ApiResponse.success(speechRecognitionConfigService.saveCustomService(payload)); + } + + @DeleteMapping("/speech-recognition-services/{serviceType}") + public ApiResponse deleteSpeechRecognitionService(@PathVariable String serviceType) { + return ApiResponse.success(speechRecognitionConfigService.deleteCustomService(serviceType)); + } + @PutMapping("/speech-recognition-config") public ApiResponse updateSpeechRecognitionConfig(@Valid @RequestBody SpeechRecognitionConfigDTO payload) { return ApiResponse.success(speechRecognitionConfigService.saveConfig(payload)); diff --git a/backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionConfigDTO.java b/backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionConfigDTO.java index b91dd997..29a7e5ee 100644 --- a/backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionConfigDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionConfigDTO.java @@ -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; } diff --git a/backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionServiceDTO.java b/backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionServiceDTO.java new file mode 100644 index 00000000..c5f8067f --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionServiceDTO.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/service/SpeechRecognitionConfigService.java b/backend/src/main/java/com/unis/crm/service/SpeechRecognitionConfigService.java index 32b88def..e7257721 100644 --- a/backend/src/main/java/com/unis/crm/service/SpeechRecognitionConfigService.java +++ b/backend/src/main/java/com/unis/crm/service/SpeechRecognitionConfigService.java @@ -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 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 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 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 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 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 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("语音识别服务地址不能为空"); diff --git a/frontend1/dist/index.html b/frontend1/dist/index.html index 652a27b0..24cc7d5e 100644 --- a/frontend1/dist/index.html +++ b/frontend1/dist/index.html @@ -5,7 +5,7 @@ UnisBase - 智能会议系统 - + diff --git a/frontend1/src/features/speech-recognition/api.ts b/frontend1/src/features/speech-recognition/api.ts index d6355e89..52096cbd 100644 --- a/frontend1/src/features/speech-recognition/api.ts +++ b/frontend1/src/features/speech-recognition/api.ts @@ -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 & Pick) { + 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; diff --git a/frontend1/src/features/speech-recognition/pages/speech-recognition-settings/index.less b/frontend1/src/features/speech-recognition/pages/speech-recognition-settings/index.less index 3901ec06..3f386f25 100644 --- a/frontend1/src/features/speech-recognition/pages/speech-recognition-settings/index.less +++ b/frontend1/src/features/speech-recognition/pages/speech-recognition-settings/index.less @@ -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; } diff --git a/frontend1/src/features/speech-recognition/pages/speech-recognition-settings/index.tsx b/frontend1/src/features/speech-recognition/pages/speech-recognition-settings/index.tsx index a155f62b..45b38d36 100644 --- a/frontend1/src/features/speech-recognition/pages/speech-recognition-settings/index.tsx +++ b/frontend1/src/features/speech-recognition/pages/speech-recognition-settings/index.tsx @@ -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(); + const [serviceForm] = Form.useForm(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [savingService, setSavingService] = useState(false); + const [customServiceModalOpen, setCustomServiceModalOpen] = useState(false); + const [services, setServices] = useState([]); 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 & Pick = { + 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 (
@@ -143,7 +311,7 @@ export default function SpeechRecognitionSettings() { className="speech-recognition-alert" type="info" showIcon - message="保存后立即影响当前租户的日报语音转文字功能;上传字段固定为 OpenAI Audio API 兼容的 file。" + message="语音服务下拉项来自数据库预设;保存后立即影响当前租户的日报语音转文字功能。" /> @@ -182,7 +350,57 @@ export default function SpeechRecognitionSettings() { 服务接口
- + + + + - - - - - - - - - - - - - - - - - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - + - + - + - + @@ -352,8 +545,31 @@ export default function SpeechRecognitionSettings() { )} - + + setCustomServiceModalOpen(false)} + > +
+ + + +
+
+ - - ); + ); } diff --git a/frontend1/src/features/speech-recognition/types.ts b/frontend1/src/features/speech-recognition/types.ts index 10d0a7bd..f7e7ea77 100644 --- a/frontend1/src/features/speech-recognition/types.ts +++ b/frontend1/src/features/speech-recognition/types.ts @@ -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; +} diff --git a/sql/init_speech_recognition_config_pg17.sql b/sql/init_speech_recognition_config_pg17.sql index fbb5b4e3..be1826fd 100644 --- a/sql/init_speech_recognition_config_pg17.sql +++ b/sql/init_speech_recognition_config_pg17.sql @@ -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;