Add DashScope ASR model support and enhance related components

- Introduced DashScope as a new ASR model in the database initialization.
- Updated ASRModel schema to include vendor information.
- Enhanced ASR router to support DashScope-specific functionality, including connection testing and preview capabilities.
- Modified frontend components to accommodate DashScope as a selectable vendor with appropriate default settings.
- Added tests to validate DashScope ASR model creation, updates, and connectivity.
- Updated backend API to handle DashScope-specific base URLs and vendor normalization.
This commit is contained in:
Xin Wang
2026-03-09 07:37:00 +08:00
parent e41d34fe23
commit bfe165daae
6 changed files with 638 additions and 21 deletions

View File

@@ -82,6 +82,16 @@ const convertRecordedBlobToWav = async (blob: Blob): Promise<File> => {
}
};
const OPENAI_COMPATIBLE_DEFAULT_MODEL = 'FunAudioLLM/SenseVoiceSmall';
const OPENAI_COMPATIBLE_DEFAULT_BASE_URL = 'https://api.siliconflow.cn/v1';
const DASHSCOPE_DEFAULT_MODEL = 'qwen3-asr-flash-realtime';
const DASHSCOPE_DEFAULT_BASE_URL = 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime';
type ASRVendor = 'OpenAI Compatible' | 'DashScope';
const normalizeVendor = (value?: string): ASRVendor =>
String(value || '').trim().toLowerCase() === 'dashscope' ? 'DashScope' : 'OpenAI Compatible';
export const ASRLibraryPage: React.FC = () => {
const [models, setModels] = useState<ASRModel[]>([]);
const [searchTerm, setSearchTerm] = useState('');
@@ -271,10 +281,10 @@ const ASRModelModal: React.FC<{
initialModel?: ASRModel;
}> = ({ isOpen, onClose, onSubmit, initialModel }) => {
const [name, setName] = useState('');
const [vendor, setVendor] = useState('OpenAI Compatible');
const [vendor, setVendor] = useState<ASRVendor>('OpenAI Compatible');
const [language, setLanguage] = useState('zh');
const [modelName, setModelName] = useState('FunAudioLLM/SenseVoiceSmall');
const [baseUrl, setBaseUrl] = useState('https://api.siliconflow.cn/v1');
const [modelName, setModelName] = useState(OPENAI_COMPATIBLE_DEFAULT_MODEL);
const [baseUrl, setBaseUrl] = useState(OPENAI_COMPATIBLE_DEFAULT_BASE_URL);
const [apiKey, setApiKey] = useState('');
const [hotwords, setHotwords] = useState('');
const [enablePunctuation, setEnablePunctuation] = useState(true);
@@ -282,14 +292,40 @@ const ASRModelModal: React.FC<{
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
const getDefaultModel = (nextVendor: ASRVendor): string =>
nextVendor === 'DashScope' ? DASHSCOPE_DEFAULT_MODEL : OPENAI_COMPATIBLE_DEFAULT_MODEL;
const getDefaultBaseUrl = (nextVendor: ASRVendor): string =>
nextVendor === 'DashScope' ? DASHSCOPE_DEFAULT_BASE_URL : OPENAI_COMPATIBLE_DEFAULT_BASE_URL;
const handleVendorChange = (nextVendor: ASRVendor) => {
const previousVendor = vendor;
setVendor(nextVendor);
const previousDefaultModel = getDefaultModel(previousVendor);
const nextDefaultModel = getDefaultModel(nextVendor);
const trimmedModelName = modelName.trim();
if (!trimmedModelName || trimmedModelName === previousDefaultModel) {
setModelName(nextDefaultModel);
}
const previousDefaultBaseUrl = getDefaultBaseUrl(previousVendor);
const nextDefaultBaseUrl = getDefaultBaseUrl(nextVendor);
const trimmedBaseUrl = baseUrl.trim();
if (!trimmedBaseUrl || trimmedBaseUrl === previousDefaultBaseUrl) {
setBaseUrl(nextDefaultBaseUrl);
}
};
useEffect(() => {
if (!isOpen) return;
if (initialModel) {
const nextVendor = normalizeVendor(initialModel.vendor);
setName(initialModel.name || '');
setVendor(initialModel.vendor || 'OpenAI Compatible');
setVendor(nextVendor);
setLanguage(initialModel.language || 'zh');
setModelName(initialModel.modelName || 'FunAudioLLM/SenseVoiceSmall');
setBaseUrl(initialModel.baseUrl || 'https://api.siliconflow.cn/v1');
setModelName(initialModel.modelName || getDefaultModel(nextVendor));
setBaseUrl(initialModel.baseUrl || getDefaultBaseUrl(nextVendor));
setApiKey(initialModel.apiKey || '');
setHotwords(toHotwordsValue(initialModel.hotwords));
setEnablePunctuation(initialModel.enablePunctuation ?? true);
@@ -301,8 +337,8 @@ const ASRModelModal: React.FC<{
setName('');
setVendor('OpenAI Compatible');
setLanguage('zh');
setModelName('FunAudioLLM/SenseVoiceSmall');
setBaseUrl('https://api.siliconflow.cn/v1');
setModelName(OPENAI_COMPATIBLE_DEFAULT_MODEL);
setBaseUrl(OPENAI_COMPATIBLE_DEFAULT_BASE_URL);
setApiKey('');
setHotwords('');
setEnablePunctuation(true);
@@ -368,9 +404,10 @@ const ASRModelModal: React.FC<{
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<Select
value={vendor}
onChange={(e) => setVendor(e.target.value)}
onChange={(e) => handleVendorChange(e.target.value as ASRVendor)}
>
<option value="OpenAI Compatible">OpenAI Compatible</option>
<option value="DashScope">DashScope</option>
</Select>
</div>
<div className="space-y-1.5">
@@ -388,13 +425,22 @@ const ASRModelModal: React.FC<{
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Model Name</label>
<Input value={modelName} onChange={(e) => setModelName(e.target.value)} placeholder="FunAudioLLM/SenseVoiceSmall" />
<Input
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder={vendor === 'DashScope' ? DASHSCOPE_DEFAULT_MODEL : OPENAI_COMPATIBLE_DEFAULT_MODEL}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block flex items-center"><Server className="w-3 h-3 mr-1.5" />Base URL</label>
<Input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.siliconflow.cn/v1" className="font-mono text-xs" />
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={vendor === 'DashScope' ? DASHSCOPE_DEFAULT_BASE_URL : OPENAI_COMPATIBLE_DEFAULT_BASE_URL}
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block flex items-center"><Key className="w-3 h-3 mr-1.5" />API Key</label>
@@ -405,6 +451,11 @@ const ASRModelModal: React.FC<{
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (comma separated)</label>
<Input value={hotwords} onChange={(e) => setHotwords(e.target.value)} placeholder="品牌名, 人名, 专有词" />
{vendor === 'DashScope' && (
<p className="text-[11px] text-muted-foreground">
DashScope WebSocket ASR使 WAV
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">

View File

@@ -3,6 +3,8 @@ import { apiRequest, getApiBaseUrl } from './apiClient';
type AnyRecord = Record<string, any>;
const DEFAULT_LIST_LIMIT = 1000;
const OPENAI_COMPATIBLE_DEFAULT_ASR_BASE_URL = 'https://api.siliconflow.cn/v1';
const DASHSCOPE_DEFAULT_ASR_BASE_URL = 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime';
const TOOL_ID_ALIASES: Record<string, string> = {
voice_message_prompt: 'voice_msg_prompt',
};
@@ -129,7 +131,16 @@ const mapVoice = (raw: AnyRecord): Voice => ({
const mapASRModel = (raw: AnyRecord): ASRModel => ({
id: String(readField(raw, ['id'], '')),
name: readField(raw, ['name'], ''),
vendor: readField(raw, ['vendor'], 'OpenAI Compatible'),
vendor: (() => {
const vendor = String(readField(raw, ['vendor'], '')).trim().toLowerCase();
if (vendor === 'dashscope') {
return 'DashScope';
}
if (vendor === 'siliconflow' || vendor === 'openai compatible' || vendor === 'openai-compatible' || vendor === '硅基流动') {
return 'OpenAI Compatible';
}
return String(readField(raw, ['vendor'], 'OpenAI Compatible')) || 'OpenAI Compatible';
})(),
language: readField(raw, ['language'], 'zh'),
baseUrl: readField(raw, ['baseUrl', 'base_url'], ''),
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
@@ -457,11 +468,16 @@ export const fetchASRModels = async (): Promise<ASRModel[]> => {
};
export const createASRModel = async (data: Partial<ASRModel>): Promise<ASRModel> => {
const vendor = data.vendor || 'OpenAI Compatible';
const normalizedVendor = String(vendor).trim().toLowerCase();
const defaultBaseUrl = normalizedVendor === 'dashscope'
? DASHSCOPE_DEFAULT_ASR_BASE_URL
: OPENAI_COMPATIBLE_DEFAULT_ASR_BASE_URL;
const payload = {
name: data.name || 'New ASR Model',
vendor: data.vendor || 'OpenAI Compatible',
vendor,
language: data.language || 'zh',
base_url: data.baseUrl || 'https://api.siliconflow.cn/v1',
base_url: data.baseUrl || defaultBaseUrl,
api_key: data.apiKey || '',
model_name: data.modelName || undefined,
hotwords: data.hotwords || [],