mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(stt): route OpenAI Whisper provider to any OpenAI-compatible endpoint (#76)
This commit is contained in:
parent
77456d26f8
commit
88fb28a22a
46
mateclaw-server/src/main/java/vip/mate/stt/SttTransport.java
Normal file
46
mateclaw-server/src/main/java/vip/mate/stt/SttTransport.java
Normal file
@ -0,0 +1,46 @@
|
||||
package vip.mate.stt;
|
||||
|
||||
/**
|
||||
* Issue #76: protocol-family abstraction for STT.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>Two transports cover ~99% of the market today:
|
||||
* <ul>
|
||||
* <li>OpenAI Whisper compatible HTTP multipart (this transport)</li>
|
||||
* <li>DashScope realtime WebSocket (kept inline in
|
||||
* {@code DashScopeSttProvider} for now — its own transport class
|
||||
* can be carved out the same way when a second WebSocket-based
|
||||
* vendor lands)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package vip.mate.stt;
|
||||
|
||||
/**
|
||||
* Issue #76: resolved endpoint config passed to an {@link SttTransport}.
|
||||
*
|
||||
* <p>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) {
|
||||
}
|
||||
@ -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
|
||||
* <p>
|
||||
* 复用模型管理中的 OpenAI API Key。
|
||||
* OpenAI Whisper / OpenAI-compatible STT provider — thin wrapper.
|
||||
*
|
||||
* <p>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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Wire protocol lives in {@link OpenAiCompatibleSttTransport}.</li>
|
||||
* <li>Credential row is selected by {@code SystemSettingsDTO.sttOpenAiCompatProviderId}
|
||||
* (defaults to {@code "openai"} for backwards compatibility).</li>
|
||||
* <li>Model is selected by {@code SystemSettingsDTO.sttOpenAiCompatModel}
|
||||
* (defaults to {@code "whisper-1"}).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <p>Wire shape:
|
||||
* <ul>
|
||||
* <li>{@code POST {baseUrl}/v1/audio/transcriptions}
|
||||
* (or {@code {baseUrl}/audio/transcriptions} when baseUrl already
|
||||
* carries a {@code /vN} suffix)</li>
|
||||
* <li>multipart/form-data with {@code model} field + {@code file} field
|
||||
* carrying the audio bytes named after the detected mime type
|
||||
* (Hutool infers the multipart Content-Type from the extension —
|
||||
* {@link AudioMimeTypes#resolveFileName} is what makes that work).</li>
|
||||
* <li>Optional {@code Authorization: Bearer <api_key>} when the caller
|
||||
* supplies one. Self-hosted FunASR commonly skips auth entirely.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Response: {@code { "text": "..." }} — the only field we read.
|
||||
*
|
||||
* <p>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";
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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。',
|
||||
|
||||
@ -50,10 +50,52 @@
|
||||
<template v-if="settings.sttEnabled">
|
||||
<div class="provider-section">
|
||||
<div class="provider-header">
|
||||
<span class="provider-name">OpenAI Whisper</span>
|
||||
<span class="provider-name">OpenAI / OpenAI-compatible (Whisper)</span>
|
||||
<span class="provider-tag">{{ t('settings.sttProviderTags.reuseLlmKey') }}</span>
|
||||
</div>
|
||||
<div class="settings-card"><div class="setting-item"><div class="setting-info"><div class="setting-hint">{{ t('settings.hints.openaiSttInfo') }}</div></div></div></div>
|
||||
<!-- Issue #76: let users point this provider at any OpenAI-compat
|
||||
endpoint (FunASR / SiliconFlow / Groq / Together / Volcano /
|
||||
Qiniu / self-hosted ...) by selecting the credential row + model. -->
|
||||
<div class="settings-card">
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">{{ t('settings.fields.sttOpenAiCompatProviderId') }}</div>
|
||||
<div class="setting-hint">{{ t('settings.hints.sttOpenAiCompatProviderId') }}</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select
|
||||
v-model="settings.sttOpenAiCompatProviderId"
|
||||
class="form-input"
|
||||
:disabled="!settings.sttEnabled"
|
||||
>
|
||||
<option
|
||||
v-for="p in openAiCompatProviders"
|
||||
:key="p.id"
|
||||
:value="p.id"
|
||||
>{{ p.name }}{{ p.id === 'openai' ? '' : ` (${p.id})` }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">{{ t('settings.fields.sttOpenAiCompatModel') }}</div>
|
||||
<div class="setting-hint">{{ t('settings.hints.sttOpenAiCompatModel') }}</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input
|
||||
v-model="settings.sttOpenAiCompatModel"
|
||||
class="form-input"
|
||||
:disabled="!settings.sttEnabled"
|
||||
placeholder="whisper-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-hint">{{ t('settings.hints.sttOpenAiCompatNote') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-section">
|
||||
<div class="provider-header">
|
||||
@ -73,15 +115,69 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { settingsApi } from '@/api'
|
||||
import { modelApi, settingsApi } from '@/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const savedTip = ref('')
|
||||
const settings = reactive({ sttEnabled: false, sttProvider: 'auto', sttFallbackEnabled: true })
|
||||
const settings = reactive({
|
||||
sttEnabled: false,
|
||||
sttProvider: 'auto',
|
||||
sttFallbackEnabled: true,
|
||||
// Issue #76: route OpenAI Whisper provider to any OpenAI-compat credential row.
|
||||
sttOpenAiCompatProviderId: 'openai',
|
||||
sttOpenAiCompatModel: 'whisper-1',
|
||||
})
|
||||
|
||||
onMounted(() => loadSettings())
|
||||
interface ProviderRow {
|
||||
id: string
|
||||
name: string
|
||||
chatModel?: string
|
||||
protocol?: string
|
||||
}
|
||||
|
||||
const providers = ref<ProviderRow[]>([])
|
||||
|
||||
/**
|
||||
* Issue #76: surface every OpenAI-compatible credential row as a STT
|
||||
* endpoint candidate. This includes the bundled OpenAI provider, Kimi /
|
||||
* DeepSeek / Together / SiliconFlow / Groq / Volcano / Qiniu (all share
|
||||
* `OpenAIChatModel`), and any user-added custom provider that picks the
|
||||
* OpenAI-compatible protocol. The user's self-hosted FunASR fits the
|
||||
* latter — they add a custom provider with baseUrl http://internal/v1
|
||||
* and select it here.
|
||||
*/
|
||||
const openAiCompatProviders = computed<ProviderRow[]>(() => {
|
||||
const list = providers.value.filter(p =>
|
||||
(p.chatModel === 'OpenAIChatModel') || (p.protocol === 'openai-compatible')
|
||||
)
|
||||
if (list.some(p => p.id === settings.sttOpenAiCompatProviderId)) return list
|
||||
// Always render the currently-saved id even if its row was disabled / removed,
|
||||
// so the user can see what's persisted instead of silent fallback to "openai".
|
||||
return [
|
||||
...list,
|
||||
{ id: settings.sttOpenAiCompatProviderId, name: settings.sttOpenAiCompatProviderId },
|
||||
]
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadProviders(), loadSettings()])
|
||||
})
|
||||
|
||||
async function loadProviders() {
|
||||
try {
|
||||
const res: any = await modelApi.listProviders()
|
||||
providers.value = (res?.data || []).map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
chatModel: p.chatModel,
|
||||
protocol: p.protocol,
|
||||
}))
|
||||
} catch {
|
||||
providers.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const res: any = await settingsApi.get()
|
||||
@ -89,10 +185,18 @@ async function loadSettings() {
|
||||
settings.sttEnabled = d.sttEnabled ?? false
|
||||
settings.sttProvider = d.sttProvider ?? 'auto'
|
||||
settings.sttFallbackEnabled = d.sttFallbackEnabled ?? true
|
||||
settings.sttOpenAiCompatProviderId = d.sttOpenAiCompatProviderId ?? 'openai'
|
||||
settings.sttOpenAiCompatModel = d.sttOpenAiCompatModel ?? 'whisper-1'
|
||||
}
|
||||
|
||||
async function onSaveSettings() {
|
||||
await settingsApi.update({ sttEnabled: settings.sttEnabled, sttProvider: settings.sttProvider, sttFallbackEnabled: settings.sttFallbackEnabled })
|
||||
await settingsApi.update({
|
||||
sttEnabled: settings.sttEnabled,
|
||||
sttProvider: settings.sttProvider,
|
||||
sttFallbackEnabled: settings.sttFallbackEnabled,
|
||||
sttOpenAiCompatProviderId: settings.sttOpenAiCompatProviderId,
|
||||
sttOpenAiCompatModel: settings.sttOpenAiCompatModel,
|
||||
})
|
||||
await loadSettings()
|
||||
savedTip.value = t('settings.messages.saveSuccess')
|
||||
setTimeout(() => { savedTip.value = '' }, 2500)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user