From 88fb28a22afb779b592222277b555cff9fdfe3b6 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 9 May 2026 13:57:52 +0800 Subject: [PATCH] feat(stt): route OpenAI Whisper provider to any OpenAI-compatible endpoint (#76) --- .../main/java/vip/mate/stt/SttTransport.java | 46 +++++++ .../java/vip/mate/stt/SttTransportConfig.java | 19 +++ .../mate/stt/provider/OpenAiSttProvider.java | 108 +++++++++------ .../OpenAiCompatibleSttTransport.java | 128 ++++++++++++++++++ .../mate/system/model/SystemSettingsDTO.java | 14 ++ .../system/service/SystemSettingService.java | 16 +++ mateclaw-ui/src/i18n/locales/en-US.ts | 7 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 7 + mateclaw-ui/src/views/Settings/Stt/index.vue | 118 +++++++++++++++- 9 files changed, 411 insertions(+), 52 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/stt/SttTransport.java create mode 100644 mateclaw-server/src/main/java/vip/mate/stt/SttTransportConfig.java create mode 100644 mateclaw-server/src/main/java/vip/mate/stt/transport/OpenAiCompatibleSttTransport.java diff --git a/mateclaw-server/src/main/java/vip/mate/stt/SttTransport.java b/mateclaw-server/src/main/java/vip/mate/stt/SttTransport.java new file mode 100644 index 00000000..01d5bc1e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/stt/SttTransport.java @@ -0,0 +1,46 @@ +package vip.mate.stt; + +/** + * Issue #76: protocol-family abstraction for STT. + * + *

The original {@link SttProvider} bundled "which vendor is this" with + * "how does its wire protocol work", forcing every new vendor to ship a + * dedicated Java class even when the wire protocol is identical to an + * existing one. {@code SttTransport} is the protocol-only half: it knows how + * to send a request and parse a response, but doesn't care whether the + * endpoint is OpenAI cloud, FunASR self-hosted, SiliconFlow, Groq, or + * Together — anything that speaks the same protocol can plug in. + * + *

Two transports cover ~99% of the market today: + *

+ * + *

Identity (display name, baseUrl defaults, language bias, ...) is + * declared by a future {@code SttProviderProfile} layer (Phase 2 of the + * refactor). Phase 1 keeps {@link SttProvider} as the public SPI but + * delegates the wire work to a transport so swapping the credential row + * doesn't require changing the provider class. + */ +public interface SttTransport { + + /** + * Stable id of the protocol family this transport speaks. Profiles + * pick a transport by matching against this — e.g. + * {@code "openai_compatible_audio"} for any OpenAI Whisper-shaped + * endpoint. + */ + String apiMode(); + + /** + * Run a transcription against the resolved endpoint. Returns a typed + * success/failure result; transport implementations must NOT throw — + * caller relies on the failure path to keep the {@link SttProvider} + * fallback chain alive. + */ + SttResult transcribe(SttRequest request, SttTransportConfig config); +} diff --git a/mateclaw-server/src/main/java/vip/mate/stt/SttTransportConfig.java b/mateclaw-server/src/main/java/vip/mate/stt/SttTransportConfig.java new file mode 100644 index 00000000..7e276764 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/stt/SttTransportConfig.java @@ -0,0 +1,19 @@ +package vip.mate.stt; + +/** + * Issue #76: resolved endpoint config passed to an {@link SttTransport}. + * + *

Decoupling the transport from {@code ModelProviderService} lookups makes + * tests trivial (no Spring context) and lets the same transport serve any + * credential row — OpenAI cloud, FunASR self-hosted, SiliconFlow, Groq, etc. + * + * @param baseUrl fully-qualified provider base URL ({@code https://api.openai.com} + * or {@code http://10.0.0.5:9999/v1}). Trailing slash optional; + * transports normalize it. + * @param apiKey bearer token. May be blank when the provider doesn't require + * authentication (some self-hosted FunASR deployments). + * @param model the model id sent in the multipart "model" field + * (whisper-1 / paraformer-large / FunAudioLLM-Whisper / ...). + */ +public record SttTransportConfig(String baseUrl, String apiKey, String model) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java b/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java index 1ac7a75e..3257b56b 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java @@ -1,23 +1,40 @@ package vip.mate.stt.provider; -import cn.hutool.http.HttpRequest; -import cn.hutool.http.HttpResponse; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import vip.mate.exception.MateClawException; +import vip.mate.llm.model.ModelProviderEntity; import vip.mate.llm.service.ModelProviderService; -import vip.mate.stt.AudioMimeTypes; import vip.mate.stt.SttProvider; import vip.mate.stt.SttRequest; import vip.mate.stt.SttResult; +import vip.mate.stt.SttTransportConfig; +import vip.mate.stt.transport.OpenAiCompatibleSttTransport; import vip.mate.system.model.SystemSettingsDTO; /** - * OpenAI STT Provider — Whisper / gpt-4o-mini-transcribe - *

- * 复用模型管理中的 OpenAI API Key。 + * OpenAI Whisper / OpenAI-compatible STT provider — thin wrapper. + * + *

Issue #76: this used to bake the {@code id="openai"} credential row + the + * {@code https://api.openai.com} base URL + Whisper-1 directly into the + * transport call, so the only way to point STT at FunASR / SiliconFlow / Groq + * was to hand-edit the OpenAI provider row's baseUrl (lossy + side-effects on + * chat). After this refactor: + * + *

+ * + *

The provider id stays {@code "openai"} because settings UI / fallback + * registry / per-language ordering all key off it. Phase 2 of the refactor + * will replace this single provider with a profile-driven registry; until + * then, swapping the credential row is the path forward for new vendors. */ @Slf4j @Component @@ -25,12 +42,13 @@ import vip.mate.system.model.SystemSettingsDTO; public class OpenAiSttProvider implements SttProvider { private final ModelProviderService modelProviderService; - private final ObjectMapper objectMapper; + private final OpenAiCompatibleSttTransport transport; - private static final String DEFAULT_MODEL = "whisper-1"; + private static final String LEGACY_DEFAULT_PROVIDER_ID = "openai"; + private static final String LEGACY_DEFAULT_MODEL = "whisper-1"; @Override public String id() { return "openai"; } - @Override public String label() { return "OpenAI Whisper"; } + @Override public String label() { return "OpenAI / OpenAI-compatible (Whisper)"; } @Override public boolean requiresCredential() { return true; } @Override public int autoDetectOrder() { return 100; } @@ -56,7 +74,8 @@ public class OpenAiSttProvider implements SttProvider { @Override public boolean isAvailable(SystemSettingsDTO config) { try { - return modelProviderService.isProviderConfigured("openai"); + String providerId = resolveProviderId(config); + return modelProviderService.isProviderConfigured(providerId); } catch (Exception e) { log.warn("[OpenAI STT] availability check failed: {}", e.getMessage()); return false; @@ -65,40 +84,39 @@ public class OpenAiSttProvider implements SttProvider { @Override public SttResult transcribe(SttRequest request, SystemSettingsDTO config) { + String providerId = resolveProviderId(config); + ModelProviderEntity provider; try { - String apiKey = modelProviderService.getProviderConfig("openai").getApiKey(); - String baseUrl = modelProviderService.getProviderConfig("openai").getBaseUrl(); - if (apiKey == null) return SttResult.failure("OpenAI API Key 未配置"); - - String url = (baseUrl != null ? baseUrl : "https://api.openai.com") + "/v1/audio/transcriptions"; - String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL; - // AudioMimeTypes ensures the filename extension matches the - // actual bytes (audio.wav, audio.mp3, etc.), which Hutool then - // uses to infer the multipart Content-Type. Don't pass - // contentType to .form() explicitly — Hutool has no - // form(String,byte[],String,String) overload, and the wrong - // dispatch crashes with ClassCastException on byte[] → Object[]. - String fileName = AudioMimeTypes.resolveFileName(request.getFileName(), request.getContentType()); - - HttpResponse response = HttpRequest.post(url) - .header("Authorization", "Bearer " + apiKey) - .form("model", model) - .form("file", request.getAudioData(), fileName) - .timeout(60_000) - .execute(); - - if (response.getStatus() == 200) { - JsonNode result = objectMapper.readTree(response.body()); - String text = result.path("text").asText(""); - log.info("[OpenAI STT] Transcribed {} chars (model={})", text.length(), model); - return SttResult.success(text); - } else { - log.warn("[OpenAI STT] Failed: HTTP {} - {}", response.getStatus(), response.body()); - return SttResult.failure("OpenAI STT 失败: HTTP " + response.getStatus()); - } - } catch (Exception e) { - log.error("[OpenAI STT] Error: {}", e.getMessage(), e); - return SttResult.failure("OpenAI STT 异常: " + e.getMessage()); + provider = modelProviderService.getProviderConfig(providerId); + } catch (MateClawException e) { + return SttResult.failure("STT 凭证 provider 未找到: " + providerId); } + + String apiKey = provider.getApiKey(); + String baseUrl = StringUtils.hasText(provider.getBaseUrl()) + ? provider.getBaseUrl() + : "https://api.openai.com"; + + // Allow blank apiKey for self-hosted / no-auth setups (FunASR is the + // typical case). The transport will only attach the Authorization + // header when apiKey is present. + boolean requiresKey = Boolean.TRUE.equals(provider.getRequireApiKey()); + if (requiresKey && (apiKey == null || apiKey.isBlank())) { + return SttResult.failure("STT 凭证 provider 未配置 API Key: " + providerId); + } + + String model = resolveModel(config); + SttTransportConfig transportConfig = new SttTransportConfig(baseUrl, apiKey, model); + return transport.transcribe(request, transportConfig); + } + + private String resolveProviderId(SystemSettingsDTO config) { + String configured = config != null ? config.getSttOpenAiCompatProviderId() : null; + return StringUtils.hasText(configured) ? configured.trim() : LEGACY_DEFAULT_PROVIDER_ID; + } + + private String resolveModel(SystemSettingsDTO config) { + String configured = config != null ? config.getSttOpenAiCompatModel() : null; + return StringUtils.hasText(configured) ? configured.trim() : LEGACY_DEFAULT_MODEL; } } diff --git a/mateclaw-server/src/main/java/vip/mate/stt/transport/OpenAiCompatibleSttTransport.java b/mateclaw-server/src/main/java/vip/mate/stt/transport/OpenAiCompatibleSttTransport.java new file mode 100644 index 00000000..6e28414b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/stt/transport/OpenAiCompatibleSttTransport.java @@ -0,0 +1,128 @@ +package vip.mate.stt.transport; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.stt.AudioMimeTypes; +import vip.mate.stt.SttRequest; +import vip.mate.stt.SttResult; +import vip.mate.stt.SttTransport; +import vip.mate.stt.SttTransportConfig; + +/** + * Issue #76: protocol family transport for the OpenAI Whisper-shaped HTTP + * audio endpoint. Identical request format covers OpenAI itself, FunASR with + * the openai-compat shim, SiliconFlow, Groq Whisper, Together, Volcano, + * and roughly every other paid + self-hosted ASR vendor available today. + * + *

Wire shape: + *

+ * + *

Response: {@code { "text": "..." }} — the only field we read. + * + *

The transport intentionally does NOT touch {@code ModelProviderService} + * or {@code SystemSettingsDTO}: the caller resolves credentials and hands + * them in via {@link SttTransportConfig}. This keeps the transport reusable + * across any number of credential rows and trivially unit-testable. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OpenAiCompatibleSttTransport implements SttTransport { + + public static final String API_MODE = "openai_compatible_audio"; + + private final ObjectMapper objectMapper; + + @Override + public String apiMode() { + return API_MODE; + } + + @Override + public SttResult transcribe(SttRequest request, SttTransportConfig config) { + try { + String baseUrl = normalizeBaseUrl(config.baseUrl()); + if (baseUrl == null) { + return SttResult.failure("STT 端点 base URL 未配置"); + } + String url = baseUrl + resolveAudioPath(baseUrl); + String model = effectiveModel(request, config); + // AudioMimeTypes ensures the filename extension matches the + // actual bytes (audio.wav, audio.mp3, etc.), which Hutool then + // uses to infer the multipart Content-Type. Don't pass + // contentType to .form() explicitly — Hutool has no + // form(String,byte[],String,String) overload, and the wrong + // dispatch crashes with ClassCastException on byte[] → Object[]. + String fileName = AudioMimeTypes.resolveFileName(request.getFileName(), request.getContentType()); + + HttpRequest http = HttpRequest.post(url) + .form("model", model) + .form("file", request.getAudioData(), fileName) + .timeout(60_000); + String apiKey = config.apiKey(); + if (apiKey != null && !apiKey.isBlank()) { + http.header("Authorization", "Bearer " + apiKey.trim()); + } + + HttpResponse response = http.execute(); + if (response.getStatus() == 200) { + JsonNode result = objectMapper.readTree(response.body()); + String text = result.path("text").asText(""); + log.info("[OpenAI-compat STT] Transcribed {} chars (model={}, baseUrl={})", + text.length(), model, baseUrl); + return SttResult.success(text); + } + log.warn("[OpenAI-compat STT] Failed: HTTP {} - {}", response.getStatus(), response.body()); + return SttResult.failure("STT 失败: HTTP " + response.getStatus()); + } catch (Exception e) { + log.error("[OpenAI-compat STT] Error: {}", e.getMessage(), e); + return SttResult.failure("STT 异常: " + e.getMessage()); + } + } + + /** + * Pick the audio path to append. If baseUrl already ends in a {@code /vN} + * version segment (lmstudio-style), append only {@code /audio/transcriptions}. + * Otherwise append {@code /v1/audio/transcriptions}. Mirrors the resolver + * pattern used by the chat-models probe so user-set baseUrls behave + * consistently across endpoints. + */ + static String resolveAudioPath(String baseUrl) { + if (baseUrl != null && baseUrl.matches(".*/v\\d{1,2}$")) { + return "/audio/transcriptions"; + } + return "/v1/audio/transcriptions"; + } + + static String normalizeBaseUrl(String raw) { + if (raw == null) return null; + String trimmed = raw.trim(); + if (trimmed.isEmpty()) return null; + return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed; + } + + private static String effectiveModel(SttRequest request, SttTransportConfig config) { + if (request.getModel() != null && !request.getModel().isBlank()) { + return request.getModel(); + } + if (config.model() != null && !config.model().isBlank()) { + return config.model(); + } + return "whisper-1"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java index 25b21b8e..ace13763 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java @@ -108,6 +108,20 @@ public class SystemSettingsDTO { /** 首选 STT provider: auto / openai / dashscope */ private String sttProvider; private Boolean sttFallbackEnabled; + /** + * Issue #76: which {@code mate_model_provider} row should the OpenAI STT + * provider read its baseUrl + apiKey from. Defaults to "openai" so existing + * deployments keep working; swap to a custom OpenAI-compatible provider row + * (FunASR / SiliconFlow / Groq / Together / Volcano / etc.) to point STT + * at any compatible endpoint without a code change. + */ + private String sttOpenAiCompatProviderId; + /** + * Issue #76: model id sent in the multipart "model" field. Defaults to + * whisper-1; override with paraformer-large / FunAudioLLM-Whisper / etc. + * when the configured provider exposes a different identifier. + */ + private String sttOpenAiCompatModel; // ===== 音乐生成配置 ===== private Boolean musicEnabled; diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index 17bcdae0..e67cb3d8 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -49,6 +49,9 @@ public class SystemSettingService { private static final String STT_ENABLED_KEY = "sttEnabled"; private static final String STT_PROVIDER_KEY = "sttProvider"; private static final String STT_FALLBACK_ENABLED_KEY = "sttFallbackEnabled"; + // Issue #76: let the OpenAI STT provider point at any OpenAI-compat endpoint. + private static final String STT_OPENAI_COMPAT_PROVIDER_ID_KEY = "sttOpenAiCompatProviderId"; + private static final String STT_OPENAI_COMPAT_MODEL_KEY = "sttOpenAiCompatModel"; // 音乐生成配置 keys private static final String MUSIC_ENABLED_KEY = "musicEnabled"; @@ -134,6 +137,10 @@ public class SystemSettingService { dto.setSttEnabled(Boolean.parseBoolean(getValue(STT_ENABLED_KEY, "false"))); dto.setSttProvider(getValue(STT_PROVIDER_KEY, "auto")); dto.setSttFallbackEnabled(Boolean.parseBoolean(getValue(STT_FALLBACK_ENABLED_KEY, "true"))); + // Issue #76: default to "openai" so upgrades behave identically to the + // old hard-coded path; users can swap to any OpenAI-compat provider row. + dto.setSttOpenAiCompatProviderId(getValue(STT_OPENAI_COMPAT_PROVIDER_ID_KEY, "openai")); + dto.setSttOpenAiCompatModel(getValue(STT_OPENAI_COMPAT_MODEL_KEY, "whisper-1")); // 音乐生成配置 dto.setMusicEnabled(Boolean.parseBoolean(getValue(MUSIC_ENABLED_KEY, "false"))); @@ -295,6 +302,15 @@ public class SystemSettingService { if (dto.getSttFallbackEnabled() != null) { saveValue(STT_FALLBACK_ENABLED_KEY, String.valueOf(dto.getSttFallbackEnabled()), "STT Provider 级 Fallback"); } + // Issue #76: persist the OpenAI-compatible STT routing target. + if (dto.getSttOpenAiCompatProviderId() != null) { + saveValue(STT_OPENAI_COMPAT_PROVIDER_ID_KEY, dto.getSttOpenAiCompatProviderId(), + "OpenAI-compat STT 凭证来源 provider id"); + } + if (dto.getSttOpenAiCompatModel() != null) { + saveValue(STT_OPENAI_COMPAT_MODEL_KEY, dto.getSttOpenAiCompatModel(), + "OpenAI-compat STT 模型名"); + } // 音乐生成配置 if (dto.getMusicEnabled() != null) { diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index ffb59e5d..53c72eb5 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -757,6 +757,9 @@ export default { sttEnabled: 'Enable Speech Recognition', sttProvider: 'Preferred STT Provider', sttFallbackEnabled: 'Provider Fallback', + // Issue #76: route the OpenAI-compat STT endpoint + sttOpenAiCompatProviderId: 'OpenAI-compatible Credential', + sttOpenAiCompatModel: 'OpenAI-compatible Model', musicEnabled: 'Enable Music Generation', musicProvider: 'Preferred Music Provider', musicFallbackEnabled: 'Provider Fallback', @@ -804,6 +807,10 @@ export default { sttFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.', openaiSttInfo: 'Reuses OpenAI API Key from Model Management. Whisper model, supports multilingual auto-detection.', dashscopeSttInfo: 'Reuses DashScope API Key from Model Management. Paraformer Realtime over WebSocket — strong Chinese recognition, sub-second latency.', + // Issue #76 + sttOpenAiCompatProviderId: 'Pick any OpenAI-compatible provider from Model Management as the credential source (baseUrl + API key). Beyond OpenAI itself this covers self-hosted FunASR, SiliconFlow, Groq, Together, Volcano, Qiniu, and any custom provider you add with the OpenAI-compatible protocol.', + sttOpenAiCompatModel: 'Model id sent in the multipart "model" field. Defaults to whisper-1; use paraformer-large for FunASR, or whatever id your vendor documents.', + sttOpenAiCompatNote: 'Tip: to plug in a private ASR service, head to Model Management → Add Custom Provider → pick the "OpenAI Compatible" protocol → fill in Base URL (and an optional API key), then come back and select it here.', musicEnabled: 'Enable to let Agent use the music generation tool. Google Lyria reuses Google Key.', musicProvider: 'Select preferred music provider. Auto mode prioritizes Google Lyria.', musicFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 65404185..0ecb43a5 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -644,6 +644,9 @@ export default { sttEnabled: '启用语音识别', sttProvider: '首选 STT 提供商', sttFallbackEnabled: '提供商回退', + // Issue #76: OpenAI-compat STT 端点路由 + sttOpenAiCompatProviderId: 'OpenAI 兼容凭证', + sttOpenAiCompatModel: 'OpenAI 兼容模型名', // 音乐生成 musicEnabled: '启用音乐生成', musicProvider: '首选音乐提供商', @@ -697,6 +700,10 @@ export default { sttFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。', openaiSttInfo: '复用模型管理中的 OpenAI API Key。使用 Whisper 模型,支持多语言自动识别。', dashscopeSttInfo: '复用模型管理中的 DashScope API Key。使用 Paraformer Realtime(WebSocket 流式),中文识别效果优秀,亚秒级延迟。', + // Issue #76 + sttOpenAiCompatProviderId: '从模型管理选一个 OpenAI 兼容 provider 行作为凭证(baseUrl + API Key)来源。除官方 OpenAI 外,FunASR 私有部署 / 硅基流动 / Groq / Together / 火山 / 七牛等都可以用——在模型管理新增自定义 provider 后即可在此选用。', + sttOpenAiCompatModel: '发送给端点的模型名(multipart "model" 字段)。OpenAI 默认 whisper-1;FunASR 通常是 paraformer-large;其他厂商按其文档填写。', + sttOpenAiCompatNote: '提示:要接私有 ASR 服务,先去模型管理 → 新增自定义 provider → 协议选 "OpenAI 兼容" → 填 Base URL + 可选 API Key,然后回到这里选它。', // 音乐生成 musicEnabled: '开启后 Agent 可通过 music_generate 工具生成音乐。Google Lyria 复用 Google Key。', musicProvider: '选择首选音乐提供商,auto 模式优先使用 Google Lyria。', diff --git a/mateclaw-ui/src/views/Settings/Stt/index.vue b/mateclaw-ui/src/views/Settings/Stt/index.vue index 68b21113..39692903 100644 --- a/mateclaw-ui/src/views/Settings/Stt/index.vue +++ b/mateclaw-ui/src/views/Settings/Stt/index.vue @@ -50,10 +50,52 @@