feat(admin-ui): load and edit router config from forms

Add a form-first LLM/TTS router config editor and a redacted admin config snapshot so operators can inspect configKV state before applying changes.

Preserve existing encrypted key entries when loaded slices are submitted without new plaintext keys.

Signed-off-by: RainbowBird <git@luoling.moe>
Commit-Message-Assisted-by: Claude (via Claude Code)
This commit is contained in:
RainbowBird
2026-06-11 00:14:34 +08:00
parent 52e0476154
commit 5bf6deab89
17 changed files with 2782 additions and 121 deletions
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { Button, Callout, FieldTextArea } from '@proj-airi/ui'
defineProps<{
error: string | null
disabled: boolean
busy: 'preview' | 'apply' | 'advanced-preview' | 'advanced-apply' | null
}>()
const emit = defineEmits<{
exportForm: []
importForm: []
preview: []
apply: []
}>()
const json = defineModel<string>({ required: true })
</script>
<template>
<section :class="['panel', 'overflow-hidden']">
<div :class="['flex', 'items-start', 'justify-between', 'gap-3', 'border-b', 'border-neutral-200', 'px-4', 'py-3']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Advanced JSON
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
Escape hatch for auditing or unsupported future fields.
</p>
</div>
<span :class="['i-lucide-code-2', 'text-neutral-500']" />
</div>
<div :class="['space-y-4', 'p-4']">
<div :class="['flex', 'flex-wrap', 'gap-2']">
<Button icon="i-lucide-file-output" label="Export Form" size="sm" type="button" variant="secondary" @click="emit('exportForm')" />
<Button icon="i-lucide-file-input" label="Import JSON" size="sm" type="button" variant="secondary" @click="emit('importForm')" />
</div>
<FieldTextArea
v-model="json"
:required="false"
:rows="12"
textarea-class="font-mono text-xs leading-5"
/>
<Callout v-if="error" label="Advanced JSON error" theme="orange">
{{ error }}
</Callout>
<div :class="['flex', 'flex-wrap', 'justify-end', 'gap-2']">
<Button
icon="i-lucide-eye"
label="Preview JSON"
size="sm"
type="button"
variant="secondary"
:disabled="disabled || error != null"
:loading="busy === 'advanced-preview'"
@click="emit('preview')"
/>
<Button
icon="i-lucide-save"
label="Apply JSON"
size="sm"
type="button"
:disabled="disabled || error != null"
:loading="busy === 'advanced-apply'"
@click="emit('apply')"
/>
</div>
</div>
</section>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import type { RouterDefaultsDraft } from '../../modules/router-config-form'
import { Button, FieldInput, FieldTextArea } from '@proj-airi/ui'
import { defaultTtsVoicesJson } from '../../modules/router-config-form'
const defaults = defineModel<RouterDefaultsDraft>({ required: true })
</script>
<template>
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
<div :class="['mb-4', 'flex', 'items-start', 'justify-between', 'gap-3']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Defaults
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
Writes default model aliases alongside provider slices when filled.
</p>
</div>
</div>
<div :class="['grid', 'gap-4', 'md:grid-cols-2']">
<FieldInput
v-model="defaults.chatModel"
description="Writes DEFAULT_CHAT_MODEL."
input-class="font-mono text-xs"
label="Chat model"
placeholder="chat-default"
/>
<FieldInput
v-model="defaults.ttsModel"
description="Writes DEFAULT_TTS_MODEL."
input-class="font-mono text-xs"
label="TTS model"
placeholder="alibaba/cosyvoice-v2"
/>
</div>
<div :class="['mt-4']">
<div :class="['mb-2', 'flex', 'items-center', 'justify-between', 'gap-3']">
<div>
<div :class="['text-sm', 'font-medium']">
Recommended voices
</div>
<div :class="['text-xs', 'text-neutral-500']">
JSON object written to DEFAULT_TTS_VOICES.
</div>
</div>
<Button
class="whitespace-nowrap"
label="Example"
size="sm"
type="button"
variant="secondary"
@click="defaults.ttsVoicesJson = defaultTtsVoicesJson()"
/>
</div>
<FieldTextArea
v-model="defaults.ttsVoicesJson"
:required="false"
:rows="6"
textarea-class="font-mono text-xs leading-5"
placeholder="{&#10; &quot;alibaba/cosyvoice-v2&quot;: {&#10; &quot;zh-CN&quot;: &quot;longxiaochun_v2&quot;&#10; }&#10;}"
/>
</div>
</section>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { RouterConfigMode } from '../../modules/router-config-form'
import { Button, Callout } from '@proj-airi/ui'
const mode = defineModel<RouterConfigMode>({ required: true })
</script>
<template>
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
<div :class="['flex', 'items-start', 'justify-between', 'gap-3']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Write Mode
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
Merge keeps untouched router models. Reset replaces the router model tree with this request.
</p>
</div>
<span :class="['badge', mode === 'reset' ? 'badge-amber' : 'badge-green']">
<span :class="[mode === 'reset' ? 'i-lucide-alert-triangle' : 'i-lucide-git-merge']" />
{{ mode === 'reset' ? 'Reset' : 'Merge' }}
</span>
</div>
<div :class="['mt-4', 'grid', 'gap-2', 'sm:grid-cols-2']">
<Button
icon="i-lucide-git-merge"
label="Merge"
size="sm"
type="button"
:toggled="mode === 'merge'"
variant="secondary-muted"
@click="mode = 'merge'"
/>
<Button
icon="i-lucide-rotate-ccw"
label="Reset"
size="sm"
type="button"
:toggled="mode === 'reset'"
:variant="mode === 'reset' ? 'caution' : 'secondary-muted'"
@click="mode = 'reset'"
/>
</div>
<Callout v-if="mode === 'reset'" :class="['mt-4']" label="Reset mode" theme="orange">
Existing LLM/TTS router models not included in this request will be dropped by the server.
</Callout>
</section>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { AdminRouterConfigResult } from '../../modules/api'
import { computed } from 'vue'
const props = defineProps<{
title: string
result: AdminRouterConfigResult | null
}>()
const applied = computed(() => props.result?.applied ?? [])
const invalidatedKeys = computed(() => props.result?.invalidatedKeys ?? [])
function formatJson(value: unknown): string {
return JSON.stringify(value, null, 2)
}
</script>
<template>
<section :class="['panel', 'overflow-hidden']">
<div :class="['border-b', 'border-neutral-200', 'px-4', 'py-3', 'text-sm', 'font-semibold']">
{{ title }}
</div>
<div v-if="result" :class="['space-y-4', 'p-4']">
<div :class="['grid', 'gap-3', 'text-xs', 'sm:grid-cols-2']">
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3']">
<div :class="['text-neutral-500']">
Slices
</div>
<div :class="['mt-1', 'text-lg', 'font-semibold']">
{{ applied.length }}
</div>
</div>
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3']">
<div :class="['text-neutral-500']">
Invalidated keys
</div>
<div :class="['mt-1', 'text-lg', 'font-semibold']">
{{ invalidatedKeys.length }}
</div>
</div>
</div>
<div v-if="applied.length" :class="['space-y-2']">
<div :class="['text-xs', 'font-semibold', 'uppercase', 'text-neutral-500']">
Applied
</div>
<div :class="['flex', 'flex-wrap', 'gap-2']">
<span
v-for="(item, index) in applied"
:key="index"
:class="['badge']"
>
{{ item.kind }}
<span v-if="item.modelName" :class="['font-mono']">{{ item.modelName }}</span>
</span>
</div>
</div>
<div v-if="invalidatedKeys.length" :class="['space-y-2']">
<div :class="['text-xs', 'font-semibold', 'uppercase', 'text-neutral-500']">
Keys
</div>
<div :class="['flex', 'flex-wrap', 'gap-2']">
<span v-for="key in invalidatedKeys" :key="key" :class="['badge', 'badge-green', 'font-mono']">
{{ key }}
</span>
</div>
</div>
<pre :class="['max-h-[420px]', 'overflow-auto', 'rounded-lg', 'bg-neutral-950', 'p-4', 'text-xs', 'leading-5', 'text-neutral-50']">{{ formatJson(result.preview) }}</pre>
</div>
<div v-else :class="['empty-state', 'min-h-40']">
<span :class="['i-lucide-clipboard-list', 'text-2xl']" />
No data yet
</div>
</section>
</template>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import type { RouterSliceDraft } from '../../modules/router-config-form'
import { Button, FieldInput, FieldSelect, FieldTextArea } from '@proj-airi/ui'
import { computed } from 'vue'
import {
DASHSCOPE_REGION_OPTIONS,
STEPFUN_MODEL_OPTIONS,
} from '../../modules/router-config-form'
defineProps<{
index: number
}>()
const emit = defineEmits<{
duplicate: []
remove: []
}>()
const slice = defineModel<RouterSliceDraft>('slice', { required: true })
const title = computed(() => {
switch (slice.value.kind) {
case 'openrouter':
return 'OpenRouter'
case 'azure':
return 'Azure Speech'
case 'dashscope-cosyvoice':
return 'DashScope CosyVoice'
case 'stepfun':
return 'StepFun TTS'
case 'unspeech':
return 'UnSpeech'
default:
return 'Router Slice'
}
})
const providerKeyDescription = computed(() => {
if (slice.value.kind === 'unspeech')
return ''
return slice.value.existingKeyEntryId
? `Loaded key entry ${slice.value.existingKeyEntryId}. Leave blank to keep it.`
: undefined
})
const providerKeyPlaceholder = computed(() => {
if (slice.value.kind === 'unspeech')
return ''
return slice.value.existingKeyEntryId ? 'Leave blank to keep existing key' : 'Paste provider key'
})
const streamingKeyDescription = computed(() => {
if (slice.value.kind !== 'unspeech' || !slice.value.streamingExistingKeyEntryId)
return undefined
return `Loaded key entry ${slice.value.streamingExistingKeyEntryId}. Leave blank to keep it.`
})
const streamingKeyPlaceholder = computed(() => {
if (slice.value.kind !== 'unspeech' || !slice.value.streamingExistingKeyEntryId)
return 'Paste streaming provider key'
return 'Leave blank to keep existing key'
})
</script>
<template>
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
<div :class="['mb-4', 'flex', 'flex-col', 'gap-3', 'md:flex-row', 'md:items-start', 'md:justify-between']">
<div :class="['min-w-0']">
<h3 :class="['truncate', 'text-sm', 'font-semibold']">
{{ index }}. {{ title }}
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
{{ slice.kind }}
</p>
</div>
<div :class="['flex', 'shrink-0', 'flex-wrap', 'gap-2', 'md:justify-end']">
<Button class="whitespace-nowrap" icon="i-lucide-copy" label="Duplicate" size="sm" type="button" variant="secondary" @click="emit('duplicate')" />
<Button class="whitespace-nowrap" icon="i-lucide-trash-2" label="Remove" size="sm" type="button" variant="danger" @click="emit('remove')" />
</div>
</div>
<div v-if="slice.kind === 'openrouter'" :class="['grid', 'gap-4', 'md:grid-cols-2']">
<FieldInput v-model="slice.modelName" input-class="font-mono text-xs" label="Model alias" placeholder="chat-default" required />
<FieldInput v-model="slice.overrideModel" input-class="font-mono text-xs" label="Upstream model" placeholder="openai/gpt-4o-mini" required />
<FieldInput v-model="slice.plaintextKey" autocomplete="new-password" :description="providerKeyDescription" input-class="font-mono text-xs" label="Provider key" :placeholder="providerKeyPlaceholder" required type="password" />
<FieldInput v-model="slice.baseURL" input-class="font-mono text-xs" label="Base URL" placeholder="https://openrouter.ai/api/v1" required />
<FieldInput v-model="slice.keyEntryId" input-class="font-mono text-xs" label="Key entry ID" placeholder="openrouter-prod-1" />
<FieldInput v-model="slice.headerTemplate" input-class="font-mono text-xs" label="Header template" placeholder="Bearer {KEY}" />
</div>
<div v-else-if="slice.kind === 'azure'" :class="['grid', 'gap-4', 'md:grid-cols-2']">
<FieldInput v-model="slice.modelName" input-class="font-mono text-xs" label="Model alias" placeholder="microsoft/v1" required />
<FieldInput v-model="slice.region" input-class="font-mono text-xs" label="Azure region" placeholder="eastasia" required />
<FieldInput v-model="slice.plaintextKey" autocomplete="new-password" :description="providerKeyDescription" input-class="font-mono text-xs" label="Provider key" :placeholder="providerKeyPlaceholder" required type="password" />
<FieldInput v-model="slice.defaultVoice" input-class="font-mono text-xs" label="Default voice" placeholder="zh-CN-XiaoxiaoNeural" />
<FieldInput v-model="slice.keyEntryId" input-class="font-mono text-xs" label="Key entry ID" placeholder="azure-tts-prod-1" />
</div>
<div v-else-if="slice.kind === 'dashscope-cosyvoice'" :class="['grid', 'gap-4', 'md:grid-cols-2']">
<FieldInput v-model="slice.modelName" input-class="font-mono text-xs" label="Model alias" placeholder="alibaba/cosyvoice-v2" required />
<FieldSelect v-model="slice.region" label="Region" layout="vertical" :options="DASHSCOPE_REGION_OPTIONS" select-class="w-full" />
<FieldInput v-model="slice.upstreamModel" input-class="font-mono text-xs" label="Upstream model" placeholder="cosyvoice-v2" required />
<FieldInput v-model="slice.plaintextKey" autocomplete="new-password" :description="providerKeyDescription" input-class="font-mono text-xs" label="Provider key" :placeholder="providerKeyPlaceholder" required type="password" />
<FieldInput v-model="slice.keyEntryId" input-class="font-mono text-xs" label="Key entry ID" placeholder="dashscope-tts-prod-1" />
</div>
<div v-else-if="slice.kind === 'stepfun'" :class="['grid', 'gap-4', 'md:grid-cols-2']">
<FieldInput v-model="slice.modelName" input-class="font-mono text-xs" label="Model alias" placeholder="stepfun/stepaudio-2.5-tts" required />
<FieldSelect v-model="slice.upstreamModel" label="Upstream model" layout="vertical" :options="STEPFUN_MODEL_OPTIONS" select-class="w-full" />
<FieldInput v-model="slice.plaintextKey" autocomplete="new-password" :description="providerKeyDescription" input-class="font-mono text-xs" label="Provider key" :placeholder="providerKeyPlaceholder" required type="password" />
<FieldInput v-model="slice.defaultVoice" input-class="font-mono text-xs" label="Default voice" placeholder="cixingnansheng" />
<FieldInput v-model="slice.instruction" input-class="font-mono text-xs" label="Instruction" placeholder="Speak warmly" />
<FieldInput v-model="slice.keyEntryId" input-class="font-mono text-xs" label="Key entry ID" placeholder="stepfun-tts-prod-1" />
</div>
<div v-else :class="['space-y-4']">
<FieldInput v-model="slice.restBaseURL" input-class="font-mono text-xs" label="REST base URL" placeholder="http://airi-unspeech.railway.internal:5933" required />
<label :class="['flex', 'items-start', 'gap-3', 'rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3']">
<input v-model="slice.streamingEnabled" :class="['mt-1']" type="checkbox">
<span>
<span :class="['block', 'text-sm', 'font-medium']">Enable streaming upstream</span>
<span :class="['block', 'text-xs', 'text-neutral-500']">Writes UNSPEECH_UPSTREAM.streaming for WebSocket TTS.</span>
</span>
</label>
<div v-if="slice.streamingEnabled" :class="['grid', 'gap-4', 'md:grid-cols-2']">
<FieldInput v-model="slice.streamingUpstreamURL" input-class="font-mono text-xs" label="Streaming WebSocket URL" placeholder="ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream" required />
<FieldInput v-model="slice.streamingPlaintextKey" autocomplete="new-password" :description="streamingKeyDescription" input-class="font-mono text-xs" label="Streaming provider key" :placeholder="streamingKeyPlaceholder" required type="password" />
<FieldInput v-model="slice.streamingKeyEntryId" input-class="font-mono text-xs" label="Streaming key entry ID" placeholder="volcengine-prod-1" />
<FieldInput v-model="slice.streamingDefaultModel" input-class="font-mono text-xs" label="Default streaming model" placeholder="volcengine/seed-tts-2.0" />
<div :class="['md:col-span-2']">
<FieldTextArea
v-model="slice.streamingModelsJson"
label="Streaming models"
:required="false"
:rows="6"
textarea-class="font-mono text-xs leading-5"
/>
</div>
</div>
</div>
</section>
</template>
+77 -2
View File
@@ -47,10 +47,72 @@ export interface AdminUsersPage {
total: number
}
export interface AdminRouterOpenRouterSlice {
kind: 'openrouter'
modelName: string
overrideModel: string
plaintextKey?: string
baseURL?: string
keyEntryId?: string
existingKeyEntryId?: string
headerTemplate?: string
}
export interface AdminRouterAzureSlice {
kind: 'azure'
modelName: string
region: string
defaultVoice?: string
plaintextKey?: string
keyEntryId?: string
existingKeyEntryId?: string
}
export interface AdminRouterDashscopeSlice {
kind: 'dashscope-cosyvoice'
modelName: string
region: 'intl' | 'cn'
upstreamModel: string
plaintextKey?: string
keyEntryId?: string
existingKeyEntryId?: string
}
export interface AdminRouterStepfunSlice {
kind: 'stepfun'
modelName: string
upstreamModel?: 'stepaudio-2.5-tts' | 'step-tts-2' | 'step-tts-mini'
defaultVoice?: string
instruction?: string
plaintextKey?: string
keyEntryId?: string
existingKeyEntryId?: string
}
export interface AdminRouterUnspeechSlice {
kind: 'unspeech'
restBaseURL: string
streaming?: {
upstreamURL: string
plaintextKey?: string
keyEntryId?: string
existingKeyEntryId?: string
models?: Array<{ id: string, name?: string, description?: string }>
defaultModel?: string
}
}
export type AdminRouterConfigSlice
= | AdminRouterOpenRouterSlice
| AdminRouterAzureSlice
| AdminRouterDashscopeSlice
| AdminRouterStepfunSlice
| AdminRouterUnspeechSlice
export interface AdminRouterConfigRequest {
mode?: 'merge' | 'reset'
dryRun?: boolean
slices?: Array<Record<string, unknown>>
slices?: AdminRouterConfigSlice[]
defaults?: {
chatModel?: string
ttsModel?: string
@@ -64,6 +126,13 @@ export interface AdminRouterConfigResult {
preview: Record<string, unknown>
}
export interface AdminRouterConfigCurrent {
request: AdminRouterConfigRequest
preview: Record<string, unknown>
loadedAt: string
missingKeys: string[]
}
export interface VoicePackParams {
[key: string]: string | number | boolean | null
}
@@ -173,7 +242,12 @@ async function fetchJson<T>(endpoint: URL, init: RequestInit = {}): Promise<T> {
payload = await response.json()
}
catch {
payload = null
const contentType = response.headers.get('Content-Type')
throw new AdminApiError(
`Expected JSON from ${endpoint.pathname}, got ${contentType ?? 'an empty response'}. Check api_server_url.`,
response.status,
null,
)
}
if (!response.ok) {
@@ -269,6 +343,7 @@ export const adminApi = {
method: 'POST',
body: JSON.stringify({ ...body, dryRun }),
}),
routerConfig: () => adminFetch<AdminRouterConfigCurrent>('/config/router'),
speechModels: async () => {
const data = await publicFetch<{ models?: SpeechModel[] }>('/audio/models')
return Array.isArray(data.models) ? data.models : []
@@ -0,0 +1,223 @@
import { describe, expect, it } from 'vitest'
import {
buildRouterConfigRequest,
createRouterConfigFormState,
createRouterSliceDraft,
defaultTtsVoicesJson,
formatRouterConfigRequestJson,
formStateFromRequestJson,
validateRouterConfigForm,
} from './router-config-form'
describe('router config form builder', () => {
it('compiles the default OpenRouter chat setup', () => {
const form = createRouterConfigFormState()
const openrouter = form.slices[0]
if (openrouter.kind !== 'openrouter')
throw new Error('expected default slice to be OpenRouter')
openrouter.plaintextKey = 'sk-openrouter'
expect(buildRouterConfigRequest(form)).toEqual({
errors: [],
request: {
mode: 'merge',
slices: [{
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk-openrouter',
baseURL: 'https://openrouter.ai/api/v1',
}],
defaults: {
chatModel: 'chat-default',
},
},
})
})
it('compiles Azure speech defaults without OpenRouter-only fields', () => {
const azure = createRouterSliceDraft('azure', 'azure-test')
azure.plaintextKey = 'azure-key'
azure.defaultVoice = 'zh-CN-XiaoxiaoNeural'
azure.keyEntryId = 'azure-eastasia-1'
const form = {
mode: 'merge' as const,
slices: [azure],
defaults: {
chatModel: '',
ttsModel: 'microsoft/v1',
ttsVoicesJson: '',
},
}
expect(buildRouterConfigRequest(form).request).toEqual({
mode: 'merge',
slices: [{
kind: 'azure',
modelName: 'microsoft/v1',
region: 'eastasia',
defaultVoice: 'zh-CN-XiaoxiaoNeural',
plaintextKey: 'azure-key',
keyEntryId: 'azure-eastasia-1',
}],
defaults: {
ttsModel: 'microsoft/v1',
},
})
})
it('preserves DashScope region and upstream model', () => {
const dashscope = createRouterSliceDraft('dashscope-cosyvoice', 'dashscope-test')
dashscope.region = 'cn'
dashscope.upstreamModel = 'cosyvoice-v1'
dashscope.plaintextKey = 'dashscope-key'
expect(buildRouterConfigRequest({
mode: 'merge',
slices: [dashscope],
defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' },
}).request?.slices).toEqual([{
kind: 'dashscope-cosyvoice',
modelName: 'alibaba/cosyvoice-v2',
region: 'cn',
upstreamModel: 'cosyvoice-v1',
plaintextKey: 'dashscope-key',
}])
})
it('compiles StepFun optional instruction and voice fields', () => {
const stepfun = createRouterSliceDraft('stepfun', 'stepfun-test')
stepfun.plaintextKey = 'stepfun-key'
stepfun.defaultVoice = 'cixingnansheng'
stepfun.instruction = 'Speak warmly'
expect(buildRouterConfigRequest({
mode: 'merge',
slices: [stepfun],
defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' },
}).request?.slices).toEqual([{
kind: 'stepfun',
modelName: 'stepfun/stepaudio-2.5-tts',
upstreamModel: 'stepaudio-2.5-tts',
defaultVoice: 'cixingnansheng',
instruction: 'Speak warmly',
plaintextKey: 'stepfun-key',
}])
})
it('compiles UnSpeech REST-only and streaming requests', () => {
const restOnly = createRouterSliceDraft('unspeech', 'unspeech-rest')
const streaming = createRouterSliceDraft('unspeech', 'unspeech-stream')
streaming.streamingEnabled = true
streaming.streamingPlaintextKey = 'volcengine-key'
streaming.streamingDefaultModel = 'volcengine/seed-tts-2.0'
expect(buildRouterConfigRequest({
mode: 'merge',
slices: [restOnly],
defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' },
}).request?.slices).toEqual([{
kind: 'unspeech',
restBaseURL: 'http://airi-unspeech.railway.internal:5933',
}])
expect(buildRouterConfigRequest({
mode: 'merge',
slices: [streaming],
defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' },
}).request?.slices).toEqual([{
kind: 'unspeech',
restBaseURL: 'http://airi-unspeech.railway.internal:5933',
streaming: {
upstreamURL: 'ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream',
plaintextKey: 'volcengine-key',
models: [{
id: 'volcengine/seed-tts-2.0',
name: 'Seed TTS 2.0',
}],
defaultModel: 'volcengine/seed-tts-2.0',
},
}])
})
it('validates missing keys, URL schemes, empty aliases, and duplicate UnSpeech slices', () => {
const openrouter = createRouterSliceDraft('openrouter', 'bad-openrouter')
openrouter.modelName = ''
openrouter.baseURL = 'ftp://openrouter.local'
const unspeechA = createRouterSliceDraft('unspeech', 'unspeech-a')
const unspeechB = createRouterSliceDraft('unspeech', 'unspeech-b')
unspeechA.streamingEnabled = true
unspeechA.streamingUpstreamURL = 'https://not-websocket.local'
expect(validateRouterConfigForm({
mode: 'merge',
slices: [openrouter, unspeechA, unspeechB],
defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' },
})).toEqual(expect.arrayContaining([
'Only one UnSpeech slice can be submitted at a time.',
'Slice 1 (OpenRouter): model alias is required.',
'Slice 1 (OpenRouter): provider key is required unless an existing key is loaded.',
'Slice 1 (OpenRouter): base URL must start with http:// or https://.',
'Slice 2 (UnSpeech): streaming URL must start with ws:// or wss://.',
'Slice 2 (UnSpeech): streaming provider key is required unless an existing key is loaded.',
]))
})
it('preserves loaded existing key entries when plaintext fields are blank', () => {
const imported = formStateFromRequestJson(JSON.stringify({
mode: 'merge',
slices: [{
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
baseURL: 'https://openrouter.ai/api/v1',
keyEntryId: 'openrouter-prod-1',
existingKeyEntryId: 'openrouter-prod-1',
}],
defaults: {
chatModel: 'chat-default',
},
}))
expect(buildRouterConfigRequest(imported)).toEqual({
errors: [],
request: {
mode: 'merge',
slices: [{
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
baseURL: 'https://openrouter.ai/api/v1',
keyEntryId: 'openrouter-prod-1',
existingKeyEntryId: 'openrouter-prod-1',
}],
defaults: {
chatModel: 'chat-default',
},
},
})
})
it('round-trips supported advanced JSON through form import and export', () => {
const form = createRouterConfigFormState()
const openrouter = form.slices[0]
if (openrouter.kind !== 'openrouter')
throw new Error('expected default slice to be OpenRouter')
openrouter.plaintextKey = 'sk-openrouter'
form.defaults.ttsVoicesJson = defaultTtsVoicesJson()
const request = buildRouterConfigRequest(form).request
if (!request)
throw new Error('expected valid request')
const imported = formStateFromRequestJson(formatRouterConfigRequestJson(request))
expect(buildRouterConfigRequest(imported).request).toEqual(request)
})
it('rejects unsupported advanced JSON slice kinds', () => {
expect(() => formStateFromRequestJson(JSON.stringify({
mode: 'merge',
slices: [{ kind: 'future-provider' }],
}))).toThrow('Unsupported router slice kind "future-provider".')
})
})
@@ -0,0 +1,640 @@
import type {
AdminRouterAzureSlice,
AdminRouterConfigRequest,
AdminRouterConfigSlice,
AdminRouterDashscopeSlice,
AdminRouterOpenRouterSlice,
AdminRouterStepfunSlice,
AdminRouterUnspeechSlice,
} from './api'
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
export type RouterConfigMode = 'merge' | 'reset'
export type RouterSliceKind = AdminRouterConfigSlice['kind']
export type DashscopeRegion = AdminRouterDashscopeSlice['region']
export type StepfunModel = NonNullable<AdminRouterStepfunSlice['upstreamModel']>
export interface RouterDefaultsDraft {
chatModel: string
ttsModel: string
ttsVoicesJson: string
}
interface SliceDraftBase {
id: string
kind: RouterSliceKind
}
export interface OpenRouterSliceDraft extends SliceDraftBase {
kind: 'openrouter'
modelName: string
overrideModel: string
plaintextKey: string
baseURL: string
keyEntryId: string
existingKeyEntryId: string
headerTemplate: string
}
export interface AzureSliceDraft extends SliceDraftBase {
kind: 'azure'
modelName: string
region: string
defaultVoice: string
plaintextKey: string
keyEntryId: string
existingKeyEntryId: string
}
export interface DashscopeSliceDraft extends SliceDraftBase {
kind: 'dashscope-cosyvoice'
modelName: string
region: DashscopeRegion
upstreamModel: string
plaintextKey: string
keyEntryId: string
existingKeyEntryId: string
}
export interface StepfunSliceDraft extends SliceDraftBase {
kind: 'stepfun'
modelName: string
upstreamModel: StepfunModel
defaultVoice: string
instruction: string
plaintextKey: string
keyEntryId: string
existingKeyEntryId: string
}
export interface UnspeechSliceDraft extends SliceDraftBase {
kind: 'unspeech'
restBaseURL: string
streamingEnabled: boolean
streamingUpstreamURL: string
streamingPlaintextKey: string
streamingKeyEntryId: string
streamingExistingKeyEntryId: string
streamingModelsJson: string
streamingDefaultModel: string
}
export type RouterSliceDraft
= | OpenRouterSliceDraft
| AzureSliceDraft
| DashscopeSliceDraft
| StepfunSliceDraft
| UnspeechSliceDraft
export interface RouterConfigFormState {
mode: RouterConfigMode
slices: RouterSliceDraft[]
defaults: RouterDefaultsDraft
}
export interface RouterConfigBuildResult {
request: AdminRouterConfigRequest | null
errors: string[]
}
const DEFAULT_TTS_VOICES_JSON = '{\n "alibaba/cosyvoice-v2": {\n "zh-CN": "longxiaochun_v2"\n }\n}'
const DEFAULT_STREAMING_MODELS_JSON = '[\n {\n "id": "volcengine/seed-tts-2.0",\n "name": "Seed TTS 2.0"\n }\n]'
let draftId = 0
export const ROUTER_SLICE_KIND_OPTIONS: Array<{ label: string, value: RouterSliceKind, description: string }> = [
{ label: 'OpenRouter', value: 'openrouter', description: 'LLM chat model alias' },
{ label: 'Azure Speech', value: 'azure', description: 'Microsoft TTS model alias' },
{ label: 'DashScope CosyVoice', value: 'dashscope-cosyvoice', description: 'Alibaba TTS model alias' },
{ label: 'StepFun TTS', value: 'stepfun', description: 'StepAudio / Step TTS model alias' },
{ label: 'UnSpeech', value: 'unspeech', description: 'REST and optional streaming TTS upstream' },
]
export const DASHSCOPE_REGION_OPTIONS: Array<{ label: string, value: DashscopeRegion, description: string }> = [
{ label: 'International', value: 'intl', description: 'dashscope-intl.aliyuncs.com' },
{ label: 'China', value: 'cn', description: 'dashscope.aliyuncs.com' },
]
export const STEPFUN_MODEL_OPTIONS: Array<{ label: string, value: StepfunModel }> = [
{ label: 'StepAudio 2.5 TTS', value: 'stepaudio-2.5-tts' },
{ label: 'Step TTS 2', value: 'step-tts-2' },
{ label: 'Step TTS Mini', value: 'step-tts-mini' },
]
/**
* Creates the default LLM Router form state.
*
* Use when:
* - The admin page needs a fresh editable request with the common OpenRouter
* chat-default path prefilled.
*
* Returns:
* - Mutable form state that can be compiled by {@link buildRouterConfigRequest}.
*/
export function createRouterConfigFormState(): RouterConfigFormState {
return {
mode: 'merge',
slices: [createRouterSliceDraft('openrouter')],
defaults: {
chatModel: 'chat-default',
ttsModel: '',
ttsVoicesJson: '',
},
}
}
/**
* Creates an editable provider slice draft.
*
* Use when:
* - The admin adds a provider card or imports a supported JSON slice.
*
* Returns:
* - A draft with provider-specific operational defaults.
*/
export function createRouterSliceDraft(kind: 'openrouter', id?: string): OpenRouterSliceDraft
export function createRouterSliceDraft(kind: 'azure', id?: string): AzureSliceDraft
export function createRouterSliceDraft(kind: 'dashscope-cosyvoice', id?: string): DashscopeSliceDraft
export function createRouterSliceDraft(kind: 'stepfun', id?: string): StepfunSliceDraft
export function createRouterSliceDraft(kind: 'unspeech', id?: string): UnspeechSliceDraft
export function createRouterSliceDraft(kind: RouterSliceKind, id?: string): RouterSliceDraft
export function createRouterSliceDraft(kind: RouterSliceKind, id?: string): RouterSliceDraft {
const sliceId = id ?? `${kind}-${++draftId}`
switch (kind) {
case 'openrouter':
return {
id: sliceId,
kind,
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: '',
baseURL: 'https://openrouter.ai/api/v1',
keyEntryId: '',
existingKeyEntryId: '',
headerTemplate: '',
}
case 'azure':
return {
id: sliceId,
kind,
modelName: 'microsoft/v1',
region: 'eastasia',
defaultVoice: '',
plaintextKey: '',
keyEntryId: '',
existingKeyEntryId: '',
}
case 'dashscope-cosyvoice':
return {
id: sliceId,
kind,
modelName: 'alibaba/cosyvoice-v2',
region: 'intl',
upstreamModel: 'cosyvoice-v2',
plaintextKey: '',
keyEntryId: '',
existingKeyEntryId: '',
}
case 'stepfun':
return {
id: sliceId,
kind,
modelName: 'stepfun/stepaudio-2.5-tts',
upstreamModel: 'stepaudio-2.5-tts',
defaultVoice: '',
instruction: '',
plaintextKey: '',
keyEntryId: '',
existingKeyEntryId: '',
}
case 'unspeech':
return {
id: sliceId,
kind,
restBaseURL: 'http://airi-unspeech.railway.internal:5933',
streamingEnabled: false,
streamingUpstreamURL: 'ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream',
streamingPlaintextKey: '',
streamingKeyEntryId: '',
streamingExistingKeyEntryId: '',
streamingModelsJson: DEFAULT_STREAMING_MODELS_JSON,
streamingDefaultModel: '',
}
}
}
export function defaultTtsVoicesJson(): string {
return DEFAULT_TTS_VOICES_JSON
}
/**
* Builds the admin router config request from form state.
*
* Use when:
* - Previewing or applying the visible form payload.
*
* Expects:
* - Plaintext keys are present in draft state only; callers should submit the
* returned request directly and avoid rendering it in normal summaries.
*
* Returns:
* - A request when client-side validation passes, otherwise actionable errors.
*/
export function buildRouterConfigRequest(form: RouterConfigFormState): RouterConfigBuildResult {
const errors = validateRouterConfigForm(form)
if (errors.length > 0)
return { request: null, errors }
return { request: routerConfigRequestFromFormDraft(form), errors: [] }
}
/**
* Projects form state into request JSON without validating it first.
*
* Use when:
* - The UI needs to export the current draft for inspection before all
* required fields are complete.
*
* Returns:
* - The request shape the validated builder would submit once errors are fixed.
*/
export function routerConfigRequestFromFormDraft(form: RouterConfigFormState): AdminRouterConfigRequest {
const slices = form.slices.map(sliceToRequest)
const defaults = defaultsToRequest(form.defaults)
return {
mode: form.mode,
slices,
...(Object.keys(defaults).length > 0 ? { defaults } : {}),
}
}
export function validateRouterConfigForm(form: RouterConfigFormState): string[] {
const errors: string[] = []
const unspeechCount = form.slices.filter(slice => slice.kind === 'unspeech').length
if (unspeechCount > 1)
errors.push('Only one UnSpeech slice can be submitted at a time.')
for (const [index, slice] of form.slices.entries())
errors.push(...validateSlice(slice, index + 1))
validateDefaults(form.defaults, errors)
const hasDefaults = hasText(form.defaults.chatModel) || hasText(form.defaults.ttsModel) || hasText(form.defaults.ttsVoicesJson)
if (form.slices.length === 0 && !hasDefaults)
errors.push('Add at least one provider slice or default alias before previewing.')
return errors
}
/**
* Imports supported router config request JSON into editable form state.
*
* Before:
* - `{ "mode": "merge", "slices": [{ "kind": "openrouter", ... }] }`
*
* After:
* - Form state with one OpenRouter draft and matching defaults.
*/
export function formStateFromRequestJson(json: string): RouterConfigFormState {
const parsed = JSON.parse(json) as unknown
if (!isRecord(parsed))
throw new Error('Advanced JSON must be an object.')
const mode = parsed.mode === 'reset' ? 'reset' : 'merge'
const slicesValue = parsed.slices
const slices = Array.isArray(slicesValue)
? slicesValue.map((slice, index) => draftFromRequestSlice(slice, index + 1))
: []
return {
mode,
slices,
defaults: defaultsFromUnknown(parsed.defaults),
}
}
export function formatRouterConfigRequestJson(request: AdminRouterConfigRequest): string {
return JSON.stringify(request, null, 2)
}
function validateSlice(slice: RouterSliceDraft, ordinal: number): string[] {
const label = `Slice ${ordinal} (${kindLabel(slice.kind)})`
switch (slice.kind) {
case 'openrouter':
return [
required(slice.modelName, `${label}: model alias is required.`),
noPipe(slice.modelName, `${label}: model alias must not contain "|".`),
required(slice.overrideModel, `${label}: upstream model is required.`),
requiredKey(slice.plaintextKey, slice.existingKeyEntryId, `${label}: provider key is required unless an existing key is loaded.`),
httpUrl(slice.baseURL, `${label}: base URL must start with http:// or https://.`),
].filter(isPresent)
case 'azure':
return [
required(slice.modelName, `${label}: model alias is required.`),
noPipe(slice.modelName, `${label}: model alias must not contain "|".`),
required(slice.region, `${label}: Azure region is required.`),
requiredKey(slice.plaintextKey, slice.existingKeyEntryId, `${label}: provider key is required unless an existing key is loaded.`),
].filter(isPresent)
case 'dashscope-cosyvoice':
return [
required(slice.modelName, `${label}: model alias is required.`),
noPipe(slice.modelName, `${label}: model alias must not contain "|".`),
required(slice.upstreamModel, `${label}: upstream model is required.`),
requiredKey(slice.plaintextKey, slice.existingKeyEntryId, `${label}: provider key is required unless an existing key is loaded.`),
].filter(isPresent)
case 'stepfun':
return [
required(slice.modelName, `${label}: model alias is required.`),
noPipe(slice.modelName, `${label}: model alias must not contain "|".`),
requiredKey(slice.plaintextKey, slice.existingKeyEntryId, `${label}: provider key is required unless an existing key is loaded.`),
].filter(isPresent)
case 'unspeech':
return [
required(slice.restBaseURL, `${label}: REST base URL is required.`),
httpUrl(slice.restBaseURL, `${label}: REST base URL must start with http:// or https://.`),
...(slice.streamingEnabled
? [
required(slice.streamingUpstreamURL, `${label}: streaming WebSocket URL is required.`),
wsUrl(slice.streamingUpstreamURL, `${label}: streaming URL must start with ws:// or wss://.`),
requiredKey(slice.streamingPlaintextKey, slice.streamingExistingKeyEntryId, `${label}: streaming provider key is required unless an existing key is loaded.`),
validateStreamingModels(slice.streamingModelsJson, label),
].filter(isPresent)
: []),
].filter(isPresent)
}
}
function validateDefaults(defaults: RouterDefaultsDraft, errors: string[]) {
if (!hasText(defaults.ttsVoicesJson))
return
try {
parseTtsVoices(defaults.ttsVoicesJson)
}
catch (error) {
errors.push(errorMessageFromUnknown(error, 'Default TTS voices must be valid JSON.'))
}
}
function validateStreamingModels(json: string, label: string): string | undefined {
if (!hasText(json))
return undefined
try {
parseStreamingModels(json)
return undefined
}
catch (error) {
return error instanceof Error ? `${label}: ${error.message}` : `${label}: streaming models must be valid JSON.`
}
}
function sliceToRequest(slice: RouterSliceDraft): AdminRouterConfigSlice {
switch (slice.kind) {
case 'openrouter': {
const request: AdminRouterOpenRouterSlice = {
kind: slice.kind,
modelName: trim(slice.modelName),
overrideModel: trim(slice.overrideModel),
baseURL: trim(slice.baseURL),
}
assignOptional(request, 'plaintextKey', slice.plaintextKey)
assignOptional(request, 'keyEntryId', slice.keyEntryId)
assignOptional(request, 'existingKeyEntryId', slice.existingKeyEntryId)
assignOptional(request, 'headerTemplate', slice.headerTemplate)
return request
}
case 'azure': {
const request: AdminRouterAzureSlice = {
kind: slice.kind,
modelName: trim(slice.modelName),
region: trim(slice.region),
}
assignOptional(request, 'plaintextKey', slice.plaintextKey)
assignOptional(request, 'defaultVoice', slice.defaultVoice)
assignOptional(request, 'keyEntryId', slice.keyEntryId)
assignOptional(request, 'existingKeyEntryId', slice.existingKeyEntryId)
return request
}
case 'dashscope-cosyvoice': {
const request: AdminRouterDashscopeSlice = {
kind: slice.kind,
modelName: trim(slice.modelName),
region: slice.region,
upstreamModel: trim(slice.upstreamModel),
}
assignOptional(request, 'plaintextKey', slice.plaintextKey)
assignOptional(request, 'keyEntryId', slice.keyEntryId)
assignOptional(request, 'existingKeyEntryId', slice.existingKeyEntryId)
return request
}
case 'stepfun': {
const request: AdminRouterStepfunSlice = {
kind: slice.kind,
modelName: trim(slice.modelName),
upstreamModel: slice.upstreamModel,
}
assignOptional(request, 'plaintextKey', slice.plaintextKey)
assignOptional(request, 'defaultVoice', slice.defaultVoice)
assignOptional(request, 'instruction', slice.instruction)
assignOptional(request, 'keyEntryId', slice.keyEntryId)
assignOptional(request, 'existingKeyEntryId', slice.existingKeyEntryId)
return request
}
case 'unspeech': {
const request: AdminRouterUnspeechSlice = {
kind: slice.kind,
restBaseURL: trim(slice.restBaseURL),
}
if (slice.streamingEnabled) {
request.streaming = {
upstreamURL: trim(slice.streamingUpstreamURL),
}
assignOptional(request.streaming, 'plaintextKey', slice.streamingPlaintextKey)
assignOptional(request.streaming, 'keyEntryId', slice.streamingKeyEntryId)
assignOptional(request.streaming, 'existingKeyEntryId', slice.streamingExistingKeyEntryId)
assignOptional(request.streaming, 'defaultModel', slice.streamingDefaultModel)
const models = parseStreamingModels(slice.streamingModelsJson)
if (models.length > 0)
request.streaming.models = models
}
return request
}
}
}
function defaultsToRequest(defaults: RouterDefaultsDraft): NonNullable<AdminRouterConfigRequest['defaults']> {
const out: NonNullable<AdminRouterConfigRequest['defaults']> = {}
assignOptional(out, 'chatModel', defaults.chatModel)
assignOptional(out, 'ttsModel', defaults.ttsModel)
if (hasText(defaults.ttsVoicesJson))
out.ttsVoices = parseTtsVoices(defaults.ttsVoicesJson)
return out
}
function draftFromRequestSlice(value: unknown, ordinal: number): RouterSliceDraft {
if (!isRecord(value) || typeof value.kind !== 'string')
throw new Error(`slices[${ordinal - 1}] must include a supported kind.`)
switch (value.kind) {
case 'openrouter': {
const draft = createRouterSliceDraft('openrouter', `imported-openrouter-${ordinal}`) as OpenRouterSliceDraft
draft.modelName = stringValue(value.modelName)
draft.overrideModel = stringValue(value.overrideModel)
draft.plaintextKey = stringValue(value.plaintextKey)
draft.baseURL = stringValue(value.baseURL) || draft.baseURL
draft.keyEntryId = stringValue(value.keyEntryId)
draft.existingKeyEntryId = stringValue(value.existingKeyEntryId)
draft.headerTemplate = stringValue(value.headerTemplate)
return draft
}
case 'azure': {
const draft = createRouterSliceDraft('azure', `imported-azure-${ordinal}`) as AzureSliceDraft
draft.modelName = stringValue(value.modelName)
draft.region = stringValue(value.region)
draft.defaultVoice = stringValue(value.defaultVoice)
draft.plaintextKey = stringValue(value.plaintextKey)
draft.keyEntryId = stringValue(value.keyEntryId)
draft.existingKeyEntryId = stringValue(value.existingKeyEntryId)
return draft
}
case 'dashscope-cosyvoice': {
const draft = createRouterSliceDraft('dashscope-cosyvoice', `imported-dashscope-${ordinal}`) as DashscopeSliceDraft
draft.modelName = stringValue(value.modelName)
draft.region = value.region === 'cn' ? 'cn' : 'intl'
draft.upstreamModel = stringValue(value.upstreamModel)
draft.plaintextKey = stringValue(value.plaintextKey)
draft.keyEntryId = stringValue(value.keyEntryId)
draft.existingKeyEntryId = stringValue(value.existingKeyEntryId)
return draft
}
case 'stepfun': {
const draft = createRouterSliceDraft('stepfun', `imported-stepfun-${ordinal}`) as StepfunSliceDraft
draft.modelName = stringValue(value.modelName)
draft.upstreamModel = isStepfunModel(value.upstreamModel) ? value.upstreamModel : draft.upstreamModel
draft.defaultVoice = stringValue(value.defaultVoice)
draft.instruction = stringValue(value.instruction)
draft.plaintextKey = stringValue(value.plaintextKey)
draft.keyEntryId = stringValue(value.keyEntryId)
draft.existingKeyEntryId = stringValue(value.existingKeyEntryId)
return draft
}
case 'unspeech': {
const draft = createRouterSliceDraft('unspeech', `imported-unspeech-${ordinal}`) as UnspeechSliceDraft
draft.restBaseURL = stringValue(value.restBaseURL)
const streaming = isRecord(value.streaming) ? value.streaming : null
draft.streamingEnabled = streaming != null
if (streaming) {
draft.streamingUpstreamURL = stringValue(streaming.upstreamURL)
draft.streamingPlaintextKey = stringValue(streaming.plaintextKey)
draft.streamingKeyEntryId = stringValue(streaming.keyEntryId)
draft.streamingExistingKeyEntryId = stringValue(streaming.existingKeyEntryId)
draft.streamingDefaultModel = stringValue(streaming.defaultModel)
if (Array.isArray(streaming.models))
draft.streamingModelsJson = JSON.stringify(streaming.models, null, 2)
}
return draft
}
default:
throw new Error(`Unsupported router slice kind "${value.kind}".`)
}
}
function defaultsFromUnknown(value: unknown): RouterDefaultsDraft {
const defaults = isRecord(value) ? value : {}
return {
chatModel: stringValue(defaults.chatModel),
ttsModel: stringValue(defaults.ttsModel),
ttsVoicesJson: isRecord(defaults.ttsVoices) ? JSON.stringify(defaults.ttsVoices, null, 2) : '',
}
}
function parseTtsVoices(json: string): Record<string, Record<string, string>> {
const parsed = JSON.parse(json) as unknown
if (!isRecord(parsed))
throw new Error('Default TTS voices must be a JSON object.')
const out: Record<string, Record<string, string>> = {}
for (const [model, locales] of Object.entries(parsed)) {
if (!model.trim() || !isRecord(locales))
throw new Error('Default TTS voices must map model IDs to locale objects.')
out[model] = {}
for (const [locale, voice] of Object.entries(locales)) {
if (!locale.trim() || typeof voice !== 'string' || !voice.trim())
throw new Error('Default TTS voices locales must map to voice IDs.')
out[model][locale] = voice
}
}
return out
}
function parseStreamingModels(json: string): NonNullable<NonNullable<AdminRouterUnspeechSlice['streaming']>['models']> {
if (!hasText(json))
return []
const parsed = JSON.parse(json) as unknown
if (!Array.isArray(parsed))
throw new Error('streaming models must be a JSON array.')
return parsed.map((item, index) => {
if (!isRecord(item) || typeof item.id !== 'string' || !item.id.trim())
throw new Error(`streaming models[${index}].id is required.`)
return {
id: item.id,
...(typeof item.name === 'string' && item.name.trim() ? { name: item.name } : {}),
...(typeof item.description === 'string' && item.description.trim() ? { description: item.description } : {}),
}
})
}
function kindLabel(kind: RouterSliceKind): string {
return ROUTER_SLICE_KIND_OPTIONS.find(option => option.value === kind)?.label ?? kind
}
function required(value: string, message: string): string | undefined {
return hasText(value) ? undefined : message
}
function requiredKey(value: string, existingKeyEntryId: string, message: string): string | undefined {
return hasText(value) || hasText(existingKeyEntryId) ? undefined : message
}
function noPipe(value: string, message: string): string | undefined {
return value.includes('|') ? message : undefined
}
function httpUrl(value: string, message: string): string | undefined {
return /^https?:\/\/\S+$/u.test(value.trim()) ? undefined : message
}
function wsUrl(value: string, message: string): string | undefined {
return /^wss?:\/\/\S+$/u.test(value.trim()) ? undefined : message
}
function assignOptional<T extends object>(target: T, key: string, value: string) {
if (hasText(value))
Object.assign(target, { [key]: trim(value) })
}
function trim(value: string): string {
return value.trim()
}
function hasText(value: string): boolean {
return value.trim().length > 0
}
function isPresent(value: string | undefined): value is string {
return value != null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value)
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function isStepfunModel(value: unknown): value is StepfunModel {
return value === 'stepaudio-2.5-tts' || value === 'step-tts-2' || value === 'step-tts-mini'
}
@@ -0,0 +1,211 @@
// @vitest-environment jsdom
import type { App } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import LlmRouterPage from './LlmRouterPage.vue'
const mocks = vi.hoisted(() => ({
applyRouterConfig: vi.fn(),
routerConfig: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn(),
}))
vi.mock('../modules/api', () => ({
adminApi: {
applyRouterConfig: mocks.applyRouterConfig,
routerConfig: mocks.routerConfig,
},
}))
vi.mock('vue-sonner', () => ({
toast: {
success: mocks.toastSuccess,
error: mocks.toastError,
},
}))
describe('llm router page', () => {
let app: App<Element>
let host: HTMLElement
beforeEach(() => {
mocks.routerConfig.mockResolvedValue({
request: {
mode: 'merge',
slices: [],
defaults: {},
},
preview: {},
loadedAt: '2026-06-10T00:00:00.000Z',
missingKeys: ['LLM_ROUTER_CONFIG'],
})
mocks.applyRouterConfig.mockResolvedValue({
applied: [{ kind: 'openrouter', target: 'llm-router', surface: 'llm', modelName: 'chat-default', keyEntryId: 'k1' }],
invalidatedKeys: [],
preview: {
LLM_ROUTER_CONFIG: {
llm: {
models: {
'chat-default': {
upstreams: [{ keys: [{ id: 'k1', ciphertext: '<ciphertext: 10 chars>' }] }],
},
},
},
},
},
})
document.body.innerHTML = '<div id="app"></div>'
host = document.querySelector('#app')!
app = createApp(LlmRouterPage)
app.mount(host)
})
afterEach(() => {
app.unmount()
vi.clearAllMocks()
})
it('starts as a form-first router config editor', () => {
expect(host.textContent).toContain('Router Config Form')
expect(host.textContent).toContain('Provider configuration')
expect(host.textContent).toContain('LLM')
expect(host.textContent).toContain('TTS')
expect(host.textContent).toContain('OpenRouter')
expect(host.textContent).not.toContain('Router Config Request')
})
it('loads current router config into the form and preserves existing keys', async () => {
app.unmount()
mocks.routerConfig.mockResolvedValueOnce({
request: {
mode: 'merge',
slices: [{
kind: 'openrouter',
modelName: 'chat-live',
overrideModel: 'openai/gpt-4.1-mini',
baseURL: 'https://openrouter.ai/api/v1',
keyEntryId: 'openrouter-live',
existingKeyEntryId: 'openrouter-live',
}],
defaults: {
chatModel: 'chat-live',
},
},
preview: {
LLM_ROUTER_CONFIG: { llm: { models: { 'chat-live': {} } } },
},
loadedAt: '2026-06-10T00:00:00.000Z',
missingKeys: [],
})
document.body.innerHTML = '<div id="app"></div>'
host = document.querySelector('#app')!
app = createApp(LlmRouterPage)
app.mount(host)
await flushPromises()
expect(host.textContent).toContain('Loaded current config')
expect(host.textContent).toContain('Loaded key entry openrouter-live')
expect(previewButton().disabled).toBe(false)
})
it('shows an actionable error when the current config response is not JSON data', async () => {
app.unmount()
mocks.routerConfig.mockResolvedValueOnce(null)
document.body.innerHTML = '<div id="app"></div>'
host = document.querySelector('#app')!
app = createApp(LlmRouterPage)
app.mount(host)
await flushPromises()
expect(host.textContent).toContain('Current router config response is not a valid JSON object.')
})
it('separates LLM and TTS provider slices by tab', async () => {
buttonByText('TTS').click()
await nextTick()
expect(host.textContent).toContain('No TTS provider slices')
expect(sectionHeadings(host)).not.toContain('1. OpenRouter')
buttonByText('Add').click()
await nextTick()
expect(host.textContent).toContain('Azure Speech')
})
it('keeps preview disabled until required fields are complete', async () => {
expect(previewButton().disabled).toBe(true)
const keyInput = host.querySelector<HTMLInputElement>('input[type="password"]')
expect(keyInput).not.toBeNull()
keyInput!.value = 'sk-openrouter'
keyInput!.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(previewButton().disabled).toBe(false)
})
it('previews the built form payload without hand-written JSON', async () => {
const keyInput = host.querySelector<HTMLInputElement>('input[type="password"]')
keyInput!.value = 'sk-openrouter'
keyInput!.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
previewButton().click()
await nextTick()
await flushPromises()
await nextTick()
expect(mocks.applyRouterConfig).toHaveBeenCalledWith({
mode: 'merge',
slices: [{
kind: 'openrouter',
modelName: 'chat-default',
overrideModel: 'openai/gpt-4o-mini',
plaintextKey: 'sk-openrouter',
baseURL: 'https://openrouter.ai/api/v1',
}],
defaults: {
chatModel: 'chat-default',
},
}, true)
expect(mocks.toastSuccess).toHaveBeenCalledWith('Router config preview generated')
})
it('exports the current form to advanced JSON', async () => {
const exportButton = buttonByText('Export Form')
exportButton.click()
await nextTick()
const advancedTextarea = Array.from(host.querySelectorAll('textarea')).at(-1)
expect(advancedTextarea?.value).toContain('"kind": "openrouter"')
expect(advancedTextarea?.value).toContain('"chatModel": "chat-default"')
})
})
function previewButton(): HTMLButtonElement {
return buttonByText('Preview')
}
async function flushPromises() {
await nextTick()
await Promise.resolve()
await nextTick()
}
function buttonByText(text: string): HTMLButtonElement {
const button = Array.from(document.querySelectorAll('button'))
.find(item => item.textContent?.includes(text))
if (!(button instanceof HTMLButtonElement))
throw new Error(`Button "${text}" not found`)
return button
}
function sectionHeadings(host: HTMLElement): string[] {
return Array.from(host.querySelectorAll('h3'))
.map(heading => heading.textContent?.trim() ?? '')
}
+387 -89
View File
@@ -1,55 +1,205 @@
<script setup lang="ts">
import type { AdminRouterConfigRequest, AdminRouterConfigResult } from '../modules/api'
import type { AdminRouterConfigCurrent, AdminRouterConfigRequest, AdminRouterConfigResult } from '../modules/api'
import type { RouterSliceDraft, RouterSliceKind } from '../modules/router-config-form'
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
import { computed, shallowRef } from 'vue'
import { Button, Callout, FieldSelect } from '@proj-airi/ui'
import { computed, onMounted, reactive, shallowRef } from 'vue'
import { toast } from 'vue-sonner'
import RouterAdvancedJsonPanel from '../components/llm-router/RouterAdvancedJsonPanel.vue'
import RouterDefaultsEditor from '../components/llm-router/RouterDefaultsEditor.vue'
import RouterModeControl from '../components/llm-router/RouterModeControl.vue'
import RouterPreviewPanel from '../components/llm-router/RouterPreviewPanel.vue'
import RouterSliceEditor from '../components/llm-router/RouterSliceEditor.vue'
import { adminApi } from '../modules/api'
import {
buildRouterConfigRequest,
createRouterConfigFormState,
createRouterSliceDraft,
formatRouterConfigRequestJson,
formStateFromRequestJson,
ROUTER_SLICE_KIND_OPTIONS,
routerConfigRequestFromFormDraft,
validateRouterConfigForm,
} from '../modules/router-config-form'
const DEFAULT_REQUEST_BODY = `{
"mode": "merge",
"slices": [
{
"kind": "openrouter",
"modelName": "chat-default",
"overrideModel": "openai/gpt-4o-mini",
"plaintextKey": "",
"baseURL": "https://openrouter.ai/api/v1"
}
],
"defaults": {
"chatModel": "chat-default"
}
}`
type BusyState = 'preview' | 'apply' | 'advanced-preview' | 'advanced-apply'
type ProviderTab = 'llm' | 'tts'
const requestBody = shallowRef(DEFAULT_REQUEST_BODY)
const form = reactive(createRouterConfigFormState())
const activeProviderTab = shallowRef<ProviderTab>('llm')
const selectedKindByTab = reactive<Record<ProviderTab, RouterSliceKind>>({
llm: 'openrouter',
tts: 'azure',
})
const previewResult = shallowRef<AdminRouterConfigResult | null>(null)
const applyResult = shallowRef<AdminRouterConfigResult | null>(null)
const busy = shallowRef<'preview' | 'apply' | null>(null)
const busy = shallowRef<BusyState | null>(null)
const currentConfig = shallowRef<AdminRouterConfigCurrent | null>(null)
const loadingCurrent = shallowRef(false)
const currentError = shallowRef<string | null>(null)
const advancedJson = shallowRef(formatRouterConfigRequestJson(routerConfigRequestFromFormDraft(form)))
const advancedError = shallowRef<string | null>(null)
const parseError = computed(() => {
try {
parseRequestBody()
return null
}
catch (error) {
return errorMessageFromUnknown(error, 'Invalid JSON')
const validationErrors = computed(() => validateRouterConfigForm(form))
const hasValidationErrors = computed(() => validationErrors.value.length > 0)
const pendingRequest = computed(() => routerConfigRequestFromFormDraft(form))
const selectedKind = computed({
get: () => selectedKindByTab[activeProviderTab.value],
set: (kind: RouterSliceKind) => {
selectedKindByTab[activeProviderTab.value] = kind
},
})
const pendingSummary = computed(() => {
const defaults = pendingRequest.value.defaults ?? {}
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'),
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) },
])
const providerKindOptions = computed(() => ROUTER_SLICE_KIND_OPTIONS.filter((option) => {
if (activeProviderTab.value === 'llm')
return option.value === 'openrouter'
return option.value !== 'openrouter'
}))
const visibleSlices = computed(() => form.slices
.map((slice, index) => ({ slice, index }))
.filter(({ slice }) => activeProviderTab.value === 'llm'
? isLlmSlice(slice)
: !isLlmSlice(slice)))
const currentStatusLabel = computed(() => {
if (loadingCurrent.value)
return 'Loading current config'
if (currentError.value)
return 'Current config unavailable'
if (!currentConfig.value)
return 'Current config not loaded'
if (currentConfig.value.request.slices?.length || Object.keys(currentConfig.value.request.defaults ?? {}).length > 0)
return 'Loaded current config'
return 'No current config'
})
onMounted(() => {
void loadCurrentConfig()
})
async function previewConfig() {
await submit(true)
await submitForm(true)
}
async function applyConfig() {
await submit(false)
await submitForm(false)
}
async function submit(dryRun: boolean) {
busy.value = dryRun ? 'preview' : 'apply'
async function previewAdvancedJson() {
await submitAdvancedJson(true)
}
async function applyAdvancedJson() {
await submitAdvancedJson(false)
}
async function loadCurrentConfig() {
loadingCurrent.value = true
currentError.value = null
try {
const result = await adminApi.applyRouterConfig(parseRequestBody(), dryRun)
const current = await adminApi.routerConfig()
if (!isCurrentConfigResponse(current))
throw new Error('Current router config response is not a valid JSON object.')
currentConfig.value = current
previewResult.value = {
applied: [],
invalidatedKeys: [],
preview: current.preview,
}
if (current.request.slices?.length || Object.keys(current.request.defaults ?? {}).length > 0) {
Object.assign(form, formStateFromRequestJson(formatRouterConfigRequestJson(current.request)))
advancedJson.value = formatRouterConfigRequestJson(routerConfigRequestFromFormDraft(form))
}
}
catch (error) {
currentError.value = errorMessageFromUnknown(error, 'Failed to load current router config')
}
finally {
loadingCurrent.value = false
}
}
function addSlice() {
form.slices.push(createRouterSliceDraft(selectedKind.value))
}
function selectProviderTab(tab: ProviderTab) {
activeProviderTab.value = tab
}
function duplicateSlice(index: number) {
const source = form.slices[index]
if (!source)
return
const clone = createRouterSliceDraft(source.kind)
const cloneId = clone.id
Object.assign(clone, JSON.parse(JSON.stringify(source)) as RouterSliceDraft, { id: cloneId })
form.slices.splice(index + 1, 0, clone)
}
function removeSlice(index: number) {
form.slices.splice(index, 1)
}
function exportFormJson() {
advancedJson.value = formatRouterConfigRequestJson(routerConfigRequestFromFormDraft(form))
advancedError.value = null
toast.success('Form exported to advanced JSON')
}
function importAdvancedJson() {
try {
const imported = formStateFromRequestJson(advancedJson.value)
Object.assign(form, imported)
advancedError.value = null
toast.success('Advanced JSON imported into form')
}
catch (error) {
advancedError.value = errorMessageFromUnknown(error, 'Failed to import advanced JSON')
}
}
async function submitForm(dryRun: boolean) {
const built = buildRouterConfigRequest(form)
if (!built.request) {
toast.error(built.errors[0] ?? 'Router config form is incomplete')
return
}
await submitRequest(built.request, dryRun, dryRun ? 'preview' : 'apply')
}
async function submitAdvancedJson(dryRun: boolean) {
const request = parseAdvancedJsonRequest()
if (!request)
return
await submitRequest(request, dryRun, dryRun ? 'advanced-preview' : 'advanced-apply')
}
async function submitRequest(request: AdminRouterConfigRequest, dryRun: boolean, state: BusyState) {
busy.value = state
try {
const result = await adminApi.applyRouterConfig(request, dryRun)
if (dryRun) {
previewResult.value = result
toast.success('Router config preview generated')
@@ -68,83 +218,231 @@ async function submit(dryRun: boolean) {
}
}
function parseRequestBody(): AdminRouterConfigRequest {
const parsed = JSON.parse(requestBody.value) as unknown
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error('Request body must be a JSON object')
function parseAdvancedJsonRequest(): AdminRouterConfigRequest | null {
try {
const parsed = JSON.parse(advancedJson.value) as unknown
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error('Advanced JSON must be a request object.')
return parsed as AdminRouterConfigRequest
advancedError.value = null
return parsed as AdminRouterConfigRequest
}
catch (error) {
advancedError.value = errorMessageFromUnknown(error, 'Invalid advanced JSON')
return null
}
}
function formatJson(value: unknown): string {
return JSON.stringify(value, null, 2)
function isLlmSlice(slice: RouterSliceDraft) {
return slice.kind === 'openrouter'
}
function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value is AdminRouterConfigCurrent {
return value != null
&& typeof value === 'object'
&& value.request != null
&& typeof value.request === 'object'
&& value.preview != null
&& typeof value.preview === 'object'
}
</script>
<template>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
<section class="panel overflow-hidden">
<div class="flex flex-col gap-3 border-b border-neutral-200 px-5 py-4 md:flex-row md:items-center md:justify-between">
<div :class="['grid', 'gap-5', 'xl:grid-cols-[minmax(0,1fr)_420px]']">
<section :class="['panel', 'overflow-hidden']">
<div :class="['flex', 'flex-col', 'gap-3', 'border-b', 'border-neutral-200', 'px-5', 'py-4', 'md:flex-row', 'md:items-center', 'md:justify-between']">
<div>
<h2 class="text-sm font-semibold">
Router Config Request
<h2 :class="['text-sm', 'font-semibold']">
Router Config Form
</h2>
<p class="mt-1 text-sm text-neutral-500">
Writes the active LLM_ROUTER_CONFIG, UNSPEECH_UPSTREAM, and default model aliases.
<p :class="['mt-1', 'text-sm', 'text-neutral-500']">
Builds LLM_ROUTER_CONFIG, UNSPEECH_UPSTREAM, and default model aliases without hand-written JSON.
</p>
</div>
<span class="badge" :class="parseError ? 'badge-amber' : 'badge-green'">
<span :class="parseError ? 'i-lucide-alert-circle' : 'i-lucide-check-circle-2'" />
{{ parseError ? 'Invalid JSON' : 'Ready' }}
<span :class="['badge', hasValidationErrors ? 'badge-amber' : 'badge-green']">
<span :class="[hasValidationErrors ? 'i-lucide-alert-circle' : 'i-lucide-check-circle-2']" />
{{ hasValidationErrors ? `${validationErrors.length} issues` : 'Ready' }}
</span>
</div>
<div class="p-5 space-y-4">
<textarea
v-model="requestBody"
class="textarea min-h-[520px] text-xs leading-5 font-mono"
spellcheck="false"
/>
<form :class="['space-y-5', 'p-5']" @submit.prevent="previewConfig">
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
<div :class="['flex', 'flex-col', 'gap-3', 'md:flex-row', 'md:items-center', 'md:justify-between']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Current Config
</h3>
<p :class="['mt-1', 'text-xs', currentError ? 'text-red-600' : 'text-neutral-500']">
{{ currentError ?? currentStatusLabel }}
</p>
</div>
<Button
class="whitespace-nowrap"
icon="i-lucide-refresh-cw"
label="Reload"
size="sm"
type="button"
variant="secondary"
:disabled="busy != null || loadingCurrent"
:loading="loadingCurrent"
@click="loadCurrentConfig"
/>
</div>
</section>
<div v-if="parseError" class="border border-amber-200 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">
{{ parseError }}
</div>
<RouterModeControl v-model="form.mode" />
<RouterDefaultsEditor v-model="form.defaults" />
<div class="flex flex-wrap justify-end gap-2">
<button class="btn btn-secondary" :disabled="busy != null || parseError != null" type="button" @click="previewConfig">
<span :class="busy === 'preview' ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-eye'" />
Preview
</button>
<button class="btn btn-primary" :disabled="busy != null || parseError != null" type="button" @click="applyConfig">
<span :class="busy === 'apply' ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-save'" />
Apply
</button>
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white']">
<div :class="['border-b', 'border-neutral-200', 'p-4']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Provider configuration
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
LLM and TTS providers are edited separately, then applied as one router config request.
</p>
</div>
</div>
<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']"
role="tablist"
>
<button
v-for="tab in providerTabs"
:key="tab.value"
:aria-selected="activeProviderTab === tab.value"
:class="[
'h-9 rounded-md px-4 text-sm font-medium transition-colors whitespace-nowrap',
activeProviderTab === tab.value
? 'bg-white text-primary-700 shadow-sm ring-1 ring-primary-200'
: 'text-neutral-500 hover:text-neutral-900',
]"
role="tab"
type="button"
@click="selectProviderTab(tab.value)"
>
{{ tab.label }}
<span :class="['ml-1', 'text-xs', activeProviderTab === tab.value ? 'text-neutral-500' : 'text-neutral-400']">
{{ tab.count }}
</span>
</button>
</div>
<div :class="['grid', 'gap-2', 'sm:grid-cols-[minmax(220px,1fr)_auto]', 'sm:items-end']">
<FieldSelect
v-model="selectedKind"
label="Provider"
layout="vertical"
:options="providerKindOptions"
select-class="w-full"
/>
<Button class="self-end whitespace-nowrap" icon="i-lucide-plus" label="Add" size="sm" type="button" @click="addSlice" />
</div>
</div>
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50/60', 'px-3', 'py-2', 'text-xs', 'text-neutral-600']">
<span :class="['font-medium', 'text-neutral-900']">{{ activeProviderTab === 'llm' ? 'LLM' : 'TTS' }}</span>
<span v-if="activeProviderTab === 'llm'">
config writes chat model aliases under LLM_ROUTER_CONFIG.
</span>
<span v-else>
config writes speech model aliases and optional UNSPEECH_UPSTREAM settings.
</span>
</div>
<div v-if="visibleSlices.length" :class="['space-y-3']">
<RouterSliceEditor
v-for="(item, visibleIndex) in visibleSlices"
:key="item.slice.id"
v-model:slice="form.slices[item.index]"
:index="visibleIndex + 1"
@duplicate="duplicateSlice(item.index)"
@remove="removeSlice(item.index)"
/>
</div>
<div v-else :class="['empty-state', 'min-h-40', 'rounded-lg', 'border', 'border-dashed', 'border-neutral-200', 'bg-white']">
No {{ activeProviderTab === 'llm' ? 'LLM' : 'TTS' }} provider slices
</div>
</div>
<div :class="['border-t', 'border-neutral-200', 'px-4', 'py-3', 'text-xs', 'text-neutral-500']">
UnSpeech is limited to one TTS slice per request.
</div>
</section>
<Callout v-if="hasValidationErrors" label="Fix before apply" theme="orange">
<ul :class="['list-disc', 'space-y-1', 'pl-4']">
<li v-for="error in validationErrors" :key="error">
{{ error }}
</li>
</ul>
</Callout>
<div :class="['flex', 'flex-col', 'gap-3', 'border-t', 'border-neutral-200', 'pt-4', 'md:flex-row', 'md:items-center', 'md:justify-between']">
<div :class="['grid', 'gap-2', 'text-xs', 'text-neutral-600', 'sm:grid-cols-5']">
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.slices }}</span>
slices
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.llmSlices }}</span>
LLM
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.ttsSlices }}</span>
TTS
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.unspeech ? 'yes' : 'no' }}</span>
unspeech
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.defaults }}</span>
defaults
</div>
</div>
<div :class="['flex', 'flex-wrap', 'justify-end', 'gap-2']">
<Button
icon="i-lucide-eye"
label="Preview"
size="sm"
type="button"
variant="secondary"
:disabled="busy != null || hasValidationErrors"
:loading="busy === 'preview'"
@click="previewConfig"
/>
<Button
icon="i-lucide-save"
label="Apply"
size="sm"
type="button"
:disabled="busy != null || hasValidationErrors"
:loading="busy === 'apply'"
@click="applyConfig"
/>
</div>
</div>
</div>
</form>
</section>
<aside class="space-y-4">
<section class="panel overflow-hidden">
<div class="border-b border-neutral-200 px-4 py-3 text-sm font-semibold">
Preview
</div>
<pre v-if="previewResult" class="max-h-[420px] overflow-auto p-4 text-xs leading-5">{{ formatJson(previewResult) }}</pre>
<div v-else class="empty-state min-h-40">
<span class="i-lucide-clipboard-list text-2xl" />
No preview yet
</div>
</section>
<section class="panel overflow-hidden">
<div class="border-b border-neutral-200 px-4 py-3 text-sm font-semibold">
Last Apply
</div>
<pre v-if="applyResult" class="max-h-[420px] overflow-auto p-4 text-xs leading-5">{{ formatJson(applyResult) }}</pre>
<div v-else class="empty-state min-h-40">
<span class="i-lucide-history text-2xl" />
No applied changes
</div>
</section>
<aside :class="['space-y-4']">
<RouterPreviewPanel title="Preview" :result="previewResult" />
<RouterPreviewPanel title="Last Apply" :result="applyResult" />
<RouterAdvancedJsonPanel
v-model="advancedJson"
:busy="busy"
:disabled="busy != null"
:error="advancedError"
@apply="applyAdvancedJson"
@export-form="exportFormJson"
@import-form="importAdvancedJson"
@preview="previewAdvancedJson"
/>
</aside>
</div>
</template>
+14
View File
@@ -1,11 +1,25 @@
import { join } from 'node:path'
import { cwd } from 'node:process'
import Vue from '@vitejs/plugin-vue'
import VueMacros from 'vue-macros/vite'
import { loadEnv } from 'vite'
import { defineConfig } from 'vitest/config'
export default defineConfig(({ mode }) => {
return {
plugins: [
VueMacros({
plugins: {
vue: Vue({
include: [/\.vue$/, /\.md$/],
}),
vueJsx: false,
},
betterDefine: false,
}),
],
test: {
include: ['src/**/*.test.ts'],
env: loadEnv(mode, join(cwd(), 'apps', 'ui-admin'), ''),