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:
@@ -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="{ "alibaba/cosyvoice-v2": { "zh-CN": "longxiaochun_v2" } }"
|
||||
/>
|
||||
</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>
|
||||
@@ -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() ?? '')
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'), ''),
|
||||
|
||||
Reference in New Issue
Block a user