feat(admin-ui): enhance LLM router page to support Streaming TTS and ASR providers

This commit is contained in:
RainbowBird
2026-06-14 04:45:36 +08:00
parent 3215687e98
commit c3f2beba47
2 changed files with 99 additions and 18 deletions
+26 -1
View File
@@ -74,6 +74,9 @@ describe('llm router page', () => {
expect(host.textContent).toContain('Provider configuration')
expect(host.textContent).toContain('LLM')
expect(host.textContent).toContain('TTS')
expect(host.textContent).toContain('Streaming TTS')
expect(host.textContent).toContain('ASR')
expect(host.textContent).not.toContain('LLM and TTS providers')
expect(host.textContent).toContain('OpenRouter')
expect(host.textContent).not.toContain('Router Config Request')
})
@@ -124,7 +127,7 @@ describe('llm router page', () => {
expect(host.textContent).toContain('Current router config response is not a valid JSON object.')
})
it('separates LLM and TTS provider slices by tab', async () => {
it('separates LLM, TTS, Streaming TTS, and ASR provider slices by tab', async () => {
buttonByText('TTS').click()
await nextTick()
@@ -135,6 +138,28 @@ describe('llm router page', () => {
await nextTick()
expect(host.textContent).toContain('Azure Speech')
buttonByText('Streaming TTS').click()
await nextTick()
expect(host.textContent).toContain('No Streaming TTS provider slices')
expect(sectionHeadings(host)).not.toContain('1. Azure Speech')
buttonByText('Add').click()
await nextTick()
expect(host.textContent).toContain('UnSpeech')
buttonByText('ASR').click()
await nextTick()
expect(host.textContent).toContain('No ASR provider slices')
expect(sectionHeadings(host)).not.toContain('1. UnSpeech')
buttonByText('Add').click()
await nextTick()
expect(host.textContent).toContain('Aliyun NLS ASR')
})
it('keeps preview disabled until required fields are complete', async () => {
+73 -17
View File
@@ -26,13 +26,15 @@ import {
} from '../modules/router-config-form'
type BusyState = 'preview' | 'apply' | 'advanced-preview' | 'advanced-apply'
type ProviderTab = 'llm' | 'tts'
type ProviderTab = 'llm' | 'tts' | 'streamingTts' | 'asr'
const form = reactive(createRouterConfigFormState())
const activeProviderTab = shallowRef<ProviderTab>('llm')
const selectedKindByTab = reactive<Record<ProviderTab, RouterSliceKind>>({
llm: 'openrouter',
tts: 'azure',
streamingTts: 'unspeech',
asr: 'aliyun-nls-asr',
})
const previewResult = shallowRef<AdminRouterConfigResult | null>(null)
const applyResult = shallowRef<AdminRouterConfigResult | null>(null)
@@ -57,26 +59,39 @@ const pendingSummary = computed(() => {
return {
slices: form.slices.length,
llmSlices: form.slices.filter(slice => slice.kind === 'openrouter').length,
ttsSlices: form.slices.filter(slice => slice.kind !== 'openrouter' && slice.kind !== 'unspeech').length,
unspeech: form.slices.some(slice => slice.kind === 'unspeech'),
ttsSlices: form.slices.filter(isTtsSlice).length,
streamingTtsSlices: form.slices.filter(isStreamingTtsSlice).length,
asrSlices: form.slices.filter(isAsrSlice).length,
defaults: Object.keys(defaults).length,
}
})
const providerTabs = computed(() => [
{ value: 'llm' as const, label: 'LLM', count: pendingSummary.value.llmSlices },
{ value: 'tts' as const, label: 'TTS', count: pendingSummary.value.ttsSlices + (pendingSummary.value.unspeech ? 1 : 0) },
{ value: 'tts' as const, label: 'TTS', count: pendingSummary.value.ttsSlices },
{ value: 'streamingTts' as const, label: 'Streaming TTS', count: pendingSummary.value.streamingTtsSlices },
{ value: 'asr' as const, label: 'ASR', count: pendingSummary.value.asrSlices },
])
const providerKindOptions = computed(() => ROUTER_SLICE_KIND_OPTIONS.filter((option) => {
if (activeProviderTab.value === 'llm')
return option.value === 'openrouter'
if (activeProviderTab.value === 'streamingTts')
return option.value === 'unspeech'
if (activeProviderTab.value === 'asr')
return option.value === 'aliyun-nls-asr'
return option.value !== 'openrouter'
return isTtsSliceKind(option.value)
}))
const visibleSlices = computed(() => form.slices
.map((slice, index) => ({ slice, index }))
.filter(({ slice }) => activeProviderTab.value === 'llm'
? isLlmSlice(slice)
: !isLlmSlice(slice)))
.filter(({ slice }) => {
if (activeProviderTab.value === 'llm')
return isLlmSlice(slice)
if (activeProviderTab.value === 'streamingTts')
return isStreamingTtsSlice(slice)
if (activeProviderTab.value === 'asr')
return isAsrSlice(slice)
return isTtsSlice(slice)
}))
const currentStatusLabel = computed(() => {
if (loadingCurrent.value)
return 'Loading current config'
@@ -237,6 +252,37 @@ function isLlmSlice(slice: RouterSliceDraft) {
return slice.kind === 'openrouter'
}
function isTtsSlice(slice: RouterSliceDraft) {
return isTtsSliceKind(slice.kind)
}
function isStreamingTtsSlice(slice: RouterSliceDraft) {
return slice.kind === 'unspeech'
}
function isAsrSlice(slice: RouterSliceDraft) {
return slice.kind === 'aliyun-nls-asr'
}
function isTtsSliceKind(kind: RouterSliceKind) {
return kind === 'azure'
|| kind === 'dashscope-cosyvoice'
|| kind === 'stepfun'
}
function activeProviderLabel() {
switch (activeProviderTab.value) {
case 'llm':
return 'LLM'
case 'tts':
return 'TTS'
case 'streamingTts':
return 'Streaming TTS'
case 'asr':
return 'ASR'
}
}
function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value is AdminRouterConfigCurrent {
return value != null
&& typeof value === 'object'
@@ -300,7 +346,7 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
Provider configuration
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
LLM and TTS providers are edited separately, then applied as one router config request.
LLM, TTS, Streaming TTS, and ASR providers are edited separately, then applied as one router config request.
</p>
</div>
</div>
@@ -308,7 +354,7 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
<div :class="['flex', 'flex-col', 'gap-4', 'p-4']">
<div :class="['flex', 'flex-col', 'gap-3', 'lg:flex-row', 'lg:items-end', 'lg:justify-between']">
<div
:class="['inline-grid', 'w-full', 'grid-cols-2', 'rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-1', 'sm:w-auto', 'dark:border-neutral-800', 'dark:bg-neutral-950']"
:class="['inline-grid', 'w-full', 'grid-cols-4', 'rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-1', 'sm:w-auto', 'dark:border-neutral-800', 'dark:bg-neutral-950']"
role="tablist"
>
<button
@@ -345,12 +391,18 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
</div>
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50/60', 'px-3', 'py-2', 'text-xs', 'text-neutral-600', 'dark:border-neutral-800', 'dark:bg-neutral-950/60', 'dark:text-neutral-300']">
<span :class="['font-medium', 'text-neutral-900', 'dark:text-neutral-100']">{{ activeProviderTab === 'llm' ? 'LLM' : 'TTS' }}</span>
<span :class="['font-medium', 'text-neutral-900', 'dark:text-neutral-100']">{{ activeProviderLabel() }}</span>
<span v-if="activeProviderTab === 'llm'">
config writes chat model aliases under LLM_ROUTER_CONFIG.
</span>
<span v-else-if="activeProviderTab === 'tts'">
config writes speech model aliases under LLM_ROUTER_CONFIG.
</span>
<span v-else-if="activeProviderTab === 'streamingTts'">
config writes UNSPEECH_UPSTREAM settings for realtime speech synthesis.
</span>
<span v-else>
config writes speech model aliases and optional UNSPEECH_UPSTREAM settings.
config writes realtime transcription model aliases under LLM_ROUTER_CONFIG.asr.
</span>
</div>
@@ -365,11 +417,11 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
/>
</div>
<div v-else :class="['empty-state', 'min-h-40', 'rounded-lg', 'border', 'border-dashed', 'border-neutral-200', 'bg-white', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
No {{ activeProviderTab === 'llm' ? 'LLM' : 'TTS' }} provider slices
No {{ activeProviderLabel() }} provider slices
</div>
</div>
<div :class="['border-t', 'border-neutral-200', 'px-4', 'py-3', 'text-xs', 'text-neutral-500', 'dark:border-neutral-800', 'dark:text-neutral-400']">
UnSpeech is limited to one TTS slice per request.
UnSpeech is limited to one Streaming TTS slice per request. ASR writes to LLM_ROUTER_CONFIG.asr.
</div>
</section>
@@ -382,7 +434,7 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
</Callout>
<div :class="['flex', 'flex-col', 'gap-3', 'border-t', 'border-neutral-200', 'pt-4', 'md:flex-row', 'md:items-center', 'md:justify-between', 'dark:border-neutral-800']">
<div :class="['grid', 'gap-2', 'text-xs', 'text-neutral-600', 'sm:grid-cols-5', 'dark:text-neutral-400']">
<div :class="['grid', 'gap-2', 'text-xs', 'text-neutral-600', 'sm:grid-cols-6', 'dark:text-neutral-400']">
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.slices }}</span>
slices
@@ -396,8 +448,12 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
TTS
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.unspeech ? 'yes' : 'no' }}</span>
unspeech
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.streamingTtsSlices }}</span>
Streaming TTS
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.asrSlices }}</span>
ASR
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.defaults }}</span>