feat(admin): refine voice pack editor
Move Voice Pack creation and editing to dedicated admin routes, add catalog-backed free-text fields, and wire test audio generation through the public speech API. Document the mock-API browser verification workflow so future local UI checks can avoid auth and tooling dead ends. Signed-off-by: RainbowBird <git@luoling.moe>
This commit is contained in:
@@ -58,6 +58,7 @@ Concise but detailed reference for contributors working across the `moeru-ai/air
|
||||
- DI examples: `apps/stage-tamagotchi/src/main/index.ts` (injeca).
|
||||
- Styles: `uno.config.ts` (UnoCSS), `apps/stage-web/src/styles` (animations/reference).
|
||||
- Build pipeline refs: `.github/workflows`; lint rules in `eslint.config.js`.
|
||||
- Documented solutions: `docs/solutions/` records past fixes and workflow learnings, organized by category with YAML frontmatter (`module`, `tags`, `problem_type`); relevant when implementing, debugging, or verifying in documented areas.
|
||||
- Tailwind/UnoCSS: prefer UnoCSS; if standardizing styles, add shortcuts/rules/plugins in `uno.config.ts`.
|
||||
|
||||
## Commands (pnpm with filters)
|
||||
|
||||
@@ -22,7 +22,12 @@ const navItems = [
|
||||
{ to: '/voice-packs', icon: 'i-lucide-volume-2', label: 'Voice Packs' },
|
||||
]
|
||||
|
||||
const currentTitle = computed(() => navItems.find(item => item.to === route.path)?.label ?? 'Overview')
|
||||
const activeNavItem = computed(() => navItems.find(item =>
|
||||
item.to === '/'
|
||||
? route.path === '/'
|
||||
: route.path === item.to || route.path.startsWith(`${item.to}/`),
|
||||
))
|
||||
const currentTitle = computed(() => activeNavItem.value?.label ?? 'Overview')
|
||||
const initials = computed(() => {
|
||||
const source = me.value?.user.name || me.value?.user.email || 'A'
|
||||
return source.slice(0, 1).toUpperCase()
|
||||
@@ -100,7 +105,7 @@ onMounted(async () => {
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="nav-item"
|
||||
:class="{ 'nav-item-active': route.path === item.to }"
|
||||
:class="{ 'nav-item-active': activeNavItem?.to === item.to }"
|
||||
>
|
||||
<span :class="item.icon" />
|
||||
{{ item.label }}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
description?: string
|
||||
listId: string
|
||||
options: Array<{ label: string, value: string, description?: string }>
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
inputClass?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label :class="['block']">
|
||||
<span :class="['mb-1', 'block', 'text-xs', 'font-semibold', 'uppercase', 'text-neutral-500']">
|
||||
{{ label }}
|
||||
<span v-if="required" :class="['text-red-500']">*</span>
|
||||
</span>
|
||||
<span v-if="description" :class="['mb-2', 'block', 'text-xs', 'text-neutral-500']">
|
||||
{{ description }}
|
||||
</span>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
:class="[
|
||||
'field',
|
||||
inputClass,
|
||||
]"
|
||||
:list="listId"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
type="text"
|
||||
>
|
||||
<datalist :id="listId">
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="`${listId}-${option.value}`"
|
||||
:label="option.description ? `${option.label} - ${option.description}` : option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</datalist>
|
||||
</label>
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ import FluxPage from './pages/FluxPage.vue'
|
||||
import LlmRouterPage from './pages/LlmRouterPage.vue'
|
||||
import OverviewPage from './pages/OverviewPage.vue'
|
||||
import UsersPage from './pages/UsersPage.vue'
|
||||
import VoicePackFormPage from './pages/VoicePackFormPage.vue'
|
||||
import VoicePacksPage from './pages/VoicePacksPage.vue'
|
||||
|
||||
import '@proj-airi/font-chillroundm/index.css'
|
||||
@@ -26,6 +27,8 @@ const router = createRouter({
|
||||
{ path: '/flux', component: FluxPage },
|
||||
{ path: '/llm-router', component: LlmRouterPage },
|
||||
{ path: '/voice-packs', component: VoicePacksPage },
|
||||
{ path: '/voice-packs/new', name: 'voice-pack-new', component: VoicePackFormPage },
|
||||
{ path: '/voice-packs/:id/edit', name: 'voice-pack-edit', component: VoicePackFormPage },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -95,6 +95,36 @@ export interface VoicePackPayload {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface SpeechModel {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SpeechVoice {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
labels?: Record<string, unknown>
|
||||
tags?: string[]
|
||||
languages?: { code: string, title: string }[]
|
||||
preview_audio_url?: string
|
||||
}
|
||||
|
||||
export interface SpeechVoicesResult {
|
||||
voices: SpeechVoice[]
|
||||
recommended: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SpeechTestPayload {
|
||||
model: string
|
||||
input: string
|
||||
voice: string
|
||||
speed?: number
|
||||
extra_body?: {
|
||||
voice_pack?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export class AdminApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -118,6 +148,15 @@ export function signInUrl(): string {
|
||||
|
||||
async function adminFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const endpoint = new URL(`/api/admin${path}`, apiServerUrl())
|
||||
return fetchJson<T>(endpoint, init)
|
||||
}
|
||||
|
||||
async function publicFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const endpoint = new URL(`/api/v1${path}`, apiServerUrl())
|
||||
return fetchJson<T>(endpoint, init)
|
||||
}
|
||||
|
||||
async function fetchJson<T>(endpoint: URL, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
|
||||
if (init.body && !headers.has('Content-Type'))
|
||||
@@ -145,6 +184,34 @@ async function adminFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
return payload as T
|
||||
}
|
||||
|
||||
async function publicFetchBlob(path: string, init: RequestInit = {}): Promise<Blob> {
|
||||
const endpoint = new URL(`/api/v1${path}`, apiServerUrl())
|
||||
const headers = new Headers(init.headers)
|
||||
|
||||
if (init.body && !headers.has('Content-Type'))
|
||||
headers.set('Content-Type', 'application/json')
|
||||
|
||||
const response = await fetch(endpoint.toString(), {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let payload: unknown = null
|
||||
try {
|
||||
payload = await response.json()
|
||||
}
|
||||
catch {
|
||||
payload = await response.text().catch(() => null)
|
||||
}
|
||||
const message = extractErrorMessage(payload) ?? `Audio API request failed (${response.status})`
|
||||
throw new AdminApiError(message, response.status, payload)
|
||||
}
|
||||
|
||||
return await response.blob()
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== 'object')
|
||||
return null
|
||||
@@ -202,6 +269,24 @@ export const adminApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...body, dryRun }),
|
||||
}),
|
||||
speechModels: async () => {
|
||||
const data = await publicFetch<{ models?: SpeechModel[] }>('/audio/models')
|
||||
return Array.isArray(data.models) ? data.models : []
|
||||
},
|
||||
speechVoices: async (model: string): Promise<SpeechVoicesResult> => {
|
||||
const query = new URLSearchParams()
|
||||
query.set('model', model)
|
||||
const data = await publicFetch<Partial<SpeechVoicesResult>>(`/audio/voices?${query.toString()}`)
|
||||
return {
|
||||
voices: Array.isArray(data.voices) ? data.voices : [],
|
||||
recommended: data.recommended && typeof data.recommended === 'object' ? data.recommended : {},
|
||||
}
|
||||
},
|
||||
testSpeech: (body: SpeechTestPayload) =>
|
||||
publicFetchBlob('/audio/speech', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
voicePacks: () => adminFetch<VoicePack[]>('/voice-packs'),
|
||||
createVoicePack: (body: VoicePackPayload) =>
|
||||
adminFetch<VoicePack>('/voice-packs', {
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
<script setup lang="ts">
|
||||
import type { SpeechVoice, VoicePack, VoicePackParams, VoicePackPayload } from '../modules/api'
|
||||
|
||||
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
|
||||
import { Button, Callout, FieldInput, FieldSelect, FieldTextArea } from '@proj-airi/ui'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import DatalistField from '../components/voice-packs/DatalistField.vue'
|
||||
|
||||
import { adminApi } from '../modules/api'
|
||||
|
||||
const DEFAULT_PARAMS = '{}'
|
||||
const TEST_TEXT = '你好,欢迎来到 AIRI。'
|
||||
const supportedParams = new Set(['pitch', 'rate', 'volume'])
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const packs = shallowRef<VoicePack[]>([])
|
||||
const models = shallowRef<{ id: string, name: string }[]>([])
|
||||
const voices = shallowRef<SpeechVoice[]>([])
|
||||
const recommendedVoices = shallowRef<Record<string, string>>({})
|
||||
const loading = shallowRef(false)
|
||||
const loadingCatalog = shallowRef(false)
|
||||
const loadingVoices = shallowRef(false)
|
||||
const saving = shallowRef(false)
|
||||
const testing = shallowRef(false)
|
||||
const testAudioUrl = shallowRef<string | null>(null)
|
||||
const testText = shallowRef(TEST_TEXT)
|
||||
const previousDerived = shallowRef(deriveModelParts('volcengine/seed-tts-2.0'))
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
voiceId: '',
|
||||
ttsModelId: 'volcengine/seed-tts-2.0',
|
||||
paramsJson: DEFAULT_PARAMS,
|
||||
costMultiplier: 1,
|
||||
status: 'enabled',
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Yes', value: 'enabled' },
|
||||
{ label: 'No', value: 'disabled' },
|
||||
]
|
||||
|
||||
const packId = computed(() => typeof route.params.id === 'string' ? route.params.id : null)
|
||||
const isEditing = computed(() => route.name === 'voice-pack-edit')
|
||||
const pageTitle = computed(() => isEditing.value ? 'Edit Voice Pack' : 'New Voice Pack')
|
||||
const selectedPack = computed(() => packs.value.find(pack => pack.id === packId.value) ?? null)
|
||||
|
||||
const modelOptions = computed(() =>
|
||||
models.value.map(model => ({
|
||||
label: model.name || model.id,
|
||||
value: model.id,
|
||||
description: model.name && model.name !== model.id ? model.id : undefined,
|
||||
})),
|
||||
)
|
||||
|
||||
const providerOptions = computed(() => {
|
||||
const values = new Set<string>()
|
||||
for (const model of models.value)
|
||||
values.add(deriveModelParts(model.id).provider)
|
||||
for (const pack of packs.value)
|
||||
values.add(pack.provider)
|
||||
return [...values].filter(Boolean).sort().map(value => ({ label: value, value }))
|
||||
})
|
||||
|
||||
const baseModelOptions = computed(() => {
|
||||
const values = new Set<string>()
|
||||
for (const model of models.value)
|
||||
values.add(deriveModelParts(model.id).model)
|
||||
for (const pack of packs.value)
|
||||
values.add(pack.model)
|
||||
return [...values].filter(Boolean).sort().map(value => ({ label: value, value }))
|
||||
})
|
||||
|
||||
const voiceOptions = computed(() =>
|
||||
voices.value.map(voice => ({
|
||||
label: voice.name || voice.id,
|
||||
value: voice.id,
|
||||
description: voiceOptionDescription(voice),
|
||||
})),
|
||||
)
|
||||
|
||||
const paramsError = computed(() => {
|
||||
try {
|
||||
parseParams()
|
||||
return null
|
||||
}
|
||||
catch (error) {
|
||||
return errorMessageFromUnknown(error, 'Invalid params JSON')
|
||||
}
|
||||
})
|
||||
|
||||
const formError = computed(() => {
|
||||
if (!form.name.trim())
|
||||
return 'Name is required'
|
||||
if (!form.provider.trim())
|
||||
return 'Provider is required'
|
||||
if (!form.model.trim())
|
||||
return 'Model is required'
|
||||
if (!form.ttsModelId.trim())
|
||||
return 'TTS model ID is required'
|
||||
if (!form.voiceId.trim())
|
||||
return 'Voice ID is required'
|
||||
if (!Number.isFinite(Number(form.costMultiplier)) || Number(form.costMultiplier) < 0)
|
||||
return 'Cost multiplier must be a non-negative number'
|
||||
return null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadPacks(), loadCatalog()])
|
||||
if (isEditing.value)
|
||||
fillSelectedPack()
|
||||
else
|
||||
previousDerived.value = deriveModelParts(form.ttsModelId)
|
||||
await loadVoices(form.ttsModelId, { autoPick: !form.voiceId.trim() })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokeTestAudio()
|
||||
})
|
||||
|
||||
watch(() => route.params.id, async () => {
|
||||
if (!isEditing.value) {
|
||||
resetForm()
|
||||
await loadVoices(form.ttsModelId, { autoPick: true })
|
||||
return
|
||||
}
|
||||
fillSelectedPack()
|
||||
})
|
||||
|
||||
watch(() => form.ttsModelId, (next) => {
|
||||
const nextDerived = deriveModelParts(next)
|
||||
const oldDerived = previousDerived.value
|
||||
if (!form.provider.trim() || form.provider === oldDerived.provider)
|
||||
form.provider = nextDerived.provider
|
||||
if (!form.model.trim() || form.model === oldDerived.model)
|
||||
form.model = nextDerived.model
|
||||
previousDerived.value = nextDerived
|
||||
void loadVoices(next, { autoPick: true })
|
||||
})
|
||||
|
||||
async function loadPacks() {
|
||||
loading.value = true
|
||||
try {
|
||||
packs.value = await adminApi.voicePacks()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to load Voice Packs'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
loadingCatalog.value = true
|
||||
try {
|
||||
models.value = await adminApi.speechModels()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to load speech models'))
|
||||
}
|
||||
finally {
|
||||
loadingCatalog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVoices(model: string, options: { autoPick: boolean }) {
|
||||
if (!model.trim()) {
|
||||
voices.value = []
|
||||
recommendedVoices.value = {}
|
||||
return
|
||||
}
|
||||
|
||||
loadingVoices.value = true
|
||||
try {
|
||||
const result = await adminApi.speechVoices(model.trim())
|
||||
voices.value = result.voices
|
||||
recommendedVoices.value = result.recommended
|
||||
if (options.autoPick && !form.voiceId.trim())
|
||||
form.voiceId = firstRecommendedVoiceId(result.recommended) ?? result.voices[0]?.id ?? ''
|
||||
}
|
||||
catch (error) {
|
||||
voices.value = []
|
||||
recommendedVoices.value = {}
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to load speech voices'))
|
||||
}
|
||||
finally {
|
||||
loadingVoices.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fillSelectedPack() {
|
||||
const pack = selectedPack.value
|
||||
if (!pack) {
|
||||
toast.error('Voice Pack not found')
|
||||
void router.replace('/voice-packs')
|
||||
return
|
||||
}
|
||||
fillForm(pack)
|
||||
}
|
||||
|
||||
function fillForm(pack: VoicePack) {
|
||||
form.name = pack.name
|
||||
form.description = pack.description ?? ''
|
||||
form.provider = pack.provider
|
||||
form.model = pack.model
|
||||
form.voiceId = pack.voiceId
|
||||
form.ttsModelId = pack.ttsModelId
|
||||
form.paramsJson = JSON.stringify(pack.params ?? {}, null, 2)
|
||||
form.costMultiplier = pack.costMultiplier
|
||||
form.status = pack.enabled ? 'enabled' : 'disabled'
|
||||
previousDerived.value = deriveModelParts(pack.ttsModelId)
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.provider = 'volcengine'
|
||||
form.model = 'seed-tts-2.0'
|
||||
form.voiceId = ''
|
||||
form.ttsModelId = 'volcengine/seed-tts-2.0'
|
||||
form.paramsJson = DEFAULT_PARAMS
|
||||
form.costMultiplier = 1
|
||||
form.status = 'enabled'
|
||||
testText.value = TEST_TEXT
|
||||
previousDerived.value = deriveModelParts(form.ttsModelId)
|
||||
revokeTestAudio()
|
||||
}
|
||||
|
||||
function parseParams(): VoicePackParams {
|
||||
const parsed = JSON.parse(form.paramsJson || '{}') as unknown
|
||||
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new Error('Params must be a JSON object')
|
||||
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (!key.trim())
|
||||
throw new Error('Params keys must not be empty')
|
||||
if (!supportedParams.has(key))
|
||||
throw new Error(`Unsupported Voice Pack parameter "${key}"`)
|
||||
const valid = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value == null
|
||||
if (!valid)
|
||||
throw new Error(`Unsupported params value for "${key}"`)
|
||||
}
|
||||
|
||||
return parsed as VoicePackParams
|
||||
}
|
||||
|
||||
function payload(): VoicePackPayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
provider: form.provider.trim(),
|
||||
model: form.model.trim(),
|
||||
voiceId: form.voiceId.trim(),
|
||||
ttsModelId: form.ttsModelId.trim(),
|
||||
params: parseParams(),
|
||||
costMultiplier: Number(form.costMultiplier),
|
||||
enabled: form.status === 'enabled',
|
||||
}
|
||||
}
|
||||
|
||||
async function savePack() {
|
||||
saving.value = true
|
||||
try {
|
||||
let saved: VoicePack
|
||||
if (isEditing.value && packId.value) {
|
||||
saved = await adminApi.updateVoicePack(packId.value, payload())
|
||||
toast.success('Voice Pack updated')
|
||||
}
|
||||
else {
|
||||
saved = await adminApi.createVoicePack(payload())
|
||||
toast.success('Voice Pack created')
|
||||
}
|
||||
await loadPacks()
|
||||
await router.replace(`/voice-packs/${encodeURIComponent(saved.id)}/edit`)
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to save Voice Pack'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePack() {
|
||||
if (!packId.value)
|
||||
return
|
||||
saving.value = true
|
||||
try {
|
||||
const disabled = await adminApi.disableVoicePack(packId.value)
|
||||
toast.success('Voice Pack disabled')
|
||||
await loadPacks()
|
||||
fillForm(disabled)
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to disable Voice Pack'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testVoicePack() {
|
||||
const text = testText.value.trim()
|
||||
if (!text) {
|
||||
toast.error('Test text is required')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
const params = parseParams()
|
||||
const body = {
|
||||
model: form.ttsModelId.trim(),
|
||||
input: text,
|
||||
voice: form.voiceId.trim(),
|
||||
speed: normalizeRateOption(params.rate),
|
||||
extra_body: voicePackExtraBody(params),
|
||||
}
|
||||
const blob = await adminApi.testSpeech(body)
|
||||
setTestAudio(blob)
|
||||
toast.success('Test audio generated')
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to generate test audio'))
|
||||
}
|
||||
finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function voicePackExtraBody(params: VoicePackParams) {
|
||||
const pitch = normalizePercentOption(params.pitch, 'pitch')
|
||||
const volume = normalizePercentOption(params.volume, 'volume')
|
||||
const voicePack: Record<string, unknown> = {}
|
||||
if (pitch != null)
|
||||
voicePack.pitch = pitch
|
||||
if (volume != null)
|
||||
voicePack.volume = volume
|
||||
return Object.keys(voicePack).length > 0 ? { voice_pack: voicePack } : undefined
|
||||
}
|
||||
|
||||
function setTestAudio(blob: Blob) {
|
||||
revokeTestAudio()
|
||||
testAudioUrl.value = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
function revokeTestAudio() {
|
||||
if (!testAudioUrl.value)
|
||||
return
|
||||
URL.revokeObjectURL(testAudioUrl.value)
|
||||
testAudioUrl.value = null
|
||||
}
|
||||
|
||||
function deriveModelParts(modelId: string): { provider: string, model: string } {
|
||||
const trimmed = modelId.trim()
|
||||
if (!trimmed)
|
||||
return { provider: '', model: '' }
|
||||
const [provider, ...rest] = trimmed.split('/')
|
||||
return {
|
||||
provider: provider || '',
|
||||
model: rest.join('/') || trimmed,
|
||||
}
|
||||
}
|
||||
|
||||
function voiceOptionDescription(voice: SpeechVoice): string | undefined {
|
||||
const parts = [
|
||||
typeof voice.labels?.gender === 'string' ? voice.labels.gender : undefined,
|
||||
voice.languages?.map(language => language.title || language.code).filter(Boolean).join(', '),
|
||||
voice.description,
|
||||
].filter(Boolean)
|
||||
return parts.join(' · ') || undefined
|
||||
}
|
||||
|
||||
function firstRecommendedVoiceId(recommended: Record<string, string>): string | undefined {
|
||||
return recommended['zh-CN'] ?? recommended['en-US'] ?? Object.values(recommended)[0]
|
||||
}
|
||||
|
||||
function normalizePercentOption(value: string | number | boolean | null | undefined, name: string): number | undefined {
|
||||
if (value == null)
|
||||
return undefined
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isFinite(value))
|
||||
return value
|
||||
throw new Error(`Voice Pack parameter "${name}" must be a finite number.`)
|
||||
}
|
||||
if (typeof value !== 'string')
|
||||
throw new Error(`Voice Pack parameter "${name}" must be a number or percent string.`)
|
||||
|
||||
const trimmed = value.trim()
|
||||
const normalized = trimmed.endsWith('%') ? trimmed.slice(0, -1) : trimmed
|
||||
const parsed = Number(normalized)
|
||||
if (!Number.isFinite(parsed))
|
||||
throw new Error(`Voice Pack parameter "${name}" must be a number or percent string.`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizeRateOption(value: string | number | boolean | null | undefined): number | undefined {
|
||||
if (value == null)
|
||||
return undefined
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isFinite(value) && value > 0)
|
||||
return value
|
||||
throw new Error('Voice Pack parameter "rate" must be a positive finite number or percent string.')
|
||||
}
|
||||
if (typeof value !== 'string')
|
||||
throw new Error('Voice Pack parameter "rate" must be a positive finite number or percent string.')
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.endsWith('%')) {
|
||||
const percent = normalizePercentOption(trimmed, 'rate')
|
||||
const speed = 1 + (percent ?? 0) / 100
|
||||
if (speed > 0)
|
||||
return speed
|
||||
throw new Error('Voice Pack parameter "rate" percent must resolve to a positive speed.')
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed)
|
||||
if (Number.isFinite(parsed) && parsed > 0)
|
||||
return parsed
|
||||
throw new Error('Voice Pack parameter "rate" must be a positive finite number or percent string.')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['mx-auto', 'max-w-5xl', 'space-y-5']">
|
||||
<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>
|
||||
<div :class="['mb-2', 'flex', 'items-center', 'gap-2', 'text-xs', 'font-medium', 'text-neutral-500']">
|
||||
<button :class="['inline-flex', 'items-center', 'gap-1', 'hover:text-neutral-900']" type="button" @click="router.push('/voice-packs')">
|
||||
<span :class="['i-lucide-arrow-left', 'h-3.5', 'w-3.5']" />
|
||||
Voice Packs
|
||||
</button>
|
||||
</div>
|
||||
<h2 :class="['text-sm', 'font-semibold']">
|
||||
{{ pageTitle }}
|
||||
</h2>
|
||||
<p :class="['mt-1', 'text-sm', 'text-neutral-500']">
|
||||
Select from configured speech models and voices, or type custom values when the catalog is incomplete.
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
||||
<span :class="['badge', form.status === 'enabled' ? 'badge-green' : 'badge-amber']">
|
||||
<span :class="[form.status === 'enabled' ? 'i-lucide-check-circle-2' : 'i-lucide-pause-circle']" />
|
||||
{{ form.status === 'enabled' ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
<Button icon="i-lucide-refresh-cw" label="Refresh Catalog" size="sm" type="button" variant="secondary" :loading="loadingCatalog" @click="loadCatalog" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form :class="['grid', 'gap-5', 'p-5', 'xl:grid-cols-[minmax(0,1fr)_320px]']" @submit.prevent="savePack">
|
||||
<div :class="['space-y-5']">
|
||||
<div :class="['grid', 'gap-4', 'md:grid-cols-2']">
|
||||
<FieldInput v-model="form.name" label="Name" placeholder="Narrator CN" required />
|
||||
<FieldInput v-model="form.description" label="Description" placeholder="Warm Mandarin narrator" />
|
||||
</div>
|
||||
|
||||
<div :class="['grid', 'gap-4', 'md:grid-cols-2']">
|
||||
<DatalistField
|
||||
v-model="form.ttsModelId"
|
||||
description="Configured router model ID from /api/v1/audio/models."
|
||||
input-class="font-mono text-xs"
|
||||
label="TTS model ID"
|
||||
list-id="voice-pack-tts-models"
|
||||
:options="modelOptions"
|
||||
placeholder="volcengine/seed-tts-2.0"
|
||||
required
|
||||
/>
|
||||
<DatalistField
|
||||
v-model="form.voiceId"
|
||||
:description="loadingVoices ? 'Loading voices for the selected model...' : 'Voice catalog from /api/v1/audio/voices.'"
|
||||
input-class="font-mono text-xs"
|
||||
label="Voice ID"
|
||||
list-id="voice-pack-voices"
|
||||
:options="voiceOptions"
|
||||
placeholder="zh_female_vv_uranus_bigtts"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="['grid', 'gap-4', 'md:grid-cols-2']">
|
||||
<DatalistField
|
||||
v-model="form.provider"
|
||||
description="Derived from the model ID when possible; editable for custom routing metadata."
|
||||
label="Provider"
|
||||
list-id="voice-pack-providers"
|
||||
:options="providerOptions"
|
||||
placeholder="volcengine"
|
||||
required
|
||||
/>
|
||||
<DatalistField
|
||||
v-model="form.model"
|
||||
description="Provider-native model name; derived from the router model ID when possible."
|
||||
label="Model"
|
||||
list-id="voice-pack-provider-models"
|
||||
:options="baseModelOptions"
|
||||
placeholder="seed-tts-2.0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="['grid', 'gap-4', 'md:grid-cols-[1fr_160px]']">
|
||||
<FieldInput v-model="form.costMultiplier" label="Cost multiplier" placeholder="1" type="number" />
|
||||
<FieldSelect
|
||||
v-model="form.status"
|
||||
label="Enabled"
|
||||
layout="vertical"
|
||||
:options="statusOptions"
|
||||
select-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FieldTextArea
|
||||
v-model="form.paramsJson"
|
||||
description="Supported keys: rate, pitch, volume. Example: { "rate": "+8%", "pitch": 3 }"
|
||||
label="Params JSON"
|
||||
placeholder="{ "rate": "+8%" }"
|
||||
:required="false"
|
||||
:rows="9"
|
||||
textarea-class="font-mono text-xs leading-5"
|
||||
/>
|
||||
|
||||
<Callout v-if="paramsError" label="Invalid params" theme="orange">
|
||||
{{ paramsError }}
|
||||
</Callout>
|
||||
<Callout v-else-if="formError" label="Missing fields" theme="orange">
|
||||
{{ formError }}
|
||||
</Callout>
|
||||
</div>
|
||||
|
||||
<aside :class="['space-y-4']">
|
||||
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-4']">
|
||||
<div :class="['mb-3', 'flex', 'items-center', 'gap-2']">
|
||||
<span :class="['i-lucide-waveform', 'text-neutral-500']" />
|
||||
<h3 :class="['text-sm', 'font-semibold']">
|
||||
Test Audio
|
||||
</h3>
|
||||
</div>
|
||||
<FieldTextArea
|
||||
v-model="testText"
|
||||
description="Generates audio with the current model, voice, rate, pitch, and volume before saving."
|
||||
label="Test text"
|
||||
:required="false"
|
||||
:rows="4"
|
||||
/>
|
||||
<div :class="['mt-3', 'flex', 'justify-end']">
|
||||
<Button
|
||||
icon="i-lucide-play"
|
||||
label="Test"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="paramsError != null || formError != null"
|
||||
:loading="testing"
|
||||
@click="testVoicePack"
|
||||
/>
|
||||
</div>
|
||||
<audio
|
||||
v-if="testAudioUrl"
|
||||
:class="['mt-4', 'w-full']"
|
||||
:src="testAudioUrl"
|
||||
controls
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
|
||||
<h3 :class="['text-sm', 'font-semibold']">
|
||||
Catalog Status
|
||||
</h3>
|
||||
<dl :class="['mt-3', 'space-y-2', 'text-xs', 'text-neutral-600']">
|
||||
<div :class="['flex', 'justify-between', 'gap-3']">
|
||||
<dt>Models</dt>
|
||||
<dd :class="['font-mono']">
|
||||
{{ models.length }}
|
||||
</dd>
|
||||
</div>
|
||||
<div :class="['flex', 'justify-between', 'gap-3']">
|
||||
<dt>Voices</dt>
|
||||
<dd :class="['font-mono']">
|
||||
{{ voices.length }}
|
||||
</dd>
|
||||
</div>
|
||||
<div :class="['flex', 'justify-between', 'gap-3']">
|
||||
<dt>Recommended</dt>
|
||||
<dd :class="['truncate', 'font-mono']">
|
||||
{{ firstRecommendedVoiceId(recommendedVoices) || 'none' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div :class="['border-t', 'border-neutral-200', 'pt-4', 'xl:col-span-2']">
|
||||
<div :class="['flex', 'flex-wrap', 'justify-between', 'gap-2']">
|
||||
<Button
|
||||
v-if="isEditing && selectedPack?.enabled"
|
||||
icon="i-lucide-ban"
|
||||
label="Disable"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="danger"
|
||||
:loading="saving"
|
||||
@click="disablePack"
|
||||
/>
|
||||
<span v-else />
|
||||
<div :class="['flex', 'flex-wrap', 'gap-2']">
|
||||
<Button icon="i-lucide-x" label="Cancel" size="sm" type="button" variant="secondary" @click="router.push('/voice-packs')" />
|
||||
<Button
|
||||
icon="i-lucide-save"
|
||||
label="Save"
|
||||
size="sm"
|
||||
type="submit"
|
||||
:disabled="loading || paramsError != null || formError != null"
|
||||
:loading="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,58 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { VoicePack, VoicePackParams, VoicePackPayload } from '../modules/api'
|
||||
import type { VoicePack } from '../modules/api'
|
||||
|
||||
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
|
||||
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { computed, onMounted, shallowRef } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { adminApi } from '../modules/api'
|
||||
|
||||
const DEFAULT_PARAMS = '{}'
|
||||
const router = useRouter()
|
||||
|
||||
const packs = shallowRef<VoicePack[]>([])
|
||||
const selected = shallowRef<VoicePack | null>(null)
|
||||
const loading = shallowRef(false)
|
||||
const saving = shallowRef(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
voiceId: '',
|
||||
ttsModelId: '',
|
||||
paramsJson: DEFAULT_PARAMS,
|
||||
costMultiplier: 1,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const enabledCount = computed(() => packs.value.filter(pack => pack.enabled).length)
|
||||
const disabledCount = computed(() => packs.value.length - enabledCount.value)
|
||||
const selectedId = computed(() => selected.value?.id ?? null)
|
||||
const paramsError = computed(() => {
|
||||
try {
|
||||
parseParams()
|
||||
return null
|
||||
}
|
||||
catch (error) {
|
||||
return errorMessageFromUnknown(error, 'Invalid params JSON')
|
||||
}
|
||||
})
|
||||
const formError = computed(() => {
|
||||
if (!form.name.trim())
|
||||
return 'Name is required'
|
||||
if (!form.provider.trim())
|
||||
return 'Provider is required'
|
||||
if (!form.model.trim())
|
||||
return 'Model is required'
|
||||
if (!form.ttsModelId.trim())
|
||||
return 'TTS model ID is required'
|
||||
if (!form.voiceId.trim())
|
||||
return 'Voice ID is required'
|
||||
if (!Number.isFinite(Number(form.costMultiplier)) || Number(form.costMultiplier) < 0)
|
||||
return 'Cost multiplier must be a non-negative number'
|
||||
return null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadPacks()
|
||||
@@ -62,11 +25,6 @@ async function loadPacks() {
|
||||
loading.value = true
|
||||
try {
|
||||
packs.value = await adminApi.voicePacks()
|
||||
if (selectedId.value) {
|
||||
selected.value = packs.value.find(pack => pack.id === selectedId.value) ?? null
|
||||
if (selected.value)
|
||||
fillForm(selected.value)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to load Voice Packs'))
|
||||
@@ -76,103 +34,6 @@ async function loadPacks() {
|
||||
}
|
||||
}
|
||||
|
||||
function fillForm(pack: VoicePack) {
|
||||
selected.value = pack
|
||||
form.name = pack.name
|
||||
form.description = pack.description ?? ''
|
||||
form.provider = pack.provider
|
||||
form.model = pack.model
|
||||
form.voiceId = pack.voiceId
|
||||
form.ttsModelId = pack.ttsModelId
|
||||
form.paramsJson = JSON.stringify(pack.params ?? {}, null, 2)
|
||||
form.costMultiplier = pack.costMultiplier
|
||||
form.enabled = pack.enabled
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
selected.value = null
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.provider = 'volcengine'
|
||||
form.model = 'seed-tts-2.0'
|
||||
form.voiceId = ''
|
||||
form.ttsModelId = ''
|
||||
form.paramsJson = DEFAULT_PARAMS
|
||||
form.costMultiplier = 1
|
||||
form.enabled = true
|
||||
}
|
||||
|
||||
function parseParams(): VoicePackParams {
|
||||
const parsed = JSON.parse(form.paramsJson || '{}') as unknown
|
||||
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new Error('Params must be a JSON object')
|
||||
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (!key.trim())
|
||||
throw new Error('Params keys must not be empty')
|
||||
const valid = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value == null
|
||||
if (!valid)
|
||||
throw new Error(`Unsupported params value for "${key}"`)
|
||||
}
|
||||
|
||||
return parsed as VoicePackParams
|
||||
}
|
||||
|
||||
function payload(): VoicePackPayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
provider: form.provider.trim(),
|
||||
model: form.model.trim(),
|
||||
voiceId: form.voiceId.trim(),
|
||||
ttsModelId: form.ttsModelId.trim(),
|
||||
params: parseParams(),
|
||||
costMultiplier: Number(form.costMultiplier),
|
||||
enabled: form.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
async function savePack() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (selected.value) {
|
||||
const updated = await adminApi.updateVoicePack(selected.value.id, payload())
|
||||
toast.success('Voice Pack updated')
|
||||
selected.value = updated
|
||||
}
|
||||
else {
|
||||
const created = await adminApi.createVoicePack(payload())
|
||||
toast.success('Voice Pack created')
|
||||
selected.value = created
|
||||
}
|
||||
await loadPacks()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to save Voice Pack'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disableSelected() {
|
||||
if (!selected.value)
|
||||
return
|
||||
saving.value = true
|
||||
try {
|
||||
const disabled = await adminApi.disableVoicePack(selected.value.id)
|
||||
toast.success('Voice Pack disabled')
|
||||
selected.value = disabled
|
||||
await loadPacks()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFromUnknown(error, 'Failed to disable Voice Pack'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(value))
|
||||
}
|
||||
@@ -180,192 +41,101 @@ function formatDate(value: string): string {
|
||||
function formatMultiplier(value: number): string {
|
||||
return `${Number(value.toFixed(2))}x`
|
||||
}
|
||||
|
||||
function editPack(pack: VoicePack) {
|
||||
void router.push(`/voice-packs/${encodeURIComponent(pack.id)}/edit`)
|
||||
}
|
||||
</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>
|
||||
<h2 class="text-sm font-semibold">
|
||||
Voice Packs
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">
|
||||
Curated speech presets exposed to users for character-card binding.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="badge badge-green">
|
||||
<span class="i-lucide-volume-2" />
|
||||
{{ enabledCount }} enabled
|
||||
</span>
|
||||
<span class="badge" :class="disabledCount > 0 ? 'badge-amber' : 'badge-green'">
|
||||
<span class="i-lucide-circle-slash" />
|
||||
{{ disabledCount }} disabled
|
||||
</span>
|
||||
</div>
|
||||
<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']">
|
||||
Voice Packs
|
||||
</h2>
|
||||
<p :class="['mt-1', 'text-sm', 'text-neutral-500']">
|
||||
Curated speech presets exposed to users for character-card binding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && packs.length === 0" class="empty-state">
|
||||
<span class="i-lucide-loader-2 animate-spin text-2xl" />
|
||||
Loading Voice Packs
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
||||
<span :class="['badge', 'badge-green']">
|
||||
<span :class="['i-lucide-volume-2']" />
|
||||
{{ enabledCount }} enabled
|
||||
</span>
|
||||
<span :class="['badge', disabledCount > 0 ? 'badge-amber' : 'badge-green']">
|
||||
<span :class="['i-lucide-circle-slash']" />
|
||||
{{ disabledCount }} disabled
|
||||
</span>
|
||||
<RouterLink to="/voice-packs/new">
|
||||
<Button icon="i-lucide-plus" label="New" size="sm" variant="secondary" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table v-else-if="packs.length > 0" class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Routing</th>
|
||||
<th>Cost</th>
|
||||
<th>Status</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="pack in packs"
|
||||
:key="pack.id"
|
||||
class="cursor-pointer transition-colors hover:bg-neutral-50"
|
||||
:class="{ 'bg-emerald-50/50': selectedId === pack.id }"
|
||||
tabindex="0"
|
||||
@click="fillForm(pack)"
|
||||
@keydown.enter.prevent="fillForm(pack)"
|
||||
@keydown.space.prevent="fillForm(pack)"
|
||||
>
|
||||
<td>
|
||||
<div class="font-medium">
|
||||
{{ pack.name }}
|
||||
</div>
|
||||
<div class="mt-1 max-w-[280px] truncate text-xs text-neutral-500">
|
||||
{{ pack.description || pack.voiceId }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-xs font-mono">
|
||||
{{ pack.ttsModelId }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-neutral-500">
|
||||
{{ pack.provider }} / {{ pack.model }}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatMultiplier(pack.costMultiplier) }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="pack.enabled ? 'badge-green' : 'badge-amber'">
|
||||
<span :class="pack.enabled ? 'i-lucide-check-circle-2' : 'i-lucide-pause-circle'" />
|
||||
{{ pack.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDate(pack.updatedAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="loading && packs.length === 0" :class="['empty-state']">
|
||||
<span :class="['i-lucide-loader-2', 'animate-spin', 'text-2xl']" />
|
||||
Loading Voice Packs
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<span class="i-lucide-volume-x text-2xl" />
|
||||
No Voice Packs configured
|
||||
</div>
|
||||
</section>
|
||||
<table v-else-if="packs.length > 0" :class="['table']">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Routing</th>
|
||||
<th>Voice</th>
|
||||
<th>Cost</th>
|
||||
<th>Status</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="pack in packs"
|
||||
:key="pack.id"
|
||||
:class="['cursor-pointer', 'transition-colors', 'hover:bg-neutral-50']"
|
||||
tabindex="0"
|
||||
@click="editPack(pack)"
|
||||
@keydown.enter.prevent="editPack(pack)"
|
||||
@keydown.space.prevent="editPack(pack)"
|
||||
>
|
||||
<td>
|
||||
<div :class="['font-medium']">
|
||||
{{ pack.name }}
|
||||
</div>
|
||||
<div :class="['mt-1', 'max-w-[280px]', 'truncate', 'text-xs', 'text-neutral-500']">
|
||||
{{ pack.description || 'No description' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div :class="['text-xs', 'font-mono']">
|
||||
{{ pack.ttsModelId }}
|
||||
</div>
|
||||
<div :class="['mt-1', 'text-xs', 'text-neutral-500']">
|
||||
{{ pack.provider }} / {{ pack.model }}
|
||||
</div>
|
||||
</td>
|
||||
<td :class="['text-xs', 'font-mono']">
|
||||
{{ pack.voiceId }}
|
||||
</td>
|
||||
<td>{{ formatMultiplier(pack.costMultiplier) }}</td>
|
||||
<td>
|
||||
<span :class="['badge', pack.enabled ? 'badge-green' : 'badge-amber']">
|
||||
<span :class="[pack.enabled ? 'i-lucide-check-circle-2' : 'i-lucide-pause-circle']" />
|
||||
{{ pack.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDate(pack.updatedAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<aside class="panel p-5">
|
||||
<div class="mb-5 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ selected ? 'Edit Voice Pack' : 'New Voice Pack' }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">
|
||||
Frozen copies stay on character cards after binding.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary" type="button" @click="resetForm">
|
||||
<span class="i-lucide-plus" />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="savePack">
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Name</span>
|
||||
<input v-model="form.name" class="field" required type="text">
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Description</span>
|
||||
<input v-model="form.description" class="field" type="text">
|
||||
</label>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Provider</span>
|
||||
<input v-model="form.provider" class="field" required type="text">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Model</span>
|
||||
<input v-model="form.model" class="field" required type="text">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">TTS model ID</span>
|
||||
<input v-model="form.ttsModelId" class="field text-xs font-mono" required type="text">
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Voice ID</span>
|
||||
<input v-model="form.voiceId" class="field text-xs font-mono" required type="text">
|
||||
</label>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-[1fr_120px]">
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Cost multiplier</span>
|
||||
<input v-model.number="form.costMultiplier" class="field" min="0" step="0.1" type="number">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Enabled</span>
|
||||
<select v-model="form.enabled" class="field">
|
||||
<option :value="true">
|
||||
Yes
|
||||
</option>
|
||||
<option :value="false">
|
||||
No
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Params JSON</span>
|
||||
<textarea
|
||||
v-model="form.paramsJson"
|
||||
class="textarea min-h-[180px] text-xs leading-5 font-mono"
|
||||
placeholder="{ "rate": "+5%" }"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="paramsError" class="border border-amber-200 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
{{ paramsError }}
|
||||
</div>
|
||||
<div v-else-if="formError" class="border border-amber-200 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4">
|
||||
<button
|
||||
v-if="selected?.enabled"
|
||||
class="btn btn-danger"
|
||||
:disabled="saving"
|
||||
type="button"
|
||||
@click="disableSelected"
|
||||
>
|
||||
<span class="i-lucide-ban" />
|
||||
Disable
|
||||
</button>
|
||||
<button class="btn btn-primary" :disabled="saving || paramsError != null || formError != null" type="submit">
|
||||
<span :class="saving ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-save'" />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
<div v-else :class="['empty-state']">
|
||||
<span :class="['i-lucide-volume-x', 'text-2xl']" />
|
||||
No Voice Packs configured
|
||||
<RouterLink to="/voice-packs/new">
|
||||
<Button icon="i-lucide-plus" label="Create Voice Pack" size="sm" variant="secondary" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: Agent Browser Verification With Mock APIs
|
||||
date: 2026-06-06
|
||||
category: developer-experience
|
||||
module: Local frontend verification
|
||||
problem_type: developer_experience
|
||||
component: tooling
|
||||
severity: medium
|
||||
applies_when:
|
||||
- "Verifying a local Vite or Vue page that is gated by backend authentication"
|
||||
- "Using agent-browser, Playwright MCP, or browser screenshots to inspect local UI changes"
|
||||
- "The real backend is unavailable, unauthenticated, or too expensive to call during UI verification"
|
||||
- "pnpm fails before running the requested command because a proto shim tries to update files outside the workspace sandbox"
|
||||
tags:
|
||||
- agent-browser
|
||||
- playwright
|
||||
- mock-api
|
||||
- vite
|
||||
- pnpm
|
||||
- proto
|
||||
---
|
||||
|
||||
# Agent Browser Verification With Mock APIs
|
||||
|
||||
## Context
|
||||
|
||||
Local UI verification can waste time when the page depends on backend state that the browser cannot reach. In the Voice Pack admin page work, opening the page through the browser first showed only `Admin access required` because the root app requests `/api/admin/me` before rendering child routes. The form itself was fine, but the real page could not be inspected until the auth boundary was handled.
|
||||
|
||||
The same session also hit two tool-environment traps:
|
||||
|
||||
- launching a standalone Playwright browser failed because the Playwright-managed browser cache was missing;
|
||||
- running `pnpm` through the proto shim failed with `fs::perms` because the shim attempted to update `~/.proto/.../pnpx`, which was outside the active workspace sandbox.
|
||||
|
||||
## Guidance
|
||||
|
||||
Start with the in-app Browser or Playwright MCP against the real local URL, but treat an auth-only page as an environment boundary, not as proof the feature page is broken. Capture a snapshot first because it identifies the blocking text and accessible controls faster than screenshots.
|
||||
|
||||
When the root app blocks on auth or backend bootstrap, provide a tiny local mock API and point the Vite app at it. For AIRI admin pages, the useful pattern is:
|
||||
|
||||
```bash
|
||||
node -e "const http=require('http'); /* serve /api/admin/me and required page APIs */"
|
||||
```
|
||||
|
||||
Then run the app with the mock server as the API origin:
|
||||
|
||||
```bash
|
||||
VITE_SERVER_URL=http://127.0.0.1:8787 ./node_modules/.bin/vite --host 127.0.0.1 --port 5175
|
||||
```
|
||||
|
||||
Prefer the repository binary or the actual pnpm CJS entrypoint when the global `pnpm` command fails before the project script starts:
|
||||
|
||||
```bash
|
||||
node /Users/luoling8192/.proto/tools/pnpm/10.33.0/bin/pnpm.cjs -F @proj-airi/ui-admin exec vue-tsc --noEmit
|
||||
```
|
||||
|
||||
This avoids the proto shim permission update and keeps validation focused on project failures. If a new Codex thread has `/Users/luoling8192/.proto` in its writable roots, normal `pnpm` may be fine again; verify with:
|
||||
|
||||
```bash
|
||||
test -w /Users/luoling8192/.proto
|
||||
```
|
||||
|
||||
Use the browser tools in this order:
|
||||
|
||||
1. Open the real route and take an accessibility snapshot.
|
||||
2. If auth blocks the route, inspect the app root to find the bootstrap request.
|
||||
3. Start a mock API with only the endpoints required for that route.
|
||||
4. Restart or start Vite with the mock API origin.
|
||||
5. Re-open the route and validate the actual controls through accessibility snapshots.
|
||||
6. Use screenshots only after the DOM is known to be the intended page; screenshots can stall on font loading.
|
||||
|
||||
Do not over-claim browser behavior from a fake backend. Mock verification can prove layout, accessible controls, routing, and button wiring are present. It cannot prove real provider behavior, real audio generation, auth cookies, billing, or production routing unless those real services were used.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Without this sequence, browser verification turns into tool thrash: trying new tabs, launching standalone Playwright, retrying screenshots, and rerunning pnpm with escalation while the real problem is simply that the app has not rendered the target page yet.
|
||||
|
||||
Separating boundaries keeps the evidence clean:
|
||||
|
||||
- auth failures explain why the target page is absent;
|
||||
- mock API runs verify UI rendering and interaction shape;
|
||||
- real backend runs verify integration behavior;
|
||||
- pnpm/proto shim failures are environment setup noise unless the project command actually starts and fails.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- A local page redirects to sign-in or renders an access-required shell before the component under test appears.
|
||||
- The frontend already supports an API-origin environment variable such as `VITE_SERVER_URL`.
|
||||
- The feature needs visual or accessibility verification but does not require live provider side effects.
|
||||
- Browser screenshots time out or standalone Playwright fails before navigation.
|
||||
- `pnpm` reports `fs::perms` around `~/.proto` before printing project script output.
|
||||
|
||||
## Examples
|
||||
|
||||
Before:
|
||||
|
||||
```text
|
||||
Open /admin/voice-packs/new
|
||||
See "Admin access required"
|
||||
Try screenshots and standalone Playwright
|
||||
Treat missing form as ambiguous UI failure
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```text
|
||||
Open /admin/voice-packs/new
|
||||
Snapshot shows /api/admin/me blocks rendering
|
||||
Mock /api/admin/me, /api/admin/voice-packs, /api/v1/audio/models, and /api/v1/audio/voices
|
||||
Run Vite with VITE_SERVER_URL pointing to the mock API
|
||||
Snapshot confirms the form, combobox fields, catalog counts, and buttons render
|
||||
Reserve real audio claims for a real /api/v1/audio/speech backend run
|
||||
```
|
||||
|
||||
For audio or media tests, a mock response can confirm the request path is wired, but it may not prove browser playback. Confirm `<audio controls>` appears only when the DOM shows it, and confirm provider behavior only with the real backend and a valid media response.
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/ai/context/verification-automation.md`
|
||||
- `docs/ai/context/ui-components.md`
|
||||
Reference in New Issue
Block a user