From 87e94956674ce1af14ed61ff0243a25f34de3bd4 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sat, 6 Jun 2026 02:22:54 +0800 Subject: [PATCH] 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 --- AGENTS.md | 1 + apps/ui-admin/src/App.vue | 9 +- .../components/voice-packs/DatalistField.vue | 44 ++ apps/ui-admin/src/main.ts | 3 + apps/ui-admin/src/modules/api.ts | 85 +++ apps/ui-admin/src/pages/VoicePackFormPage.vue | 631 ++++++++++++++++++ apps/ui-admin/src/pages/VoicePacksPage.vue | 418 +++--------- .../agent-browser-mock-api-verification.md | 119 ++++ 8 files changed, 984 insertions(+), 326 deletions(-) create mode 100644 apps/ui-admin/src/components/voice-packs/DatalistField.vue create mode 100644 apps/ui-admin/src/pages/VoicePackFormPage.vue create mode 100644 docs/solutions/developer-experience/agent-browser-mock-api-verification.md diff --git a/AGENTS.md b/AGENTS.md index e1a6a261c..28523b6fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/apps/ui-admin/src/App.vue b/apps/ui-admin/src/App.vue index 288ea360d..40165aa53 100644 --- a/apps/ui-admin/src/App.vue +++ b/apps/ui-admin/src/App.vue @@ -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 }" > {{ item.label }} diff --git a/apps/ui-admin/src/components/voice-packs/DatalistField.vue b/apps/ui-admin/src/components/voice-packs/DatalistField.vue new file mode 100644 index 000000000..ec5fe9e31 --- /dev/null +++ b/apps/ui-admin/src/components/voice-packs/DatalistField.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/ui-admin/src/main.ts b/apps/ui-admin/src/main.ts index 720d7bf5a..01c8fb373 100644 --- a/apps/ui-admin/src/main.ts +++ b/apps/ui-admin/src/main.ts @@ -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 }, ], }) diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts index 2fe8b81ab..a787bea22 100644 --- a/apps/ui-admin/src/modules/api.ts +++ b/apps/ui-admin/src/modules/api.ts @@ -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 + tags?: string[] + languages?: { code: string, title: string }[] + preview_audio_url?: string +} + +export interface SpeechVoicesResult { + voices: SpeechVoice[] + recommended: Record +} + +export interface SpeechTestPayload { + model: string + input: string + voice: string + speed?: number + extra_body?: { + voice_pack?: Record + } +} + export class AdminApiError extends Error { constructor( message: string, @@ -118,6 +148,15 @@ export function signInUrl(): string { async function adminFetch(path: string, init: RequestInit = {}): Promise { const endpoint = new URL(`/api/admin${path}`, apiServerUrl()) + return fetchJson(endpoint, init) +} + +async function publicFetch(path: string, init: RequestInit = {}): Promise { + const endpoint = new URL(`/api/v1${path}`, apiServerUrl()) + return fetchJson(endpoint, init) +} + +async function fetchJson(endpoint: URL, init: RequestInit = {}): Promise { const headers = new Headers(init.headers) if (init.body && !headers.has('Content-Type')) @@ -145,6 +184,34 @@ async function adminFetch(path: string, init: RequestInit = {}): Promise { return payload as T } +async function publicFetchBlob(path: string, init: RequestInit = {}): Promise { + 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 => { + const query = new URLSearchParams() + query.set('model', model) + const data = await publicFetch>(`/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('/voice-packs'), createVoicePack: (body: VoicePackPayload) => adminFetch('/voice-packs', { diff --git a/apps/ui-admin/src/pages/VoicePackFormPage.vue b/apps/ui-admin/src/pages/VoicePackFormPage.vue new file mode 100644 index 000000000..8666f76a6 --- /dev/null +++ b/apps/ui-admin/src/pages/VoicePackFormPage.vue @@ -0,0 +1,631 @@ + + + diff --git a/apps/ui-admin/src/pages/VoicePacksPage.vue b/apps/ui-admin/src/pages/VoicePacksPage.vue index 0f0955b6b..f5a712941 100644 --- a/apps/ui-admin/src/pages/VoicePacksPage.vue +++ b/apps/ui-admin/src/pages/VoicePacksPage.vue @@ -1,58 +1,21 @@