chore: remove admin dashboard

This commit is contained in:
RainbowBird 2026-07-02 02:15:28 +08:00
parent c7de38b121
commit 63532367a9
No known key found for this signature in database
46 changed files with 28 additions and 6772 deletions

View file

@ -1,55 +0,0 @@
name: Cloudflare Pages (Admin UI)
on:
push:
branches:
- 'main'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Deploy - ui-admin
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
environment:
name: Admin Production
url: https://admin.airi.build/
steps:
- uses: actions/checkout@v6
# Turborepo
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-admin-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-admin-
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: lts/*
cache: pnpm
- run: pnpm i -g wrangler@4
- run: pnpm install --frozen-lockfile
- run: pnpm run build:packages
- name: Build ui-admin
run: pnpm -F @proj-airi/ui-admin run build
env:
VITE_SERVER_URL: 'https://api.airi.build'
- uses: cloudflare/wrangler-action@v3.14.1
with:
apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./apps/ui-admin/dist --project-name=airi-server-admin --branch=main
gitHubToken: ${{ secrets.GITHUB_TOKEN }}

View file

@ -130,50 +130,3 @@ jobs:
- name: Print preview URL - name: Print preview URL
run: | run: |
echo "Preview URL: https://server-dev.airi-server-auth.pages.dev/ui/" echo "Preview URL: https://server-dev.airi-server-auth.pages.dev/ui/"
deploy-admin-ui:
name: Deploy - ui-admin (server-dev)
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
environment:
name: AdminServerDev
url: https://server-dev.airi-server-admin.pages.dev/
steps:
- uses: actions/checkout@v6
# Turborepo
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-admin-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-admin-
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: lts/*
cache: pnpm
- run: pnpm i -g wrangler@4
- run: pnpm install --frozen-lockfile
- run: pnpm run build:packages
- name: Build ui-admin
run: pnpm -F @proj-airi/ui-admin run build
env:
VITE_SERVER_URL: 'https://airi-server-dev.up.railway.app'
- name: Wrangler Pages Deploy
uses: cloudflare/wrangler-action@v3.14.1
with:
apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./apps/ui-admin/dist --project-name=airi-server-admin --branch=server-dev
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
- name: Print preview URL
run: |
echo "Preview URL: https://server-dev.airi-server-admin.pages.dev/"

View file

@ -38,7 +38,7 @@ Set this when previewing or deploying auth UI to a different Cloudflare URL.
## `ADMIN_UI_URL` ## `ADMIN_UI_URL`
`apps/ui-admin` is deployed separately from the server image. The API server still owns the historical `/admin/*` entrypoints and redirects them to **`ADMIN_UI_URL`**. The admin UI is deployed from the standalone `proj-airi` repository. The API server still owns the historical `/admin/*` entrypoints and redirects them to **`ADMIN_UI_URL`**.
Default: Default:

View file

@ -86,8 +86,8 @@ const EnvSchema = object({
AUTH_UI_URL: optional(string(), 'https://accounts.airi.build/ui'), AUTH_UI_URL: optional(string(), 'https://accounts.airi.build/ui'),
// Standalone admin UI base URL. The server keeps `/admin/*` as the historical // Standalone admin UI base URL. The server keeps `/admin/*` as the historical
// entrypoint and redirects those requests here after ui-admin moved out of // entrypoint and redirects those requests here after the admin UI moved to
// the server image. // the standalone proj-airi repository.
ADMIN_UI_URL: optional(string(), 'https://admin.airi.build'), ADMIN_UI_URL: optional(string(), 'https://admin.airi.build'),
// Canonical user-facing web app origin. Used as the Stripe redirect base // Canonical user-facing web app origin. Used as the Stripe redirect base

View file

@ -9,7 +9,7 @@ export const SERVER_DEV_ADMIN_UI_URL = 'https://server-dev.airi-server-admin.pag
* *
* Use when: * Use when:
* - Redirecting server-owned admin UI entrypoints to the standalone * - Redirecting server-owned admin UI entrypoints to the standalone
* `apps/ui-admin` deployment. * admin UI deployment from the `proj-airi` repository.
* - Preserving dashboard route paths and query parameters. * - Preserving dashboard route paths and query parameters.
* *
* Expects: * Expects:

View file

@ -1,25 +0,0 @@
# AIRI Admin Dashboard
Admin dashboard for operating the hosted AIRI server. It is a standalone Vue/Vite app deployed to Cloudflare Pages; the API server redirects its historical `/admin/*` entrypoints to this app.
## Use When
- Reviewing server metrics, users, flux balances, LLM router config, and curated Voice Packs.
- Building operator-only workflows that depend on the server admin API under `/api/admin`.
## Do Not Use When
- Building end-user settings or character-card flows. Those belong in the stage apps and shared stage packages.
- Adding unauthenticated server UI. This app expects the server admin guard and Better Auth session cookies.
## Commands
```sh
pnpm -F @proj-airi/ui-admin dev
pnpm -F @proj-airi/ui-admin typecheck
pnpm -F @proj-airi/ui-admin build
```
## Build Output
`pnpm -F @proj-airi/ui-admin build` writes to `apps/ui-admin/dist`. Server builds do not package this output; deploy the directory through the admin Cloudflare Pages workflow.

View file

@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>AIRI Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f8fafc" />
</head>
<body class="font-sans">
<div id="app"></div>
<script id="airi-server-admin-context" type="application/json">__AIRI_SERVER_ADMIN_CONTEXT__</script>
<script type="module" src="/src/main.ts"></script>
<noscript>This dashboard requires JavaScript.</noscript>
</body>
</html>

View file

@ -1,34 +0,0 @@
{
"name": "@proj-airi/ui-admin",
"type": "module",
"private": true,
"description": "Admin dashboard for Project AIRI",
"scripts": {
"build": "vite build",
"dev": "vite --host",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@proj-airi/font-chillroundm": "workspace:^",
"@proj-airi/stage-shared": "workspace:^",
"@proj-airi/ui": "workspace:^",
"@vueuse/core": "^14.2.1",
"nprogress": "catalog:",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:",
"vue-sonner": "catalog:"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.102",
"@types/nprogress": "^0.2.3",
"@unocss/reset": "^66.6.8",
"@vitejs/plugin-vue": "^6.0.6",
"@vue-macros/volar": "^3.1.2",
"unocss": "catalog:",
"vite": "catalog:",
"vue-macros": "catalog:",
"vue-tsc": "catalog:"
}
}

View file

@ -1,4 +0,0 @@
/assets/*
cache-control: max-age=31536000
cache-control: immutable

View file

@ -1 +0,0 @@
/* /index.html 200

View file

@ -1,69 +0,0 @@
// @vitest-environment jsdom
import type { App as VueApp } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import { AdminApiError } from './modules/api'
const mocks = vi.hoisted(() => ({
me: vi.fn(),
}))
vi.mock('./modules/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('./modules/api')>()
return {
...actual,
adminApi: {
me: mocks.me,
},
}
})
describe('admin app shell', () => {
let app: VueApp<Element>
let host: HTMLElement
beforeEach(() => {
mocks.me.mockRejectedValue(new AdminApiError('unauthorized', 401, null))
window.history.replaceState(null, '', '/llm-router?api_server_url=https%3A%2F%2Fapi.airi.build')
document.body.innerHTML = '<div id="app"></div>'
host = document.querySelector('#app')!
})
afterEach(() => {
app.unmount()
vi.clearAllMocks()
})
it('shows a sign-in page with backend switching instead of immediately redirecting on 401', async () => {
const router = createRouter({
history: createWebHistory('/'),
routes: [
{ path: '/llm-router', component: { template: '<div />' } },
],
})
app = createApp(App)
app.use(router)
app.mount(host)
await router.isReady()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/llm-router')
expect(host.textContent).toContain('Sign in to AIRI Admin')
expect(host.textContent).toContain('Production - api.airi.build')
const href = host.querySelector('a')?.getAttribute('href')
expect(href).toContain('https://api.airi.build/auth/sign-in?redirect=')
expect(decodeURIComponent(href ?? '')).toContain('api_server_url=https%3A%2F%2Fapi.airi.build')
})
})
async function flushPromises() {
await nextTick()
await Promise.resolve()
await nextTick()
}

View file

@ -1,160 +0,0 @@
<script setup lang="ts">
import type { AdminMe } from './modules/api'
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
import { computed, onMounted, shallowRef } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import { Toaster } from 'vue-sonner'
import ApiEnvironmentSelect from './components/admin-shell/ApiEnvironmentSelect.vue'
import { adminApi, AdminApiError, apiServerUrl, signInUrl } from './modules/api'
const route = useRoute()
const loading = shallowRef(true)
const me = shallowRef<AdminMe | null>(null)
const accessError = shallowRef<string | null>(null)
const needsSignIn = shallowRef(false)
const currentApiServerUrl = apiServerUrl()
const navItems = [
{ to: '/', icon: 'i-lucide-layout-dashboard', label: 'Overview' },
{ to: '/users', icon: 'i-lucide-users', label: 'Users' },
{ to: '/flux', icon: 'i-lucide-coins', label: 'Flux' },
{ to: '/llm-router', icon: 'i-lucide-route', label: 'LLM Router' },
{ to: '/capability-aliases', icon: 'i-lucide-route', label: 'Capability Aliases' },
{ to: '/tts', icon: 'i-lucide-audio-lines', label: 'TTS' },
{ to: '/voice-packs', icon: 'i-lucide-volume-2', label: 'Voice Packs' },
]
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()
})
onMounted(async () => {
try {
me.value = await adminApi.me()
}
catch (error) {
if (error instanceof AdminApiError && error.status === 401) {
needsSignIn.value = true
return
}
accessError.value = errorMessageFromUnknown(error, 'Admin access required')
}
finally {
loading.value = false
}
})
</script>
<template>
<div class="min-h-screen bg-[#f7f8fa] text-[#171717] dark:bg-neutral-950 dark:text-neutral-100">
<div v-if="loading" class="grid min-h-screen place-items-center">
<div class="flex items-center gap-3 text-sm text-neutral-500 dark:text-neutral-400">
<span class="i-lucide-loader-2 animate-spin text-lg" />
Loading admin session
</div>
</div>
<div v-else-if="needsSignIn || accessError" :class="['grid', 'min-h-screen', 'place-items-center', 'px-6']">
<div :class="['fixed', 'right-6', 'top-5']">
<ApiEnvironmentSelect :api-server-url="currentApiServerUrl" />
</div>
<section class="max-w-md w-full border border-neutral-200 rounded-lg bg-white p-6 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
<div :class="['mb-4', 'h-10', 'w-10', 'flex', 'items-center', 'justify-center', 'rounded-lg', needsSignIn ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600']">
<span :class="[needsSignIn ? 'i-lucide-lock-keyhole' : 'i-lucide-shield-alert', 'text-xl']" />
</div>
<h1 class="text-xl font-semibold">
{{ needsSignIn ? 'Sign in to AIRI Admin' : 'Admin access required' }}
</h1>
<p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
{{ needsSignIn ? 'Choose the backend environment, then continue to its auth page.' : accessError }}
</p>
<a class="mt-5 h-9 inline-flex items-center gap-2 rounded-md bg-emerald-600 px-3 text-sm text-white" :href="signInUrl()">
<span class="i-lucide-log-in" />
Sign in
</a>
</section>
</div>
<div v-else class="admin-shell">
<aside class="admin-sidebar">
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="h-7 w-7 flex items-center justify-center rounded-md bg-neutral-950 text-white">
<span class="i-lucide-sparkles text-sm" />
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold">
AIRI Admin
</div>
<div class="truncate text-xs text-neutral-500 dark:text-neutral-400">
Operations
</div>
</div>
</div>
<button class="quick-action" type="button" @click="$router.push('/flux')">
<span class="i-lucide-plus-circle" />
Quick Grant
</button>
<nav class="mt-3 space-y-1">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="nav-item"
:class="{ 'nav-item-active': activeNavItem?.to === item.to }"
>
<span :class="item.icon" />
{{ item.label }}
</RouterLink>
</nav>
<div class="mt-auto">
<div class="mt-4 flex items-center gap-3 rounded-lg px-2 py-2">
<div class="h-9 w-9 flex shrink-0 items-center justify-center rounded-full bg-neutral-900 text-sm text-white">
{{ initials }}
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">
{{ me?.user.name }}
</div>
<div class="truncate text-xs text-neutral-500">
{{ me?.user.email }}
</div>
</div>
</div>
</div>
</aside>
<main class="admin-main">
<header class="admin-topbar">
<div class="flex items-center gap-3">
<span class="i-lucide-panel-left text-neutral-500 dark:text-neutral-400" />
<div class="h-5 w-px bg-neutral-200 dark:bg-neutral-800" />
<h1 class="text-sm font-semibold">
{{ currentTitle }}
</h1>
</div>
<ApiEnvironmentSelect :api-server-url="currentApiServerUrl" />
</header>
<div class="admin-content">
<RouterView />
</div>
</main>
</div>
<Toaster rich-colors position="top-right" />
</div>
</template>

View file

@ -1,187 +0,0 @@
<script setup lang="ts">
import { computed, shallowRef, watch } from 'vue'
export interface AdminListColumn {
key: string
label: string
sortable?: boolean
}
export interface AdminListFilter {
key: string
label: string
value: string
options: Array<{
label: string
value: string
}>
}
const props = withDefaults(defineProps<{
columns: AdminListColumn[]
currentPage: number
emptyLabel: string
filters?: AdminListFilter[]
initialLoading: boolean
loading: boolean
loadingPage?: number | null
pageEnd: number
pageStart: number
search: string
searchPlaceholder: string
sortDirection: 'asc' | 'desc'
sortKey: string
tableClass?: string
title: string
totalItems: number
totalPages: number
description?: string
}>(), {
description: '',
filters: () => [],
loadingPage: null,
tableClass: '',
})
const emit = defineEmits<{
filter: [key: string, value: string]
page: [page: number]
search: [value: string]
sort: [key: string]
}>()
const searchDraft = shallowRef(props.search)
const pageDraft = shallowRef(String(props.currentPage))
const hasPreviousPage = computed(() => props.currentPage > 1)
const hasNextPage = computed(() => props.currentPage < props.totalPages)
watch(() => props.search, (value) => {
searchDraft.value = value
})
watch(() => props.currentPage, (page) => {
pageDraft.value = String(page)
})
function submitSearch() {
emit('search', searchDraft.value)
}
function submitPage() {
const parsed = Number(pageDraft.value)
if (!Number.isFinite(parsed))
return
emit('page', Math.min(Math.max(1, Math.trunc(parsed)), props.totalPages))
}
</script>
<template>
<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">
{{ title }}
</h2>
<p v-if="description" class="mt-1 text-sm text-neutral-500">
{{ description }}
</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<slot name="actions" />
<form class="min-w-0 flex gap-2" @submit.prevent="submitSearch">
<input v-model="searchDraft" class="field w-72" :placeholder="searchPlaceholder" type="search">
<button class="btn btn-secondary" type="submit">
<span class="i-lucide-search" />
Search
</button>
</form>
<label v-for="filter in filters" :key="filter.key" class="flex items-center gap-2 text-sm text-neutral-500">
{{ filter.label }}
<select class="field w-36" :value="filter.value" @change="emit('filter', filter.key, ($event.target as HTMLSelectElement).value)">
<option v-for="option in filter.options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div>
</div>
<div class="overflow-x-auto">
<table class="table" :class="tableClass">
<slot name="colgroup" />
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
<button
v-if="column.sortable"
class="inline-flex items-center gap-1 text-left font-semibold"
type="button"
@click="emit('sort', column.key)"
>
{{ column.label }}
<span v-if="sortKey === column.key" :class="sortDirection === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'" />
<span v-else class="i-lucide-arrow-up-down text-neutral-400" />
</button>
<span v-else>{{ column.label }}</span>
</th>
</tr>
</thead>
<tbody>
<template v-if="initialLoading">
<tr v-for="index in 6" :key="index">
<td v-for="column in columns" :key="column.key">
<div class="h-4 w-24 animate-pulse rounded bg-neutral-200" />
</td>
</tr>
</template>
<slot v-else name="rows" />
</tbody>
</table>
</div>
<div v-if="totalItems === 0 && !loading" class="empty-state">
<span class="i-lucide-list-filter text-2xl" />
{{ emptyLabel }}
</div>
<div v-if="loading && !initialLoading" class="border-t border-neutral-100 px-5 py-3 text-sm text-neutral-500">
<span class="inline-flex items-center gap-2">
<span class="i-lucide-loader-2 animate-spin" />
Loading page {{ loadingPage ?? currentPage }}
</span>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
<div class="text-sm text-neutral-500">
Page {{ currentPage }} of {{ totalPages }} · {{ pageStart }}-{{ pageEnd }} of {{ totalItems.toLocaleString() }} items
</div>
<div class="flex flex-wrap items-center gap-2">
<form class="flex items-center gap-2" @submit.prevent="submitPage">
<span class="text-sm text-neutral-500">Go to</span>
<input
v-model="pageDraft"
class="field w-20 text-center"
inputmode="numeric"
min="1"
:max="totalPages"
type="number"
>
<button class="btn btn-secondary" :disabled="loading" type="submit">
<span v-if="loading" class="i-lucide-loader-2 animate-spin" />
Go
</button>
</form>
<button class="btn btn-secondary" :disabled="loading || !hasPreviousPage" type="button" @click="emit('page', currentPage - 1)">
<span :class="loading && loadingPage === currentPage - 1 ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-chevron-left'" />
Previous
</button>
<button class="btn btn-secondary" :disabled="loading || !hasNextPage" type="button" @click="emit('page', currentPage + 1)">
Next
<span :class="loading && loadingPage === currentPage + 1 ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-chevron-right'" />
</button>
</div>
</div>
</section>
</template>

View file

@ -1,60 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ADMIN_API_ENVIRONMENTS, apiEnvironmentValueFor, buildApiServerSwitchUrl } from '../../modules/server-admin-context'
const props = defineProps<{
apiServerUrl: string
}>()
const selectedEnvironment = computed(() => apiEnvironmentValueFor(props.apiServerUrl))
function switchEnvironment(event: Event) {
const target = event.target
if (!(target instanceof HTMLSelectElement))
return
if (target.value === selectedEnvironment.value)
return
window.location.assign(buildApiServerSwitchUrl(window.location.href, target.value))
}
</script>
<template>
<label :class="['flex', 'items-center', 'gap-2', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
<span :class="['i-lucide-server', 'h-4', 'w-4', 'shrink-0']" />
<span :class="['sr-only']">API environment</span>
<select
:class="[
'h-8',
'max-w-[180px]',
'rounded-md',
'border',
'border-neutral-200',
'bg-white',
'px-2',
'text-xs',
'font-medium',
'text-neutral-700',
'outline-none',
'dark:border-neutral-800',
'dark:bg-neutral-950',
'dark:text-neutral-200',
'focus:border-emerald-500',
'focus:ring-3',
'focus:ring-emerald-500/14',
]"
:value="selectedEnvironment"
@change="switchEnvironment"
>
<option
v-for="environment in ADMIN_API_ENVIRONMENTS"
:key="environment.value"
:value="environment.value"
>
{{ environment.label }} - {{ environment.description }}
</option>
</select>
</label>
</template>

View file

@ -1,74 +0,0 @@
<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', 'dark:border-neutral-800']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Advanced JSON
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
Escape hatch for auditing or unsupported future fields.
</p>
</div>
<span :class="['i-lucide-code-2', 'text-neutral-500', 'dark:text-neutral-400']" />
</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>

View file

@ -1,69 +0,0 @@
<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', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<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', 'dark:text-neutral-400']">
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', 'dark:text-neutral-400']">
JSON object written to DEFAULT_TTS_VOICES.
</div>
</div>
<Button
class="whitespace-nowrap"
label="Example"
size="sm"
type="button"
variant="secondary"
@click="defaults.ttsVoicesJson = defaultTtsVoicesJson()"
/>
</div>
<FieldTextArea
v-model="defaults.ttsVoicesJson"
:required="false"
:rows="6"
textarea-class="font-mono text-xs leading-5"
placeholder="{&#10; &quot;alibaba/cosyvoice-v2&quot;: {&#10; &quot;zh-CN&quot;: &quot;longxiaochun_v2&quot;&#10; }&#10;}"
/>
</div>
</section>
</template>

View file

@ -1,51 +0,0 @@
<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', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<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', 'dark:text-neutral-400']">
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>

View file

@ -1,80 +0,0 @@
<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', 'dark:border-neutral-800']">
{{ 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', 'dark:border-neutral-800', 'dark:bg-neutral-950']">
<div :class="['text-neutral-500', 'dark:text-neutral-400']">
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', 'dark:border-neutral-800', 'dark:bg-neutral-950']">
<div :class="['text-neutral-500', 'dark:text-neutral-400']">
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', 'dark:text-neutral-400']">
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', 'dark:text-neutral-400']">
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>

View file

@ -1,177 +0,0 @@
<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 {
ALIYUN_NLS_REGION_OPTIONS,
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 'bedrock':
return 'Bedrock'
case 'openai-compatible':
return 'OpenAI Compatible'
case 'azure':
return 'Azure Speech'
case 'dashscope-cosyvoice':
return 'DashScope CosyVoice'
case 'stepfun':
return 'StepFun TTS'
case 'aliyun-nls-asr':
return 'Aliyun NLS ASR'
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 baseUrlPlaceholder = computed(() => {
switch (slice.value.kind) {
case 'bedrock':
return 'https://bedrock-mantle.us-east-1.api.aws/v1'
case 'openai-compatible':
return 'https://api.example.com/v1'
default:
return 'https://openrouter.ai/api/v1'
}
})
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', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<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', 'dark:text-neutral-400']">
{{ 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' || slice.kind === 'bedrock' || slice.kind === 'openai-compatible'" :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="baseUrlPlaceholder" 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-if="slice.kind === 'aliyun-nls-asr'" :class="['grid', 'gap-4', 'md:grid-cols-2']">
<FieldInput v-model="slice.modelName" input-class="font-mono text-xs" label="Model alias" placeholder="auto" required />
<FieldInput v-model="slice.accessKeyId" autocomplete="username" input-class="font-mono text-xs" label="Access key ID" placeholder="LTAI..." required />
<FieldInput v-model="slice.appKey" input-class="font-mono text-xs" label="App key" placeholder="nls app key" required />
<FieldSelect v-model="slice.region" label="Region" layout="vertical" :options="ALIYUN_NLS_REGION_OPTIONS" select-class="w-full" />
<FieldInput v-model="slice.plaintextKey" autocomplete="new-password" :description="providerKeyDescription" input-class="font-mono text-xs" label="Access key secret" :placeholder="providerKeyPlaceholder" required type="password" />
<FieldInput v-model="slice.keyEntryId" input-class="font-mono text-xs" label="Key entry ID" placeholder="aliyun-nls-asr-prod-1" />
</div>
<div v-else-if="slice.kind === 'unspeech'" :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', 'dark:border-neutral-800', 'dark:bg-neutral-950']">
<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', 'dark:text-neutral-400']">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>

View file

@ -1,44 +0,0 @@
<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>

View file

@ -1,52 +0,0 @@
import NProgress from 'nprogress'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { Toaster } from 'vue-sonner'
import App from './App.vue'
import CapabilityAliasesPage from './pages/CapabilityAliasesPage.vue'
import FluxPage from './pages/FluxPage.vue'
import LlmRouterPage from './pages/LlmRouterPage.vue'
import OverviewPage from './pages/OverviewPage.vue'
import TtsCatalogPage from './pages/TtsCatalogPage.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'
import '@unocss/reset/tailwind.css'
import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
const router = createRouter({
history: createWebHistory('/'),
routes: [
{ path: '/', component: OverviewPage },
{ path: '/users', component: UsersPage },
{ path: '/flux', component: FluxPage },
{ path: '/llm-router', component: LlmRouterPage },
{ path: '/capability-aliases', component: CapabilityAliasesPage },
{ path: '/tts', component: TtsCatalogPage },
{ 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 },
],
})
router.beforeEach((to, from) => {
if (to.path !== from.path)
NProgress.start()
})
router.afterEach(() => {
NProgress.done()
})
createApp(App)
.use(createPinia())
.use(router)
.component('Toaster', Toaster)
.mount('#app')

View file

@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildAdminSignInUrl } from './api'
describe('ui-admin API URL helpers', () => {
it('builds sign-in URLs with the absolute admin return URL', () => {
expect(buildAdminSignInUrl(
'http://127.0.0.1:3000',
'http://127.0.0.1:5178/llm-router?api_server_url=http%3A%2F%2F127.0.0.1%3A3000',
)).toBe(
'http://127.0.0.1:3000/auth/sign-in?redirect=http%3A%2F%2F127.0.0.1%3A5178%2Fllm-router%3Fapi_server_url%3Dhttp%253A%252F%252F127.0.0.1%253A3000',
)
})
it('builds local dev sign-in URLs on the local API origin', () => {
expect(buildAdminSignInUrl(
'http://localhost:3000',
'http://localhost:5178/',
)).toBe(
'http://localhost:3000/auth/sign-in?redirect=http%3A%2F%2Flocalhost%3A5178%2F',
)
})
})

View file

@ -1,550 +0,0 @@
import { defaultApiServerUrl, getServerAdminBootstrapContext } from './server-admin-context'
export interface AdminUser {
id: string
name: string
email: string
emailVerified: boolean
image: string | null
createdAt: string
updatedAt: string
flux: number
stripeCustomerId: string | null
}
export interface AdminMe {
role: 'admin'
user: Pick<AdminUser, 'id' | 'name' | 'email' | 'emailVerified' | 'image'>
}
export interface AdminMetrics {
totalUsers: number
verifiedUsers: number
activeSessions: number
currentFlux: number
issuedFlux: number
llmRequests24h: number
llmFlux24h: number
adminSeats: number
grafanaEmbedUrl: string | null
}
export interface FluxTransaction {
id: string
type: string
amount: number
balanceBefore: number
balanceAfter: number
description: string
metadata: unknown
createdAt: string
}
export interface AdminUsersPage {
users: AdminUser[]
hasMore: boolean
nextOffset: number | null
total: number
}
export interface AdminRouterOpenRouterSlice {
kind: 'openrouter'
modelName: string
overrideModel: string
plaintextKey?: string
baseURL?: string
keyEntryId?: string
existingKeyEntryId?: string
headerTemplate?: string
}
export interface AdminRouterBedrockSlice {
kind: 'bedrock'
modelName: string
overrideModel: string
plaintextKey?: string
baseURL?: string
keyEntryId?: string
existingKeyEntryId?: string
headerTemplate?: string
}
export interface AdminRouterOpenAICompatibleSlice {
kind: 'openai-compatible'
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 interface AdminRouterAliyunNlsAsrSlice {
kind: 'aliyun-nls-asr'
modelName: string
accessKeyId: string
appKey: string
region?: 'cn-shanghai' | 'cn-shanghai-internal' | 'cn-beijing' | 'cn-beijing-internal' | 'cn-shenzhen' | 'cn-shenzhen-internal'
plaintextKey?: string
keyEntryId?: string
existingKeyEntryId?: string
}
export type AdminRouterConfigSlice
= | AdminRouterOpenRouterSlice
| AdminRouterBedrockSlice
| AdminRouterOpenAICompatibleSlice
| AdminRouterAzureSlice
| AdminRouterDashscopeSlice
| AdminRouterStepfunSlice
| AdminRouterAliyunNlsAsrSlice
| AdminRouterUnspeechSlice
export interface AdminRouterConfigRequest {
mode?: 'merge' | 'reset'
dryRun?: boolean
slices?: AdminRouterConfigSlice[]
defaults?: {
chatModel?: string
ttsModel?: string
ttsVoices?: Record<string, Record<string, string>>
}
}
export interface AdminRouterConfigResult {
applied: Array<Record<string, unknown>>
invalidatedKeys: string[]
preview: Record<string, unknown>
}
export interface AdminRouterConfigCurrent {
request: AdminRouterConfigRequest
preview: Record<string, unknown>
loadedAt: string
missingKeys: string[]
}
export interface VoicePackParams {
pitch?: number
volume?: number
rate?: number
}
export interface VoicePack {
id: string
name: string
description: string | null
provider: string
model: string
voiceId: string
upstreamVoiceId: string
ttsModelId: string
params: VoicePackParams
costMultiplier: number
enabled: boolean
createdAt: string
updatedAt: string
}
export interface VoicePackPayload {
name: string
description?: string
provider: string
model: string
voiceId: string
upstreamVoiceId: string
ttsModelId: string
params?: VoicePackParams
costMultiplier: number
enabled?: boolean
}
export interface SpeechModel {
id: string
name: string
}
export interface SpeechModelsResult {
models: SpeechModel[]
default: string | null
}
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 type CapabilityAliasSurface = 'llm' | 'asr'
export type CapabilityAliasRoutePool = 'primary' | 'fallback'
export interface CapabilityAliasRoute {
id: string
aliasId: string
routerModelId: string
pool: CapabilityAliasRoutePool
enabled: boolean
weight: number
displayOrder: number
createdAt: string
updatedAt: string
}
export interface CapabilityAlias {
id: string
surface: CapabilityAliasSurface
aliasId: string
displayName: string
enabled: boolean
displayOrder: number
fallbackEnabled: boolean
loadBalancingEnabled: boolean
routes: CapabilityAliasRoute[]
createdAt: string
updatedAt: string
}
export interface ProviderCatalogTtsModel {
id: string
routerModelId: string
provider: string
displayName: string
enabled: boolean
displayOrder: number
lastSyncedAt: string | null
createdAt: string
updatedAt: string
}
export interface ProviderCatalogTtsVoice {
id: string
ttsModelId: string
providerVoiceId: string
displayName: string
enabled: boolean
displayOrder: number
languages: Array<{ code: string, title?: string }>
labels: Record<string, unknown>
previewAudioUrl: string | null
source: 'provider-sync' | 'manual'
lastSyncedAt: string | null
createdAt: string
updatedAt: string
}
export class AdminApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly payload: unknown,
) {
super(message)
this.name = 'AdminApiError'
}
}
export function apiServerUrl(): string {
return getServerAdminBootstrapContext()?.apiServerUrl ?? defaultApiServerUrl()
}
/**
* Builds an API-owned sign-in URL that returns to the exact admin page.
*
* Use when:
* - The standalone admin app needs to bounce through the API auth route.
* - The admin app may be hosted on a different origin than the auth UI.
*
* Expects:
* - `currentUrl` is the browser's absolute admin URL.
*
* Returns:
* - An API `/auth/sign-in` URL carrying an absolute trusted return target.
*/
export function buildAdminSignInUrl(apiServerUrl: string, currentUrl: string): string {
const url = new URL('/auth/sign-in', apiServerUrl)
url.searchParams.set('redirect', currentUrl)
return url.toString()
}
export function signInUrl(): string {
return buildAdminSignInUrl(apiServerUrl(), window.location.href)
}
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'))
headers.set('Content-Type', 'application/json')
const response = await fetch(endpoint.toString(), {
...init,
headers,
credentials: 'include',
})
let payload: unknown = null
try {
payload = await response.json()
}
catch {
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) {
const message = extractErrorMessage(payload) ?? `Admin API request failed (${response.status})`
throw new AdminApiError(message, response.status, payload)
}
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
const maybe = payload as { message?: unknown, error?: unknown }
if (typeof maybe.message === 'string')
return maybe.message
if (typeof maybe.error === 'string')
return maybe.error
return null
}
export const adminApi = {
me: () => adminFetch<AdminMe>('/me'),
metrics: () => adminFetch<AdminMetrics>('/metrics'),
users: (params: { query?: string, limit?: number, offset?: number, sortDirection?: string, sortKey?: string, status?: string }) => {
const query = new URLSearchParams()
if (params.query)
query.set('query', params.query)
if (params.limit != null)
query.set('limit', String(params.limit))
if (params.offset != null)
query.set('offset', String(params.offset))
if (params.sortDirection)
query.set('sortDirection', params.sortDirection)
if (params.sortKey)
query.set('sortKey', params.sortKey)
if (params.status)
query.set('status', params.status)
const suffix = query.toString() ? `?${query.toString()}` : ''
return adminFetch<AdminUsersPage>(`/users${suffix}`)
},
user: (id: string) => adminFetch<{ user: AdminUser, recentFluxTransactions: FluxTransaction[] }>(`/users/${encodeURIComponent(id)}`),
grantUserFlux: (id: string, body: { amount: number, description: string, idempotencyKey?: string }) =>
adminFetch<{ balanceBefore: number, balanceAfter: number, fluxTransactionId: string, idempotent: boolean }>(`/users/${encodeURIComponent(id)}/flux/grant`, {
method: 'POST',
body: JSON.stringify(body),
}),
setUserFlux: (id: string, body: { balance: number, description: string }) =>
adminFetch<{ balanceBefore: number, balanceAfter: number, fluxTransactionId: string | null, changed: boolean }>(`/users/${encodeURIComponent(id)}/flux`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
fluxGrantPreview: (body: { amount: number, description: string, emails: string[], idempotencyKey?: string }) =>
adminFetch<unknown>('/flux-grants?dryRun=true', {
method: 'POST',
body: JSON.stringify(body),
}),
fluxGrant: (body: { amount: number, description: string, emails: string[], idempotencyKey?: string }) =>
adminFetch<unknown>('/flux-grants', {
method: 'POST',
body: JSON.stringify(body),
}),
applyRouterConfig: (body: AdminRouterConfigRequest, dryRun: boolean) =>
adminFetch<AdminRouterConfigResult>('/config/router', {
method: 'POST',
body: JSON.stringify({ ...body, dryRun }),
}),
routerConfig: () => adminFetch<AdminRouterConfigCurrent>('/config/router'),
speechModels: async (): Promise<SpeechModelsResult> => {
const data = await publicFetch<{ default?: unknown, models?: SpeechModel[] }>('/audio/models')
return {
models: Array.isArray(data.models) ? data.models : [],
default: typeof data.default === 'string' ? data.default : null,
}
},
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', {
method: 'POST',
body: JSON.stringify(body),
}),
updateVoicePack: (id: string, body: Partial<VoicePackPayload>) =>
adminFetch<VoicePack>(`/voice-packs/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
disableVoicePack: (id: string) =>
adminFetch<VoicePack>(`/voice-packs/${encodeURIComponent(id)}/disable`, {
method: 'POST',
}),
capabilityAliases: (surface?: CapabilityAliasSurface) => {
const suffix = surface ? `?surface=${encodeURIComponent(surface)}` : ''
return adminFetch<CapabilityAlias[]>(`/capability-aliases${suffix}`)
},
syncCapabilityAliases: (surface: CapabilityAliasSurface) =>
adminFetch<{ aliases: CapabilityAlias[] }>('/capability-aliases/sync', {
method: 'POST',
body: JSON.stringify({ surface }),
}),
updateCapabilityAlias: (id: string, body: Partial<Pick<CapabilityAlias, 'displayName' | 'enabled' | 'displayOrder' | 'fallbackEnabled' | 'loadBalancingEnabled'>>) =>
adminFetch<CapabilityAlias>(`/capability-aliases/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
updateCapabilityAliasRoute: (id: string, body: Partial<Pick<CapabilityAliasRoute, 'enabled' | 'pool' | 'weight' | 'displayOrder'>>) =>
adminFetch<CapabilityAliasRoute>(`/capability-aliases/routes/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
providerCatalogTtsModels: () => adminFetch<ProviderCatalogTtsModel[]>('/provider-catalog/tts/models'),
syncProviderCatalogTtsModels: () =>
adminFetch<{ models: ProviderCatalogTtsModel[] }>('/provider-catalog/tts/models/sync', {
method: 'POST',
}),
updateProviderCatalogTtsModel: (id: string, body: Partial<Pick<ProviderCatalogTtsModel, 'displayName' | 'enabled' | 'displayOrder'>>) =>
adminFetch<ProviderCatalogTtsModel>(`/provider-catalog/tts/models/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
providerCatalogTtsVoices: (model: string) => {
const query = new URLSearchParams({ model })
return adminFetch<ProviderCatalogTtsVoice[]>(`/provider-catalog/tts/voices?${query.toString()}`)
},
syncProviderCatalogTtsVoices: (routerModelId: string) =>
adminFetch<{ voices: ProviderCatalogTtsVoice[], syncedCount: number }>('/provider-catalog/tts/voices/sync', {
method: 'POST',
body: JSON.stringify({ routerModelId }),
}),
updateProviderCatalogTtsVoice: (id: string, body: Partial<Pick<ProviderCatalogTtsVoice, 'displayName' | 'enabled' | 'displayOrder' | 'languages' | 'labels' | 'previewAudioUrl'>>) =>
adminFetch<ProviderCatalogTtsVoice>(`/provider-catalog/tts/voices/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
generateProviderCatalogTtsVoicePreview: (id: string, body: { text?: string, responseFormat?: string } = {}) =>
adminFetch<{ voice: ProviderCatalogTtsVoice, contentType: string, byteLength: number }>(`/provider-catalog/tts/voices/${encodeURIComponent(id)}/preview`, {
method: 'POST',
body: JSON.stringify(body),
}),
}

View file

@ -1,254 +0,0 @@
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 Bedrock and OpenAI-compatible LLM slices', () => {
const bedrock = createRouterSliceDraft('bedrock', 'bedrock-test')
bedrock.plaintextKey = 'bedrock-token'
const compatible = createRouterSliceDraft('openai-compatible', 'compatible-test')
compatible.plaintextKey = 'sk-compatible'
compatible.baseURL = 'https://llm.example.com/v1'
expect(buildRouterConfigRequest({
mode: 'merge',
slices: [bedrock, compatible],
defaults: { chatModel: '', ttsModel: '', ttsVoicesJson: '' },
}).request?.slices).toEqual([
{
kind: 'bedrock',
modelName: 'chat-bedrock',
overrideModel: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
plaintextKey: 'bedrock-token',
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
keyEntryId: 'bedrock-prod-1',
},
{
kind: 'openai-compatible',
modelName: 'chat-compatible',
overrideModel: 'gpt-4o-mini',
plaintextKey: 'sk-compatible',
baseURL: 'https://llm.example.com/v1',
keyEntryId: 'openai-compatible-prod-1',
},
])
})
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".')
})
})

View file

@ -1,780 +0,0 @@
import type {
AdminRouterAliyunNlsAsrSlice,
AdminRouterAzureSlice,
AdminRouterBedrockSlice,
AdminRouterConfigRequest,
AdminRouterConfigSlice,
AdminRouterDashscopeSlice,
AdminRouterOpenAICompatibleSlice,
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 type AliyunNlsRegion = NonNullable<AdminRouterAliyunNlsAsrSlice['region']>
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 BedrockSliceDraft extends SliceDraftBase {
kind: 'bedrock'
modelName: string
overrideModel: string
plaintextKey: string
baseURL: string
keyEntryId: string
existingKeyEntryId: string
headerTemplate: string
}
export interface OpenAICompatibleSliceDraft extends SliceDraftBase {
kind: 'openai-compatible'
modelName: string
overrideModel: string
plaintextKey: string
baseURL: string
keyEntryId: string
existingKeyEntryId: string
headerTemplate: string
}
type LlmSliceDraft = OpenRouterSliceDraft | BedrockSliceDraft | OpenAICompatibleSliceDraft
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 interface AliyunNlsAsrSliceDraft extends SliceDraftBase {
kind: 'aliyun-nls-asr'
modelName: string
accessKeyId: string
appKey: string
region: AliyunNlsRegion
plaintextKey: string
keyEntryId: string
existingKeyEntryId: string
}
export type RouterSliceDraft
= | OpenRouterSliceDraft
| BedrockSliceDraft
| OpenAICompatibleSliceDraft
| AzureSliceDraft
| DashscopeSliceDraft
| StepfunSliceDraft
| AliyunNlsAsrSliceDraft
| 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: 'Bedrock', value: 'bedrock', description: 'Amazon Bedrock OpenAI-compatible chat alias' },
{ label: 'OpenAI Compatible', value: 'openai-compatible', description: 'Custom OpenAI-compatible chat 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: 'Aliyun NLS ASR', value: 'aliyun-nls-asr', description: 'Alibaba realtime ASR 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' },
]
export const ALIYUN_NLS_REGION_OPTIONS: Array<{ label: string, value: AliyunNlsRegion }> = [
{ label: 'Shanghai', value: 'cn-shanghai' },
{ label: 'Shanghai Internal', value: 'cn-shanghai-internal' },
{ label: 'Beijing', value: 'cn-beijing' },
{ label: 'Beijing Internal', value: 'cn-beijing-internal' },
{ label: 'Shenzhen', value: 'cn-shenzhen' },
{ label: 'Shenzhen Internal', value: 'cn-shenzhen-internal' },
]
/**
* 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: 'bedrock', id?: string): BedrockSliceDraft
export function createRouterSliceDraft(kind: 'openai-compatible', id?: string): OpenAICompatibleSliceDraft
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: 'aliyun-nls-asr', id?: string): AliyunNlsAsrSliceDraft
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 'bedrock':
return {
id: sliceId,
kind,
modelName: 'chat-bedrock',
overrideModel: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
plaintextKey: '',
baseURL: 'https://bedrock-mantle.us-east-1.api.aws/v1',
keyEntryId: 'bedrock-prod-1',
existingKeyEntryId: '',
headerTemplate: '',
}
case 'openai-compatible':
return {
id: sliceId,
kind,
modelName: 'chat-compatible',
overrideModel: 'gpt-4o-mini',
plaintextKey: '',
baseURL: 'https://api.example.com/v1',
keyEntryId: 'openai-compatible-prod-1',
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 'aliyun-nls-asr':
return {
id: sliceId,
kind,
modelName: 'auto',
accessKeyId: '',
appKey: '',
region: 'cn-shanghai',
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':
case 'bedrock':
case 'openai-compatible':
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 'aliyun-nls-asr':
return [
required(slice.modelName, `${label}: model alias is required.`),
noPipe(slice.modelName, `${label}: model alias must not contain "|".`),
required(slice.accessKeyId, `${label}: access key id is required.`),
required(slice.appKey, `${label}: app key is required.`),
requiredKey(slice.plaintextKey, slice.existingKeyEntryId, `${label}: access key secret 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':
case 'bedrock':
case 'openai-compatible': {
const request: AdminRouterOpenRouterSlice | AdminRouterBedrockSlice | AdminRouterOpenAICompatibleSlice = {
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 'aliyun-nls-asr': {
const request: AdminRouterAliyunNlsAsrSlice = {
kind: slice.kind,
modelName: trim(slice.modelName),
accessKeyId: trim(slice.accessKeyId),
appKey: trim(slice.appKey),
region: slice.region,
}
assignOptional(request, 'plaintextKey', slice.plaintextKey)
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':
case 'bedrock':
case 'openai-compatible': {
const draft = createRouterSliceDraft(value.kind, `imported-${value.kind}-${ordinal}`) as LlmSliceDraft
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 'aliyun-nls-asr': {
const draft = createRouterSliceDraft('aliyun-nls-asr', `imported-aliyun-nls-asr-${ordinal}`) as AliyunNlsAsrSliceDraft
draft.modelName = stringValue(value.modelName)
draft.accessKeyId = stringValue(value.accessKeyId)
draft.appKey = stringValue(value.appKey)
draft.region = isAliyunNlsRegion(value.region) ? value.region : draft.region
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'
}
function isAliyunNlsRegion(value: unknown): value is AliyunNlsRegion {
return value === 'cn-shanghai'
|| value === 'cn-shanghai-internal'
|| value === 'cn-beijing'
|| value === 'cn-beijing-internal'
|| value === 'cn-shenzhen'
|| value === 'cn-shenzhen-internal'
}

View file

@ -1,78 +0,0 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { ADMIN_API_ENVIRONMENTS, apiEnvironmentValueFor, buildApiServerSwitchUrl, defaultStandaloneApiServerUrl, getServerAdminBootstrapContext, resolveStandaloneServerAdminContext } from './server-admin-context'
describe('ui-admin bootstrap context', () => {
it('uses the trusted API server origin carried by standalone server redirects', () => {
expect(resolveStandaloneServerAdminContext(
'https://admin.airi.build/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fadmin',
)).toEqual({
apiServerUrl: 'https://airi-server-dev.up.railway.app',
currentUrl: 'https://admin.airi.build/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fadmin',
})
})
it('ignores untrusted API server origins from crafted standalone admin URLs', () => {
expect(resolveStandaloneServerAdminContext(
'https://admin.airi.build/users?api_server_url=https%3A%2F%2Fevil.example',
)).toBeNull()
})
it('allows localhost API origins for local development', () => {
expect(resolveStandaloneServerAdminContext(
'http://localhost:5173/admin/users?api_server_url=http%3A%2F%2F127.0.0.1%3A3000',
)?.apiServerUrl).toBe('http://127.0.0.1:3000')
})
it('normalizes known production API hosts to HTTPS when the query param is typed with HTTP', () => {
expect(resolveStandaloneServerAdminContext(
'http://localhost:5178/llm-router?api_server_url=http%3A%2F%2Fapi.airi.build',
)?.apiServerUrl).toBe('https://api.airi.build')
})
it('defaults local standalone dev UI origins to the local API port', () => {
expect(defaultStandaloneApiServerUrl('http://localhost:5178')).toBe('http://localhost:3000')
expect(defaultStandaloneApiServerUrl('http://127.0.0.1:5178')).toBe('http://127.0.0.1:3000')
})
it('keeps non-local standalone origins unchanged without redirect context', () => {
expect(defaultStandaloneApiServerUrl('https://admin-preview.example')).toBe('https://admin-preview.example')
})
it('defaults known standalone admin deployments to their API origins', () => {
expect(defaultStandaloneApiServerUrl('https://admin.airi.build')).toBe('https://api.airi.build')
expect(defaultStandaloneApiServerUrl('https://server-dev.airi-server-admin.pages.dev')).toBe('https://airi-server-dev.up.railway.app')
})
it('exposes production, testing, and local API environment choices', () => {
expect(ADMIN_API_ENVIRONMENTS.map(environment => environment.value)).toEqual([
'https://api.airi.build',
'https://airi-server-dev.up.railway.app',
'http://localhost:3000',
])
})
it('maps custom localhost API origins to the local environment option', () => {
expect(apiEnvironmentValueFor('http://127.0.0.1:8787')).toBe('http://localhost:3000')
})
it('builds a switched admin URL without dropping the current page state', () => {
expect(buildApiServerSwitchUrl(
'https://admin.airi.build/voice-packs/new?draft=1#advanced',
'https://airi-server-dev.up.railway.app/api/admin',
)).toBe('https://admin.airi.build/voice-packs/new?draft=1&api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app#advanced')
})
it('falls back to the standalone query context when the static placeholder script is still present', () => {
document.body.innerHTML = '<script id="airi-server-admin-context" type="application/json">__AIRI_SERVER_ADMIN_CONTEXT__</script>'
window.history.replaceState(
null,
'',
'/admin/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app',
)
expect(getServerAdminBootstrapContext()?.apiServerUrl).toBe('https://airi-server-dev.up.railway.app')
})
})

View file

@ -1,183 +0,0 @@
export interface ServerAdminBootstrapContext {
apiServerUrl: string
currentUrl: string
}
export interface AdminApiEnvironment {
label: string
value: string
description: string
}
const SCRIPT_ID = 'airi-server-admin-context'
const API_SERVER_URL_QUERY_PARAM = 'api_server_url'
export const ADMIN_API_ENVIRONMENTS = [
{
label: 'Production',
value: 'https://api.airi.build',
description: 'api.airi.build',
},
{
label: 'Testing',
value: 'https://airi-server-dev.up.railway.app',
description: 'airi-server-dev.up.railway.app',
},
{
label: 'Local',
value: 'http://localhost:3000',
description: 'localhost:3000',
},
] as const satisfies readonly AdminApiEnvironment[]
const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [
'https://api.airi.build',
'https://airi-server-dev.up.railway.app',
]
const TRUSTED_HTTPS_API_SERVER_HOSTS = new Map(
TRUSTED_STANDALONE_API_SERVER_ORIGINS.map((origin) => {
const url = new URL(origin)
return [url.hostname, origin]
}),
)
const DEFAULT_API_SERVER_ORIGINS_BY_ADMIN_UI_ORIGIN = new Map([
['https://admin.airi.build', 'https://api.airi.build'],
['https://server-dev.airi-server-admin.pages.dev', 'https://airi-server-dev.up.railway.app'],
])
const TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS = [
/^http:\/\/localhost(:\d+)?$/,
/^http:\/\/127\.0\.0\.1(:\d+)?$/,
/^https:\/\/localhost(:\d+)?$/,
/^https:\/\/127\.0\.0\.1(:\d+)?$/,
]
let cachedContext: ServerAdminBootstrapContext | null | undefined
export function getServerAdminBootstrapContext(): ServerAdminBootstrapContext | null {
if (cachedContext !== undefined)
return cachedContext
const element = document.getElementById(SCRIPT_ID)
if (!element) {
cachedContext = resolveStandaloneServerAdminContext(window.location.href)
return cachedContext
}
try {
const parsed = JSON.parse(element.textContent ?? '') as Partial<ServerAdminBootstrapContext>
cachedContext = {
apiServerUrl: parsed.apiServerUrl ?? defaultApiServerUrl(),
currentUrl: parsed.currentUrl ?? window.location.href,
}
return cachedContext
}
catch {
cachedContext = resolveStandaloneServerAdminContext(window.location.href)
return cachedContext
}
}
export function defaultApiServerUrl(): string {
return import.meta.env.VITE_SERVER_URL || defaultStandaloneApiServerUrl(window.location.origin)
}
/**
* Resolves the API origin for a standalone admin UI without redirect context.
*
* Before:
* - "http://localhost:5178"
*
* After:
* - "http://localhost:3000"
*/
export function defaultStandaloneApiServerUrl(currentOrigin: string): string {
const origin = new URL(currentOrigin).origin
const knownApiServerOrigin = DEFAULT_API_SERVER_ORIGINS_BY_ADMIN_UI_ORIGIN.get(origin)
if (knownApiServerOrigin)
return knownApiServerOrigin
if (TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS.some(pattern => pattern.test(origin))) {
const url = new URL(origin)
url.port = '3000'
return url.origin
}
return origin
}
/**
* Resolves API-server context carried by server redirects into static admin UI.
*
* Use when:
* - The standalone admin UI serves more than one AIRI environment from the same
* Pages deployment.
*
* Expects:
* - The server-owned `/admin/*` redirect sets `api_server_url`.
* - Only known AIRI API origins and localhost development origins are accepted.
*
* Returns:
* - A bootstrap context using the trusted API origin, or null when no trusted
* override is present.
*/
export function resolveStandaloneServerAdminContext(currentUrl: string): ServerAdminBootstrapContext | null {
const url = new URL(currentUrl)
const apiServerUrl = normalizeTrustedApiServerUrl(
url.searchParams.get(API_SERVER_URL_QUERY_PARAM),
)
if (!apiServerUrl)
return null
return {
apiServerUrl,
currentUrl,
}
}
export function apiEnvironmentValueFor(apiServerUrl: string): string {
const origin = new URL(apiServerUrl).origin
const known = ADMIN_API_ENVIRONMENTS.find(environment => environment.value === origin)
if (known)
return known.value
if (TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS.some(pattern => pattern.test(origin)))
return ADMIN_API_ENVIRONMENTS.find(environment => environment.label === 'Local')?.value ?? origin
return origin
}
export function buildApiServerSwitchUrl(currentUrl: string, apiServerUrl: string): string {
const url = new URL(currentUrl)
const origin = new URL(apiServerUrl).origin
url.searchParams.set(API_SERVER_URL_QUERY_PARAM, origin)
return url.toString()
}
function normalizeTrustedApiServerUrl(value: string | null): string | null {
if (!value)
return null
try {
const url = new URL(value)
const normalizedHttpsOrigin = TRUSTED_HTTPS_API_SERVER_HOSTS.get(url.hostname)
if (normalizedHttpsOrigin)
return normalizedHttpsOrigin
const origin = url.origin
if (TRUSTED_STANDALONE_API_SERVER_ORIGINS.includes(origin))
return origin
if (TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS.some(pattern => pattern.test(origin)))
return origin
return null
}
catch {
return null
}
}

View file

@ -1,219 +0,0 @@
<script setup lang="ts">
import type { CapabilityAlias, CapabilityAliasRoute, CapabilityAliasSurface } from '../modules/api'
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
import { Button } from '@proj-airi/ui'
import { computed, onMounted, ref, shallowRef } from 'vue'
import { toast } from 'vue-sonner'
import { adminApi } from '../modules/api'
const aliases = shallowRef<CapabilityAlias[]>([])
const surface = ref<CapabilityAliasSurface>('llm')
const loading = shallowRef(false)
const syncing = shallowRef(false)
const enabledCount = computed(() => aliases.value.filter(alias => alias.enabled).length)
const routeCount = computed(() => aliases.value.reduce((total, alias) => total + alias.routes.length, 0))
onMounted(() => {
void loadAliases()
})
async function loadAliases() {
loading.value = true
try {
aliases.value = await adminApi.capabilityAliases(surface.value)
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to load capability aliases'))
}
finally {
loading.value = false
}
}
async function syncAliases() {
syncing.value = true
try {
await adminApi.syncCapabilityAliases(surface.value)
toast.success('Capability aliases synced')
await loadAliases()
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to sync capability aliases'))
}
finally {
syncing.value = false
}
}
async function updateAlias(alias: CapabilityAlias, patch: Partial<Pick<CapabilityAlias, 'displayName' | 'enabled' | 'displayOrder' | 'fallbackEnabled' | 'loadBalancingEnabled'>>) {
try {
const updated = await adminApi.updateCapabilityAlias(alias.id, patch)
aliases.value = aliases.value.map(item => item.id === updated.id ? { ...item, ...updated, routes: item.routes } : item)
toast.success('Alias updated')
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to update alias'))
}
}
async function updateRoute(alias: CapabilityAlias, route: CapabilityAliasRoute, patch: Partial<Pick<CapabilityAliasRoute, 'enabled' | 'pool' | 'weight' | 'displayOrder'>>) {
try {
const updated = await adminApi.updateCapabilityAliasRoute(route.id, patch)
aliases.value = aliases.value.map(item => item.id === alias.id
? { ...item, routes: item.routes.map(existing => existing.id === updated.id ? updated : existing) }
: item)
toast.success('Route updated')
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to update route'))
}
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
}
</script>
<template>
<div :class="['space-y-4']">
<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']">
Capability Aliases
</h2>
<p :class="['mt-1', 'text-sm', 'text-neutral-500']">
Product choices for LLM and ASR requests.
</p>
</div>
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
<div :class="['inline-flex', 'h-8', 'overflow-hidden', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'text-sm']">
<button
:class="['px-3', surface === 'llm' ? 'bg-neutral-900 text-white' : 'text-neutral-600']"
type="button"
@click="surface = 'llm'; loadAliases()"
>
LLM
</button>
<button
:class="['px-3', surface === 'asr' ? 'bg-neutral-900 text-white' : 'text-neutral-600']"
type="button"
@click="surface = 'asr'; loadAliases()"
>
ASR
</button>
</div>
<span :class="['badge', 'badge-green']">
<span :class="['i-lucide-check-circle-2']" />
{{ enabledCount }} enabled
</span>
<span :class="['badge', 'badge-amber']">
<span :class="['i-lucide-route']" />
{{ routeCount }} routes
</span>
<Button :disabled="syncing" :icon="syncing ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-refresh-cw'" label="Sync" size="sm" variant="secondary" @click="syncAliases" />
</div>
</div>
<div v-if="loading && aliases.length === 0" :class="['empty-state']">
<span :class="['i-lucide-loader-2', 'animate-spin', 'text-2xl']" />
Loading capability aliases
</div>
<div v-else-if="aliases.length > 0" :class="['divide-y', 'divide-neutral-200']">
<article v-for="alias in aliases" :key="alias.id" :class="['px-5', 'py-4']">
<div :class="['grid', 'gap-3', 'lg:grid-cols-[minmax(0,1fr)_auto]']">
<div :class="['min-w-0']">
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
<span :class="['font-mono', 'text-xs', 'text-neutral-500']">{{ alias.aliasId }}</span>
<input
:class="['h-8', 'w-56', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']"
:value="alias.displayName"
@change="event => updateAlias(alias, { displayName: (event.target as HTMLInputElement).value })"
>
<span :class="['badge', alias.enabled ? 'badge-green' : 'badge-amber']">
<span :class="[alias.enabled ? 'i-lucide-check-circle-2' : 'i-lucide-pause-circle']" />
{{ alias.enabled ? 'Enabled' : 'Disabled' }}
</span>
</div>
<div :class="['mt-2', 'text-xs', 'text-neutral-500']">
Updated {{ formatDate(alias.updatedAt) }}
</div>
</div>
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-3']">
<label :class="['flex', 'items-center', 'gap-2', 'text-sm']">
<input :checked="alias.enabled" type="checkbox" @change="event => updateAlias(alias, { enabled: (event.target as HTMLInputElement).checked })">
Visible
</label>
<label :class="['flex', 'items-center', 'gap-2', 'text-sm']">
<input :checked="alias.fallbackEnabled" type="checkbox" @change="event => updateAlias(alias, { fallbackEnabled: (event.target as HTMLInputElement).checked })">
Fallback
</label>
<label :class="['flex', 'items-center', 'gap-2', 'text-sm']">
<input :checked="alias.loadBalancingEnabled" type="checkbox" @change="event => updateAlias(alias, { loadBalancingEnabled: (event.target as HTMLInputElement).checked })">
Balance
</label>
<input
:class="['h-8', 'w-20', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']"
min="0"
type="number"
:value="alias.displayOrder"
@change="event => updateAlias(alias, { displayOrder: Number((event.target as HTMLInputElement).value) })"
>
</div>
</div>
<table :class="['table', 'mt-4']">
<thead>
<tr>
<th>Router model</th>
<th>Pool</th>
<th>Weight</th>
<th>Order</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="route in alias.routes" :key="route.id">
<td :class="['font-mono', 'text-xs']">
{{ route.routerModelId }}
</td>
<td>
<select :class="['h-8', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" :value="route.pool" @change="event => updateRoute(alias, route, { pool: (event.target as HTMLSelectElement).value as 'primary' | 'fallback' })">
<option value="primary">
primary
</option>
<option value="fallback">
fallback
</option>
</select>
</td>
<td>
<input :class="['h-8', 'w-20', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" min="1" type="number" :value="route.weight" @change="event => updateRoute(alias, route, { weight: Number((event.target as HTMLInputElement).value) })">
</td>
<td>
<input :class="['h-8', 'w-20', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" min="0" type="number" :value="route.displayOrder" @change="event => updateRoute(alias, route, { displayOrder: Number((event.target as HTMLInputElement).value) })">
</td>
<td>
<label :class="['flex', 'items-center', 'gap-2', 'text-sm']">
<input :checked="route.enabled" type="checkbox" @change="event => updateRoute(alias, route, { enabled: (event.target as HTMLInputElement).checked })">
{{ route.enabled ? 'Enabled' : 'Disabled' }}
</label>
</td>
</tr>
</tbody>
</table>
</article>
</div>
<div v-else :class="['empty-state']">
<span :class="['i-lucide-route-off', 'text-2xl']" />
No aliases configured
<Button icon="i-lucide-refresh-cw" label="Sync aliases" size="sm" variant="secondary" @click="syncAliases" />
</div>
</section>
</div>
</template>

View file

@ -1,166 +0,0 @@
<script setup lang="ts">
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
import { computed, reactive, shallowRef } from 'vue'
import { toast } from 'vue-sonner'
import { adminApi } from '../modules/api'
const form = reactive({
amount: 100,
description: 'Admin promo Flux grant',
idempotencyKey: '',
emails: '',
})
const loading = shallowRef(false)
const preview = shallowRef<unknown>(null)
const result = shallowRef<unknown>(null)
const emails = computed(() =>
form.emails
.split(/[\n,;]/)
.map(item => item.trim())
.filter(Boolean),
)
const totalFlux = computed(() => emails.value.length * Number(form.amount || 0))
async function dryRun() {
await submit(true)
}
async function grant() {
await submit(false)
}
async function submit(isDryRun: boolean) {
loading.value = true
result.value = null
try {
const body = {
amount: Number(form.amount),
description: form.description,
emails: emails.value,
...(form.idempotencyKey.trim() ? { idempotencyKey: form.idempotencyKey.trim() } : {}),
}
if (isDryRun) {
preview.value = await adminApi.fluxGrantPreview(body)
toast.success('Preview generated')
return
}
result.value = await adminApi.fluxGrant(body)
toast.success('Flux grant issued')
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Flux grant failed'))
}
finally {
loading.value = false
}
}
function formatJson(value: unknown): string {
return JSON.stringify(value, null, 2)
}
function formatNumber(value: number): string {
return new Intl.NumberFormat().format(value)
}
</script>
<template>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<section class="panel p-5">
<div class="mb-5 flex items-start justify-between gap-4">
<div>
<h2 class="text-sm font-semibold">
Bulk Flux Grant
</h2>
<p class="mt-1 text-sm text-neutral-500">
Issues promo Flux to existing users by email. Preview first for recipient validation.
</p>
</div>
<span class="badge badge-green">
<span class="i-lucide-shield-check" />
Admin guarded
</span>
</div>
<form class="space-y-4" @submit.prevent="dryRun">
<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">Amount per user</span>
<input v-model.number="form.amount" class="field" min="1" type="number">
</label>
<label class="block">
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Idempotency key</span>
<input v-model="form.idempotencyKey" class="field" placeholder="Optional safe retry key" type="text">
</label>
</div>
<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>
<label class="block">
<span class="mb-1 block text-xs text-neutral-500 font-semibold uppercase">Emails</span>
<textarea v-model="form.emails" class="textarea min-h-[260px]" placeholder="alice@example.com&#10;bob@example.com" />
</label>
<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="text-sm text-neutral-500">
{{ emails.length }} recipients · {{ formatNumber(totalFlux) }} total Flux
</div>
<div class="flex gap-2">
<button class="btn btn-secondary" :disabled="loading || emails.length === 0" type="submit">
<span class="i-lucide-eye" />
Preview
</button>
<button class="btn btn-primary" :disabled="loading || emails.length === 0" type="button" @click="grant">
<span class="i-lucide-send" />
Grant Flux
</button>
</div>
</div>
</form>
</section>
<aside class="space-y-4">
<section class="metric-card">
<div class="text-sm text-neutral-500">
Recipients
</div>
<div class="mt-3 text-3xl font-semibold">
{{ formatNumber(emails.length) }}
</div>
<div class="mt-5 text-sm text-neutral-600">
Synchronous grants are capped by the server.
</div>
</section>
<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="preview" class="max-h-[280px] overflow-auto p-4 text-xs leading-5">{{ formatJson(preview) }}</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 Result
</div>
<pre v-if="result" class="max-h-[280px] overflow-auto p-4 text-xs leading-5">{{ formatJson(result) }}</pre>
<div v-else class="empty-state min-h-40">
<span class="i-lucide-history text-2xl" />
No grant result
</div>
</section>
</aside>
</div>
</template>

View file

@ -1,236 +0,0 @@
// @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('Streaming TTS')
expect(host.textContent).toContain('ASR')
expect(host.textContent).not.toContain('LLM and TTS providers')
expect(host.textContent).toContain('OpenRouter')
expect(host.textContent).not.toContain('Router Config Request')
})
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, TTS, Streaming TTS, and ASR 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')
buttonByText('Streaming TTS').click()
await nextTick()
expect(host.textContent).toContain('No Streaming TTS provider slices')
expect(sectionHeadings(host)).not.toContain('1. Azure Speech')
buttonByText('Add').click()
await nextTick()
expect(host.textContent).toContain('UnSpeech')
buttonByText('ASR').click()
await nextTick()
expect(host.textContent).toContain('No ASR provider slices')
expect(sectionHeadings(host)).not.toContain('1. UnSpeech')
buttonByText('Add').click()
await nextTick()
expect(host.textContent).toContain('Aliyun NLS ASR')
})
it('keeps preview disabled until required fields are complete', async () => {
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() ?? '')
}

View file

@ -1,510 +0,0 @@
<script setup lang="ts">
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 { 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'
type BusyState = 'preview' | 'apply' | 'advanced-preview' | 'advanced-apply'
type ProviderTab = 'llm' | 'tts' | 'streamingTts' | 'asr'
const form = reactive(createRouterConfigFormState())
const activeProviderTab = shallowRef<ProviderTab>('llm')
const selectedKindByTab = reactive<Record<ProviderTab, RouterSliceKind>>({
llm: 'openrouter',
tts: 'azure',
streamingTts: 'unspeech',
asr: 'aliyun-nls-asr',
})
const previewResult = shallowRef<AdminRouterConfigResult | null>(null)
const applyResult = shallowRef<AdminRouterConfigResult | null>(null)
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 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(isLlmSlice).length,
ttsSlices: form.slices.filter(isTtsSlice).length,
streamingTtsSlices: form.slices.filter(isStreamingTtsSlice).length,
asrSlices: form.slices.filter(isAsrSlice).length,
defaults: Object.keys(defaults).length,
}
})
const providerTabs = computed(() => [
{ value: 'llm' as const, label: 'LLM', count: pendingSummary.value.llmSlices },
{ value: 'tts' as const, label: 'TTS', count: pendingSummary.value.ttsSlices },
{ value: 'streamingTts' as const, label: 'Streaming TTS', count: pendingSummary.value.streamingTtsSlices },
{ value: 'asr' as const, label: 'ASR', count: pendingSummary.value.asrSlices },
])
const providerKindOptions = computed(() => ROUTER_SLICE_KIND_OPTIONS.filter((option) => {
if (activeProviderTab.value === 'llm')
return isLlmSliceKind(option.value)
if (activeProviderTab.value === 'streamingTts')
return option.value === 'unspeech'
if (activeProviderTab.value === 'asr')
return option.value === 'aliyun-nls-asr'
return isTtsSliceKind(option.value)
}))
const visibleSlices = computed(() => form.slices
.map((slice, index) => ({ slice, index }))
.filter(({ slice }) => {
if (activeProviderTab.value === 'llm')
return isLlmSlice(slice)
if (activeProviderTab.value === 'streamingTts')
return isStreamingTtsSlice(slice)
if (activeProviderTab.value === 'asr')
return isAsrSlice(slice)
return isTtsSlice(slice)
}))
const currentStatusLabel = computed(() => {
if (loadingCurrent.value)
return 'Loading current config'
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 submitForm(true)
}
async function applyConfig() {
await submitForm(false)
}
async function previewAdvancedJson() {
await submitAdvancedJson(true)
}
async function applyAdvancedJson() {
await submitAdvancedJson(false)
}
async function loadCurrentConfig() {
loadingCurrent.value = true
currentError.value = null
try {
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')
return
}
applyResult.value = result
previewResult.value = result
toast.success('Router config applied')
}
catch (error) {
toast.error(errorMessageFromUnknown(error, dryRun ? 'Failed to preview router config' : 'Failed to apply router config'))
}
finally {
busy.value = null
}
}
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.')
advancedError.value = null
return parsed as AdminRouterConfigRequest
}
catch (error) {
advancedError.value = errorMessageFromUnknown(error, 'Invalid advanced JSON')
return null
}
}
function isLlmSlice(slice: RouterSliceDraft) {
return isLlmSliceKind(slice.kind)
}
function isTtsSlice(slice: RouterSliceDraft) {
return isTtsSliceKind(slice.kind)
}
function isStreamingTtsSlice(slice: RouterSliceDraft) {
return slice.kind === 'unspeech'
}
function isAsrSlice(slice: RouterSliceDraft) {
return slice.kind === 'aliyun-nls-asr'
}
function isTtsSliceKind(kind: RouterSliceKind) {
return kind === 'azure'
|| kind === 'dashscope-cosyvoice'
|| kind === 'stepfun'
}
function isLlmSliceKind(kind: RouterSliceKind) {
return kind === 'openrouter'
|| kind === 'bedrock'
|| kind === 'openai-compatible'
}
function activeProviderLabel() {
switch (activeProviderTab.value) {
case 'llm':
return 'LLM'
case 'tts':
return 'TTS'
case 'streamingTts':
return 'Streaming TTS'
case 'asr':
return 'ASR'
}
}
function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value is AdminRouterConfigCurrent {
return value != null
&& typeof value === 'object'
&& 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', 'dark:border-neutral-800']">
<div>
<h2 :class="['text-sm', 'font-semibold']">
Router Config Form
</h2>
<p :class="['mt-1', 'text-sm', 'text-neutral-500', 'dark:text-neutral-400']">
Builds LLM_ROUTER_CONFIG, UNSPEECH_UPSTREAM, and default model aliases without hand-written JSON.
</p>
</div>
<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>
<form :class="['space-y-5', 'p-5']" @submit.prevent="previewConfig">
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<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 dark:text-red-400' : 'text-neutral-500 dark:text-neutral-400']">
{{ 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>
<RouterModeControl v-model="form.mode" />
<RouterDefaultsEditor v-model="form.defaults" />
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<div :class="['border-b', 'border-neutral-200', 'p-4', 'dark:border-neutral-800']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Provider configuration
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
LLM, TTS, Streaming TTS, and ASR 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-4', 'rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-1', 'sm:w-auto', 'dark:border-neutral-800', 'dark:bg-neutral-950']"
role="tablist"
>
<button
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 dark:bg-neutral-900 dark:text-primary-300 dark:ring-primary-800'
: 'text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-200',
]"
role="tab"
type="button"
@click="selectProviderTab(tab.value)"
>
{{ tab.label }}
<span :class="['ml-1', 'text-xs', activeProviderTab === tab.value ? 'text-neutral-500 dark:text-neutral-400' : 'text-neutral-400 dark:text-neutral-500']">
{{ 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', 'dark:border-neutral-800', 'dark:bg-neutral-950/60', 'dark:text-neutral-300']">
<span :class="['font-medium', 'text-neutral-900', 'dark:text-neutral-100']">{{ activeProviderLabel() }}</span>
<span v-if="activeProviderTab === 'llm'">
config writes chat model aliases under LLM_ROUTER_CONFIG.
</span>
<span v-else-if="activeProviderTab === 'tts'">
config writes speech model aliases under LLM_ROUTER_CONFIG.
</span>
<span v-else-if="activeProviderTab === 'streamingTts'">
config writes UNSPEECH_UPSTREAM settings for realtime speech synthesis.
</span>
<span v-else>
config writes realtime transcription model aliases under LLM_ROUTER_CONFIG.asr.
</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', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
No {{ activeProviderLabel() }} provider slices
</div>
</div>
<div :class="['border-t', 'border-neutral-200', 'px-4', 'py-3', 'text-xs', 'text-neutral-500', 'dark:border-neutral-800', 'dark:text-neutral-400']">
UnSpeech is limited to one Streaming TTS slice per request. ASR writes to LLM_ROUTER_CONFIG.asr.
</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', 'dark:border-neutral-800']">
<div :class="['grid', 'gap-2', 'text-xs', 'text-neutral-600', 'sm:grid-cols-6', 'dark:text-neutral-400']">
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.slices }}</span>
slices
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.llmSlices }}</span>
LLM
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.ttsSlices }}</span>
TTS
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.streamingTtsSlices }}</span>
Streaming TTS
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.asrSlices }}</span>
ASR
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.defaults }}</span>
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>
</form>
</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>

View file

@ -1,146 +0,0 @@
<script setup lang="ts">
import type { AdminMetrics } from '../modules/api'
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
import { computed, onMounted, shallowRef } from 'vue'
import { toast } from 'vue-sonner'
import { adminApi } from '../modules/api'
const loading = shallowRef(true)
const metrics = shallowRef<AdminMetrics | null>(null)
const cards = computed(() => {
const data = metrics.value
return [
{
label: 'Total Users',
value: formatNumber(data?.totalUsers),
trend: `${formatNumber(data?.verifiedUsers)} verified`,
icon: 'i-lucide-users',
},
{
label: 'Active Sessions',
value: formatNumber(data?.activeSessions),
trend: 'Better Auth session rows',
icon: 'i-lucide-activity',
},
{
label: 'Current Flux',
value: formatNumber(data?.currentFlux),
trend: `${formatNumber(data?.issuedFlux)} issued lifetime`,
icon: 'i-lucide-coins',
},
{
label: 'LLM 24h',
value: formatNumber(data?.llmRequests24h),
trend: `${formatNumber(data?.llmFlux24h)} Flux consumed`,
icon: 'i-lucide-bot',
},
]
})
onMounted(async () => {
try {
metrics.value = await adminApi.metrics()
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to load metrics'))
}
finally {
loading.value = false
}
})
function formatNumber(value: number | null | undefined): string {
if (value == null)
return loading.value ? '...' : '0'
return new Intl.NumberFormat().format(value)
}
</script>
<template>
<div class="space-y-5">
<section class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<article v-for="card in cards" :key="card.label" class="metric-card">
<div class="flex items-start justify-between">
<div class="text-sm text-neutral-500">
{{ card.label }}
</div>
<div class="h-8 w-8 flex items-center justify-center border border-neutral-200 rounded-md bg-white text-neutral-600">
<span :class="card.icon" />
</div>
</div>
<div class="mt-3 text-3xl font-semibold tracking-tight">
{{ card.value }}
</div>
<div class="mt-5 flex items-center gap-2 text-sm text-neutral-600">
<span class="i-lucide-trending-up text-emerald-600" />
{{ card.trend }}
</div>
</article>
</section>
<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">
Operational Overview
</h2>
<p class="mt-1 text-sm text-neutral-500">
Reserved surface for Grafana panels and live gateway indicators.
</p>
</div>
<button class="btn btn-secondary" type="button">
<span class="i-lucide-panel-top" />
Grafana Embed
</button>
</div>
<div class="grid gap-4 p-5 lg:grid-cols-[minmax(0,1fr)_280px]">
<div v-if="metrics?.grafanaEmbedUrl" class="min-h-[340px] overflow-hidden border border-neutral-200 rounded-lg">
<iframe class="h-full min-h-[340px] w-full" :src="metrics.grafanaEmbedUrl" />
</div>
<div v-else class="relative min-h-[340px] overflow-hidden border border-neutral-200 rounded-lg from-white to-emerald-50/60 bg-gradient-to-b px-5 py-5">
<div class="absolute inset-x-5 bottom-10 top-8 flex items-end gap-2">
<div v-for="height in [32, 46, 38, 72, 54, 88, 50, 64, 80, 44, 70, 92, 60, 74, 56, 84, 68, 96]" :key="height" class="flex-1 rounded-t bg-emerald-500/35" :style="{ height: `${height}%` }" />
</div>
<div class="relative z-1 flex items-center justify-between">
<div>
<div class="text-sm font-semibold">
Grafana placeholder
</div>
<div class="mt-1 text-sm text-neutral-500">
Add an embed URL later without changing the page shell.
</div>
</div>
<span class="badge badge-green">
<span class="i-lucide-circle" />
Ready
</span>
</div>
</div>
<div class="space-y-3">
<div class="border border-neutral-200 rounded-lg bg-white p-4">
<div class="text-xs text-neutral-500 font-semibold uppercase">
Admin Seats
</div>
<div class="mt-2 text-2xl font-semibold">
{{ formatNumber(metrics?.adminSeats) }}
</div>
</div>
<div class="border border-neutral-200 rounded-lg bg-white p-4">
<div class="text-xs text-neutral-500 font-semibold uppercase">
Router Config
</div>
<RouterLink class="mt-2 inline-flex items-center gap-2 text-sm text-emerald-700 font-semibold" to="/llm-router">
Open Router Config
<span class="i-lucide-arrow-right" />
</RouterLink>
</div>
</div>
</div>
</section>
</div>
</template>

View file

@ -1,309 +0,0 @@
<script setup lang="ts">
import type { ProviderCatalogTtsModel, ProviderCatalogTtsVoice } from '../modules/api'
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
import { Button } from '@proj-airi/ui'
import { computed, onMounted, ref, shallowRef, watch } from 'vue'
import { toast } from 'vue-sonner'
import { adminApi } from '../modules/api'
const models = shallowRef<ProviderCatalogTtsModel[]>([])
const voices = shallowRef<ProviderCatalogTtsVoice[]>([])
const selectedModel = ref('')
const loadingModels = shallowRef(false)
const loadingVoices = shallowRef(false)
const syncingModels = shallowRef(false)
const syncingVoices = shallowRef(false)
const generatingPreviewVoiceId = shallowRef<string | null>(null)
const enabledModels = computed(() => models.value.filter(model => model.enabled).length)
const enabledVoices = computed(() => voices.value.filter(voice => voice.enabled).length)
const disabledVoices = computed(() => voices.value.length - enabledVoices.value)
const selectedModelRow = computed(() => models.value.find(model => model.routerModelId === selectedModel.value) ?? null)
watch(selectedModel, () => {
if (selectedModel.value)
void loadVoices()
})
onMounted(() => {
void loadModels()
})
async function loadModels() {
loadingModels.value = true
try {
models.value = await adminApi.providerCatalogTtsModels()
if (!selectedModel.value && models.value[0])
selectedModel.value = models.value[0].routerModelId
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to load TTS models'))
}
finally {
loadingModels.value = false
}
}
async function loadVoices() {
if (!selectedModel.value)
return
loadingVoices.value = true
try {
voices.value = await adminApi.providerCatalogTtsVoices(selectedModel.value)
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to load TTS voices'))
}
finally {
loadingVoices.value = false
}
}
async function syncModels() {
syncingModels.value = true
try {
await adminApi.syncProviderCatalogTtsModels()
toast.success('TTS models synced')
await loadModels()
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to sync TTS models'))
}
finally {
syncingModels.value = false
}
}
async function syncVoices() {
if (!selectedModel.value)
return
syncingVoices.value = true
try {
const result = await adminApi.syncProviderCatalogTtsVoices(selectedModel.value)
toast.success(`${result.syncedCount} voices synced`)
await loadVoices()
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to sync TTS voices'))
}
finally {
syncingVoices.value = false
}
}
async function updateModel(model: ProviderCatalogTtsModel, patch: Partial<Pick<ProviderCatalogTtsModel, 'displayName' | 'enabled' | 'displayOrder'>>) {
try {
const updated = await adminApi.updateProviderCatalogTtsModel(model.id, patch)
models.value = models.value.map(item => item.id === updated.id ? updated : item)
toast.success('TTS model updated')
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to update TTS model'))
}
}
async function updateVoice(voice: ProviderCatalogTtsVoice, patch: Partial<Pick<ProviderCatalogTtsVoice, 'displayName' | 'enabled' | 'displayOrder' | 'previewAudioUrl'>>) {
try {
const updated = await adminApi.updateProviderCatalogTtsVoice(voice.id, patch)
voices.value = voices.value.map(item => item.id === updated.id ? updated : item)
toast.success('TTS voice updated')
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to update TTS voice'))
}
}
async function generatePreview(voice: ProviderCatalogTtsVoice) {
generatingPreviewVoiceId.value = voice.id
try {
const result = await adminApi.generateProviderCatalogTtsVoicePreview(voice.id)
voices.value = voices.value.map(item => item.id === result.voice.id ? result.voice : item)
toast.success('Preview generated')
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to generate preview'))
}
finally {
generatingPreviewVoiceId.value = null
}
}
function formatDate(value: string | null): string {
if (!value)
return 'Never'
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
}
function languageSummary(voice: ProviderCatalogTtsVoice): string {
if (!voice.languages.length)
return 'Not set'
return voice.languages.map(language => language.title ?? language.code).join(', ')
}
</script>
<template>
<div :class="['grid', 'gap-4', 'xl:grid-cols-[360px_minmax(0,1fr)]']">
<section :class="['panel', 'overflow-hidden']">
<div :class="['flex', 'items-center', 'justify-between', 'gap-3', 'border-b', 'border-neutral-200', 'px-5', 'py-4']">
<div>
<h2 :class="['text-sm', 'font-semibold']">
TTS Models
</h2>
<p :class="['mt-1', 'text-sm', 'text-neutral-500']">
Official speech models visible to clients.
</p>
</div>
<Button :disabled="syncingModels" :icon="syncingModels ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-refresh-cw'" label="Sync" size="sm" variant="secondary" @click="syncModels" />
</div>
<div :class="['flex', 'gap-2', 'border-b', 'border-neutral-200', 'px-5', 'py-3']">
<span :class="['badge', 'badge-green']">
<span :class="['i-lucide-check-circle-2']" />
{{ enabledModels }} enabled
</span>
<span :class="['badge', 'badge-amber']">
<span :class="['i-lucide-volume-2']" />
{{ models.length }} total
</span>
</div>
<div v-if="loadingModels && models.length === 0" :class="['empty-state']">
<span :class="['i-lucide-loader-2', 'animate-spin', 'text-2xl']" />
Loading TTS models
</div>
<div v-else-if="models.length > 0" :class="['divide-y', 'divide-neutral-200']">
<button
v-for="model in models"
:key="model.id"
:class="['w-full', 'px-5', 'py-4', 'text-left', 'transition-colors', selectedModel === model.routerModelId ? 'bg-neutral-100' : 'hover:bg-neutral-50']"
type="button"
@click="selectedModel = model.routerModelId"
>
<div :class="['flex', 'items-center', 'justify-between', 'gap-3']">
<div :class="['min-w-0']">
<div :class="['truncate', 'text-sm', 'font-medium']">
{{ model.displayName }}
</div>
<div :class="['mt-1', 'truncate', 'font-mono', 'text-xs', 'text-neutral-500']">
{{ model.routerModelId }}
</div>
</div>
<span :class="['badge', model.enabled ? 'badge-green' : 'badge-amber']">
{{ model.enabled ? 'Enabled' : 'Disabled' }}
</span>
</div>
<div :class="['mt-3', 'grid', 'grid-cols-[1fr_auto]', 'gap-2']">
<input :class="['h-8', 'min-w-0', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" :value="model.displayName" @change="event => updateModel(model, { displayName: (event.target as HTMLInputElement).value })">
<input :class="['h-8', 'w-20', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" min="0" type="number" :value="model.displayOrder" @change="event => updateModel(model, { displayOrder: Number((event.target as HTMLInputElement).value) })">
</div>
<label :class="['mt-3', 'flex', 'items-center', 'gap-2', 'text-sm']">
<input :checked="model.enabled" type="checkbox" @change="event => updateModel(model, { enabled: (event.target as HTMLInputElement).checked })">
Visible to clients
</label>
</button>
</div>
<div v-else :class="['empty-state']">
<span :class="['i-lucide-volume-x', 'text-2xl']" />
No TTS models synced
<Button icon="i-lucide-refresh-cw" label="Sync models" size="sm" variant="secondary" @click="syncModels" />
</div>
</section>
<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']">
TTS Voices
</h2>
<p :class="['mt-1', 'text-sm', 'text-neutral-500']">
{{ selectedModelRow ? selectedModelRow.routerModelId : 'Select a model to manage voices.' }}
</p>
</div>
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
<select v-model="selectedModel" :class="['h-8', 'max-w-72', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']">
<option v-for="model in models" :key="model.id" :value="model.routerModelId">
{{ model.displayName }}
</option>
</select>
<span :class="['badge', 'badge-green']">
{{ enabledVoices }} enabled
</span>
<span :class="['badge', disabledVoices > 0 ? 'badge-amber' : 'badge-green']">
{{ disabledVoices }} disabled
</span>
<Button :disabled="!selectedModel || syncingVoices" :icon="syncingVoices ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-download'" label="Pull voices" size="sm" variant="secondary" @click="syncVoices" />
</div>
</div>
<div v-if="loadingVoices && voices.length === 0" :class="['empty-state']">
<span :class="['i-lucide-loader-2', 'animate-spin', 'text-2xl']" />
Loading TTS voices
</div>
<table v-else-if="voices.length > 0" :class="['table']">
<thead>
<tr>
<th>Voice</th>
<th>Languages</th>
<th>Preview</th>
<th>Order</th>
<th>Status</th>
<th>Synced</th>
</tr>
</thead>
<tbody>
<tr v-for="voice in voices" :key="voice.id">
<td>
<input :class="['h-8', 'w-full', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" :value="voice.displayName" @change="event => updateVoice(voice, { displayName: (event.target as HTMLInputElement).value })">
<div :class="['mt-1', 'font-mono', 'text-xs', 'text-neutral-500']">
{{ voice.providerVoiceId }}
</div>
</td>
<td :class="['max-w-56', 'text-xs', 'text-neutral-600']">
{{ languageSummary(voice) }}
</td>
<td>
<div :class="['flex', 'min-w-64', 'items-center', 'gap-2']">
<input :class="['h-8', 'min-w-0', 'flex-1', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" :value="voice.previewAudioUrl ?? ''" placeholder="https://..." @change="event => updateVoice(voice, { previewAudioUrl: (event.target as HTMLInputElement).value || null })">
<audio v-if="voice.previewAudioUrl" :src="voice.previewAudioUrl" controls :class="['h-8', 'w-36']" />
<Button
v-else
:disabled="generatingPreviewVoiceId === voice.id"
:icon="generatingPreviewVoiceId === voice.id ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-wand-sparkles'"
label="Generate"
size="sm"
variant="secondary"
@click="generatePreview(voice)"
/>
</div>
</td>
<td>
<input :class="['h-8', 'w-20', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" min="0" type="number" :value="voice.displayOrder" @change="event => updateVoice(voice, { displayOrder: Number((event.target as HTMLInputElement).value) })">
</td>
<td>
<label :class="['flex', 'items-center', 'gap-2', 'text-sm']">
<input :checked="voice.enabled" type="checkbox" @change="event => updateVoice(voice, { enabled: (event.target as HTMLInputElement).checked })">
{{ voice.enabled ? 'Enabled' : 'Disabled' }}
</label>
</td>
<td :class="['text-xs', 'text-neutral-500']">
{{ formatDate(voice.lastSyncedAt) }}
</td>
</tr>
</tbody>
</table>
<div v-else :class="['empty-state']">
<span :class="['i-lucide-mic-off', 'text-2xl']" />
No voices synced for this model
<Button :disabled="!selectedModel" icon="i-lucide-download" label="Pull provider voices" size="sm" variant="secondary" @click="syncVoices" />
</div>
</section>
</div>
</template>

View file

@ -1,332 +0,0 @@
<script setup lang="ts">
import type { AdminUser, FluxTransaction } from '../modules/api'
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
import { computed, onMounted, reactive, shallowRef } from 'vue'
import { toast } from 'vue-sonner'
import AdminListPanel from '../components/admin-list/AdminListPanel.vue'
import { adminApi } from '../modules/api'
const users = shallowRef<AdminUser[]>([])
const selected = shallowRef<AdminUser | null>(null)
const transactions = shallowRef<FluxTransaction[]>([])
const loading = shallowRef(false)
const loadingPage = shallowRef<number | null>(null)
const detailLoading = shallowRef(false)
const currentPage = shallowRef(1)
const sortDirection = shallowRef<'asc' | 'desc'>('desc')
const sortKey = shallowRef('createdAt')
const statusFilter = shallowRef('all')
const totalUsers = shallowRef(0)
const pageSize = 20
const search = shallowRef('')
const columns = [
{ key: 'user', label: 'User', sortable: true },
{ key: 'status', label: 'Status', sortable: true },
{ key: 'flux', label: 'Flux', sortable: true },
{ key: 'createdAt', label: 'Created', sortable: true },
]
const statusFilters = computed(() => [
{
key: 'status',
label: 'Status',
value: statusFilter.value,
options: [
{ label: 'All', value: 'all' },
{ label: 'Verified', value: 'verified' },
{ label: 'Unverified', value: 'unverified' },
],
},
])
const fluxForm = reactive({
grantAmount: 100,
grantDescription: 'Admin Flux grant',
targetBalance: 0,
balanceDescription: 'Admin balance adjustment',
})
const selectedStatus = computed(() => selected.value?.emailVerified ? 'Verified' : 'Unverified')
const totalPages = computed(() => Math.max(1, Math.ceil(totalUsers.value / pageSize)))
const pageStart = computed(() => totalUsers.value === 0 ? 0 : (currentPage.value - 1) * pageSize + 1)
const pageEnd = computed(() => Math.min(currentPage.value * pageSize, totalUsers.value))
const isInitialLoading = computed(() => loading.value && users.value.length === 0)
onMounted(() => {
void loadUsers(1)
})
async function loadUsers(page: number) {
const nextPage = clampPage(page)
loading.value = true
loadingPage.value = nextPage
try {
const result = await adminApi.users({
query: search.value.trim() || undefined,
limit: pageSize,
offset: (nextPage - 1) * pageSize,
sortDirection: sortDirection.value,
sortKey: sortKey.value === 'user' ? 'name' : sortKey.value,
status: statusFilter.value,
})
users.value = result.users
totalUsers.value = result.total
currentPage.value = nextPage
if (result.users[0] && !result.users.some(user => user.id === selected.value?.id))
await selectUser(result.users[0])
if (result.users.length === 0) {
selected.value = null
transactions.value = []
}
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to load users'))
}
finally {
loading.value = false
loadingPage.value = null
}
}
function clampPage(page: number): number {
if (!Number.isFinite(page))
return currentPage.value
return Math.min(Math.max(1, Math.trunc(page)), totalPages.value)
}
function setFilter(key: string, value: string) {
if (key !== 'status')
return
statusFilter.value = value
void loadUsers(1)
}
function setPage(page: number) {
void loadUsers(page)
}
function setSearch(value: string) {
search.value = value
void loadUsers(1)
}
function setSort(key: string) {
if (sortKey.value === key) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
}
else {
sortKey.value = key
sortDirection.value = key === 'createdAt' ? 'desc' : 'asc'
}
void loadUsers(1)
}
async function selectUser(user: AdminUser) {
detailLoading.value = true
try {
const result = await adminApi.user(user.id)
selected.value = result.user
transactions.value = result.recentFluxTransactions
fluxForm.targetBalance = result.user.flux
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to load user'))
}
finally {
detailLoading.value = false
}
}
async function grantFlux() {
if (!selected.value)
return
try {
const result = await adminApi.grantUserFlux(selected.value.id, {
amount: Number(fluxForm.grantAmount),
description: fluxForm.grantDescription,
})
toast.success(`Balance updated to ${formatNumber(result.balanceAfter)}`)
await selectUser(selected.value)
await loadUsers(currentPage.value)
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to grant Flux'))
}
}
async function setBalance() {
if (!selected.value)
return
try {
const result = await adminApi.setUserFlux(selected.value.id, {
balance: Number(fluxForm.targetBalance),
description: fluxForm.balanceDescription,
})
toast.success(result.changed ? `Balance set to ${formatNumber(result.balanceAfter)}` : 'Balance unchanged')
await selectUser(selected.value)
await loadUsers(currentPage.value)
}
catch (error) {
toast.error(errorMessageFromUnknown(error, 'Failed to set balance'))
}
}
function formatNumber(value: number | null | undefined): string {
return new Intl.NumberFormat().format(value ?? 0)
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(value))
}
</script>
<template>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
<AdminListPanel
:columns="columns"
:current-page="currentPage"
description="Search accounts, inspect balances, and apply Flux changes."
empty-label="No users matched this query"
:filters="statusFilters"
:initial-loading="isInitialLoading"
:loading="loading"
:loading-page="loadingPage"
:page-end="pageEnd"
:page-start="pageStart"
:search="search"
search-placeholder="Search email, name, or user ID"
:sort-direction="sortDirection"
:sort-key="sortKey"
title="Users"
:total-items="totalUsers"
:total-pages="totalPages"
@filter="setFilter"
@page="setPage"
@search="setSearch"
@sort="setSort"
>
<template #rows>
<tr
v-for="user in users"
:key="user.id"
class="cursor-pointer transition-colors hover:bg-neutral-50"
:class="{ 'bg-emerald-50/50': selected?.id === user.id }"
tabindex="0"
@click="selectUser(user)"
@keydown.enter.prevent="selectUser(user)"
@keydown.space.prevent="selectUser(user)"
>
<td>
<div class="font-medium">
{{ user.name }}
</div>
<div class="mt-1 text-xs text-neutral-500">
{{ user.email }}
</div>
</td>
<td>
<span class="badge" :class="user.emailVerified ? 'badge-green' : 'badge-amber'">
<span :class="user.emailVerified ? 'i-lucide-check-circle-2' : 'i-lucide-clock-3'" />
{{ user.emailVerified ? 'Verified' : 'Unverified' }}
</span>
</td>
<td class="font-semibold">
{{ formatNumber(user.flux) }}
</td>
<td>{{ formatDate(user.createdAt) }}</td>
</tr>
</template>
</AdminListPanel>
<aside class="space-y-4">
<section class="panel p-5">
<div v-if="!selected" class="empty-state">
<span class="i-lucide-mouse-pointer-click text-2xl" />
Select a user
</div>
<div v-else class="space-y-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h2 class="truncate text-base font-semibold">
{{ selected.name }}
</h2>
<p class="mt-1 truncate text-sm text-neutral-500">
{{ selected.email }}
</p>
</div>
<span class="badge" :class="selected.emailVerified ? 'badge-green' : 'badge-amber'">
{{ selectedStatus }}
</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="border border-neutral-200 rounded-lg p-3">
<div class="text-xs text-neutral-500">
Flux Balance
</div>
<div class="mt-1 text-xl font-semibold">
{{ formatNumber(selected.flux) }}
</div>
</div>
<div class="border border-neutral-200 rounded-lg p-3">
<div class="text-xs text-neutral-500">
Joined
</div>
<div class="mt-1 text-sm font-semibold">
{{ formatDate(selected.createdAt) }}
</div>
</div>
</div>
<div class="border border-neutral-200 rounded-lg p-3">
<label class="text-xs text-neutral-500 font-semibold uppercase">Grant Flux</label>
<div class="grid grid-cols-[110px_minmax(0,1fr)] mt-3 gap-2">
<input v-model.number="fluxForm.grantAmount" class="field" min="1" type="number">
<input v-model="fluxForm.grantDescription" class="field" type="text">
</div>
<button class="btn btn-primary mt-3 w-full" :disabled="detailLoading" type="button" @click="grantFlux">
<span class="i-lucide-plus-circle" />
Grant Flux
</button>
</div>
<div class="border border-neutral-200 rounded-lg p-3">
<label class="text-xs text-neutral-500 font-semibold uppercase">Set Balance</label>
<div class="grid grid-cols-[110px_minmax(0,1fr)] mt-3 gap-2">
<input v-model.number="fluxForm.targetBalance" class="field" min="0" type="number">
<input v-model="fluxForm.balanceDescription" class="field" type="text">
</div>
<button class="btn btn-secondary mt-3 w-full" :disabled="detailLoading" type="button" @click="setBalance">
<span class="i-lucide-pencil" />
Set Balance
</button>
</div>
</div>
</section>
<section v-if="selected" class="panel overflow-hidden">
<div class="border-b border-neutral-200 px-4 py-3 text-sm font-semibold">
Recent Flux Ledger
</div>
<div v-if="transactions.length === 0" class="empty-state min-h-32">
No Flux transactions
</div>
<div v-else class="max-h-[360px] overflow-auto">
<div v-for="tx in transactions" :key="tx.id" class="border-b border-neutral-100 px-4 py-3 last:border-b-0">
<div class="flex items-center justify-between gap-3">
<span class="text-sm font-medium">{{ tx.description }}</span>
<span class="text-sm font-semibold" :class="tx.type === 'debit' ? 'text-red-600' : 'text-emerald-700'">
{{ tx.type === 'debit' ? '-' : '+' }}{{ formatNumber(tx.amount) }}
</span>
</div>
<div class="mt-1 text-xs text-neutral-500">
{{ tx.type }} · {{ formatDate(tx.createdAt) }} · {{ formatNumber(tx.balanceBefore) }} -> {{ formatNumber(tx.balanceAfter) }}
</div>
</div>
</div>
</section>
</aside>
</div>
</template>

View file

@ -1,111 +0,0 @@
// @vitest-environment jsdom
import type { App } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import VoicePackFormPage from './VoicePackFormPage.vue'
const mocks = vi.hoisted(() => ({
createVoicePack: vi.fn(),
disableVoicePack: vi.fn(),
replace: vi.fn(),
route: {
name: 'voice-pack-new',
params: {},
},
speechModels: vi.fn(),
speechVoices: vi.fn(),
testSpeech: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
updateVoicePack: vi.fn(),
voicePacks: vi.fn(),
}))
vi.mock('../modules/api', () => ({
adminApi: {
createVoicePack: mocks.createVoicePack,
disableVoicePack: mocks.disableVoicePack,
speechModels: mocks.speechModels,
speechVoices: mocks.speechVoices,
testSpeech: mocks.testSpeech,
updateVoicePack: mocks.updateVoicePack,
voicePacks: mocks.voicePacks,
},
}))
vi.mock('vue-router', () => ({
useRoute: () => mocks.route,
useRouter: () => ({
replace: mocks.replace,
}),
}))
vi.mock('vue-sonner', () => ({
toast: {
error: mocks.toastError,
success: mocks.toastSuccess,
},
}))
describe('voice pack form page', () => {
let app: App<Element>
let host: HTMLElement
beforeEach(() => {
mocks.route.name = 'voice-pack-new'
mocks.route.params = {}
mocks.voicePacks.mockResolvedValue([])
mocks.speechModels.mockResolvedValue({
models: [
{ id: 'alibaba/cosyvoice-v1', name: 'alibaba/cosyvoice-v1' },
{ id: 'stepfun/stepaudio-2.5-tts', name: 'stepfun/stepaudio-2.5-tts' },
],
default: null,
})
mocks.speechVoices.mockResolvedValue({
voices: [{ id: 'longxiaochun', name: 'Long Xiaochun' }],
recommended: { 'zh-CN': 'longxiaochun' },
})
document.body.innerHTML = '<div id="app"></div>'
host = document.querySelector('#app')!
app = createApp(VoicePackFormPage)
})
afterEach(() => {
app.unmount()
vi.clearAllMocks()
})
it('uses the configured speech catalog model when creating a new Voice Pack', async () => {
app.mount(host)
await flushPromises()
expect(mocks.speechModels).toHaveBeenCalledTimes(1)
expect(mocks.speechVoices).toHaveBeenCalledWith('alibaba/cosyvoice-v1')
expect(mocks.speechVoices).not.toHaveBeenCalledWith('volcengine/seed-tts-2.0')
})
it('prefers the server speech catalog default when it is available', async () => {
mocks.speechModels.mockResolvedValueOnce({
models: [
{ id: 'alibaba/cosyvoice-v1', name: 'alibaba/cosyvoice-v1' },
{ id: 'stepfun/stepaudio-2.5-tts', name: 'stepfun/stepaudio-2.5-tts' },
],
default: 'stepfun/stepaudio-2.5-tts',
})
app.mount(host)
await flushPromises()
expect(mocks.speechVoices).toHaveBeenCalledWith('stepfun/stepaudio-2.5-tts')
})
})
async function flushPromises() {
await nextTick()
await Promise.resolve()
await nextTick()
}

View file

@ -1,621 +0,0 @@
<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 catalogDefaultModel = shallowRef<string | null>(null)
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(''))
const modelChangeVoiceLoadingEnabled = shallowRef(false)
const form = reactive({
name: '',
description: '',
provider: '',
model: '',
voiceId: '',
upstreamVoiceId: '',
ttsModelId: '',
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 ttsModelPlaceholder = computed(() => models.value[0]?.id ?? 'provider/model')
const voicePlaceholder = computed(() => voices.value[0]?.id ?? 'voice-id')
const providerPlaceholder = computed(() => providerOptions.value[0]?.value ?? 'provider')
const baseModelPlaceholder = computed(() => baseModelOptions.value[0]?.value ?? 'model')
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 alias is required'
if (!form.upstreamVoiceId.trim())
return 'Upstream 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
resetForm()
await loadVoices(form.ttsModelId, { autoPick: !form.upstreamVoiceId.trim() })
modelChangeVoiceLoadingEnabled.value = true
})
onBeforeUnmount(() => {
revokeTestAudio()
})
watch(() => route.params.id, async () => {
if (!isEditing.value) {
resetForm()
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
if (!modelChangeVoiceLoadingEnabled.value)
return
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 {
const catalog = await adminApi.speechModels()
models.value = catalog.models
catalogDefaultModel.value = catalog.default
}
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.upstreamVoiceId.trim()) {
form.upstreamVoiceId = firstRecommendedVoiceId(result.recommended) ?? result.voices[0]?.id ?? ''
if (!form.voiceId.trim())
form.voiceId = form.upstreamVoiceId
}
}
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.upstreamVoiceId = pack.upstreamVoiceId
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() {
const modelId = initialCatalogModelId()
const modelParts = deriveModelParts(modelId)
form.name = ''
form.description = ''
form.provider = modelParts.provider
form.model = modelParts.model
form.voiceId = ''
form.upstreamVoiceId = ''
form.ttsModelId = modelId
form.paramsJson = DEFAULT_PARAMS
form.costMultiplier = 1
form.status = 'enabled'
testText.value = TEST_TEXT
previousDerived.value = modelParts
revokeTestAudio()
}
function initialCatalogModelId(): string {
const defaultModel = catalogDefaultModel.value
if (defaultModel && models.value.some(model => model.id === defaultModel))
return defaultModel
return models.value[0]?.id ?? ''
}
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}"`)
if (typeof value !== 'number' || !Number.isFinite(value))
throw new Error(`Voice Pack parameter "${key}" must be a finite number`)
if (key === 'rate' && value <= 0)
throw new Error('Voice Pack parameter "rate" must be positive')
}
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(),
upstreamVoiceId: form.upstreamVoiceId.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.upstreamVoiceId.trim(),
speed: 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 voicePack: Record<string, unknown> = {}
if (params.pitch != null)
voicePack.pitch = params.pitch
if (params.volume != null)
voicePack.volume = params.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]
}
</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="ttsModelPlaceholder"
required
/>
<FieldInput
v-model="form.voiceId"
description="Product-facing Voice Pack voice alias shown to clients and analytics."
input-class="font-mono text-xs"
label="Voice alias"
placeholder="narrator-cn"
required
/>
</div>
<div :class="['grid', 'gap-4', 'md:grid-cols-2']">
<DatalistField
v-model="form.upstreamVoiceId"
:description="loadingVoices ? 'Loading voices for the selected model...' : 'Voice catalog from /api/v1/audio/voices.'"
input-class="font-mono text-xs"
label="Upstream voice ID"
list-id="voice-pack-voices"
:options="voiceOptions"
:placeholder="voicePlaceholder"
required
/>
<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="providerPlaceholder"
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="baseModelPlaceholder"
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 numeric keys: rate, pitch, volume. Example: { &quot;rate&quot;: 1.08, &quot;pitch&quot;: 3 }"
label="Params JSON"
placeholder="{&#10; &quot;rate&quot;: 1.08&#10;}"
: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>

View file

@ -1,145 +0,0 @@
<script setup lang="ts">
import type { VoicePack } from '../modules/api'
import { errorMessageFromUnknown } from '@proj-airi/stage-shared'
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 router = useRouter()
const packs = shallowRef<VoicePack[]>([])
const loading = shallowRef(false)
const enabledCount = computed(() => packs.value.filter(pack => pack.enabled).length)
const disabledCount = computed(() => packs.value.length - enabledCount.value)
onMounted(() => {
void loadPacks()
})
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
}
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(value))
}
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>
<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', '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>
<div v-if="loading && packs.length === 0" :class="['empty-state']">
<span :class="['i-lucide-loader-2', 'animate-spin', 'text-2xl']" />
Loading Voice Packs
</div>
<table v-else-if="packs.length > 0" :class="['table']">
<thead>
<tr>
<th>Name</th>
<th>Routing</th>
<th>Voice Alias</th>
<th>Upstream 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 :class="['text-xs', 'font-mono']">
{{ pack.upstreamVoiceId }}
</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>
<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>

View file

@ -1,485 +0,0 @@
@import '@proj-airi/ui/main.css';
:root {
--admin-accent: #059669;
color-scheme: light;
}
html,
body,
#app {
min-height: 100%;
margin: 0;
}
body {
background: #f7f8fa;
color: #18181b;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
html.dark {
color-scheme: dark;
}
html.dark body {
background: #09090b;
color: #f4f4f5;
}
button,
input,
textarea,
select {
font: inherit;
}
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: var(--admin-accent);
height: 2px;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 50;
}
.admin-shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 100vh;
}
.admin-sidebar {
background: #f4f5f7;
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px 14px;
}
html.dark .admin-sidebar {
background: #111113;
border-color: #27272a;
}
.admin-main {
min-width: 0;
}
html.dark .admin-main {
background: #09090b;
}
.admin-topbar {
align-items: center;
background: rgba(255, 255, 255, 0.86);
border-bottom: 1px solid #e5e7eb;
display: flex;
height: 56px;
justify-content: space-between;
padding: 0 28px;
position: sticky;
top: 0;
z-index: 10;
}
html.dark .admin-topbar {
background: rgba(9, 9, 11, 0.88);
border-color: #27272a;
}
.admin-content {
padding: 22px;
}
.quick-action {
align-items: center;
background: #059669;
border: 0;
border-radius: 7px;
color: white;
display: flex;
font-size: 13px;
font-weight: 600;
gap: 8px;
height: 34px;
justify-content: flex-start;
margin-top: 12px;
padding: 0 10px;
}
.nav-item {
align-items: center;
border-radius: 7px;
color: #27272a;
display: flex;
font-size: 13px;
gap: 9px;
height: 34px;
padding: 0 9px;
text-decoration: none;
}
html.dark .nav-item {
color: #d4d4d8;
}
.nav-item:hover {
background: #e9ecef;
}
html.dark .nav-item:hover {
background: #27272a;
}
.nav-item-active {
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
font-weight: 600;
}
html.dark .nav-item-active {
background: #18181b;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
color: #f4f4f5;
}
.panel {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
html.dark .panel {
background: #18181b;
border-color: #27272a;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.metric-card {
background: linear-gradient(180deg, #ffffff 0%, #f8fbfa 100%);
border: 1px solid #e5e7eb;
border-radius: 8px;
min-height: 132px;
padding: 20px;
}
html.dark .metric-card {
background: linear-gradient(180deg, #18181b 0%, #111113 100%);
border-color: #27272a;
}
.field {
background: #fff;
border: 1px solid #dfe3e8;
border-radius: 7px;
color: #171717;
min-height: 36px;
outline: none;
padding: 0 10px;
width: 100%;
}
html.dark .field {
background: #09090b;
border-color: #27272a;
color: #f4f4f5;
}
.field:focus,
.textarea:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.14);
}
.textarea {
background: #fff;
border: 1px solid #dfe3e8;
border-radius: 7px;
color: #171717;
min-height: 112px;
outline: none;
padding: 10px;
resize: vertical;
width: 100%;
}
html.dark .textarea {
background: #09090b;
border-color: #27272a;
color: #f4f4f5;
}
.btn {
align-items: center;
border: 1px solid #dfe3e8;
border-radius: 7px;
display: inline-flex;
font-size: 13px;
font-weight: 600;
gap: 8px;
height: 36px;
justify-content: center;
padding: 0 12px;
}
.btn-primary {
background: #059669;
border-color: #059669;
color: white;
}
.btn-secondary {
background: white;
color: #27272a;
}
html.dark .btn-secondary {
background: #18181b;
border-color: #3f3f46;
color: #f4f4f5;
}
.btn-danger {
background: #fff;
border-color: #fecaca;
color: #b91c1c;
}
html.dark .btn-danger {
background: #18181b;
border-color: #7f1d1d;
color: #fecaca;
}
.table {
border-collapse: collapse;
width: 100%;
}
.table th,
.table td {
border-bottom: 1px solid #edf0f2;
padding: 12px 14px;
text-align: left;
vertical-align: middle;
}
html.dark .table th,
html.dark .table td {
border-color: #27272a;
}
.table th {
background: #f8fafc;
color: #52525b;
font-size: 12px;
font-weight: 600;
}
html.dark .table th {
background: #111113;
color: #a1a1aa;
}
.table td {
color: #27272a;
font-size: 13px;
}
html.dark .table td {
color: #e4e4e7;
}
.badge {
align-items: center;
border: 1px solid #e5e7eb;
border-radius: 999px;
display: inline-flex;
font-size: 12px;
font-weight: 600;
gap: 6px;
height: 24px;
padding: 0 8px;
}
html.dark .badge {
border-color: #3f3f46;
color: #e4e4e7;
}
.badge-green {
background: #ecfdf5;
border-color: #bbf7d0;
color: #047857;
}
html.dark .badge-green {
background: rgba(6, 78, 59, 0.5);
border-color: rgba(52, 211, 153, 0.28);
color: #6ee7b7;
}
.badge-amber {
background: #fffbeb;
border-color: #fde68a;
color: #b45309;
}
html.dark .badge-amber {
background: rgba(120, 53, 15, 0.45);
border-color: rgba(251, 191, 36, 0.3);
color: #fcd34d;
}
.empty-state {
align-items: center;
color: #71717a;
display: flex;
flex-direction: column;
font-size: 14px;
gap: 10px;
justify-content: center;
min-height: 180px;
}
html.dark .empty-state {
color: #a1a1aa;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
}
body {
background: #09090b;
color: #f4f4f5;
}
.admin-sidebar {
background: #111113;
border-color: #27272a;
}
.admin-main {
background: #09090b;
}
.admin-topbar {
background: rgba(9, 9, 11, 0.88);
border-color: #27272a;
}
.nav-item {
color: #d4d4d8;
}
.nav-item:hover {
background: #27272a;
}
.nav-item-active {
background: #18181b;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
color: #f4f4f5;
}
.panel {
background: #18181b;
border-color: #27272a;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.metric-card {
background: linear-gradient(180deg, #18181b 0%, #111113 100%);
border-color: #27272a;
}
.field,
.textarea {
background: #09090b;
border-color: #27272a;
color: #f4f4f5;
}
.btn-secondary {
background: #18181b;
border-color: #3f3f46;
color: #f4f4f5;
}
.btn-danger {
background: #18181b;
border-color: #7f1d1d;
color: #fecaca;
}
.table th,
.table td {
border-color: #27272a;
}
.table th {
background: #111113;
color: #a1a1aa;
}
.table td {
color: #e4e4e7;
}
.badge {
border-color: #3f3f46;
color: #e4e4e7;
}
.badge-green {
background: rgba(6, 78, 59, 0.5);
border-color: rgba(52, 211, 153, 0.28);
color: #6ee7b7;
}
.badge-amber {
background: rgba(120, 53, 15, 0.45);
border-color: rgba(251, 191, 36, 0.3);
color: #fcd34d;
}
.empty-state {
color: #a1a1aa;
}
}
@media (max-width: 900px) {
.admin-shell {
grid-template-columns: 1fr;
}
.admin-sidebar {
border-bottom: 1px solid #e5e7eb;
border-right: 0;
min-height: auto;
}
html.dark .admin-sidebar {
border-bottom-color: #27272a;
}
.admin-content {
padding: 16px;
}
}
@media (max-width: 900px) and (prefers-color-scheme: dark) {
.admin-sidebar {
border-bottom-color: #27272a;
}
}

View file

@ -1,31 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "preserve",
"lib": [
"DOM",
"ESNext",
"DOM.Iterable",
"DOM.AsyncIterable"
],
"useDefineForClassFields": true,
"paths": {
"@proj-airi/stage-shared/*": [
"../../packages/stage-shared/src/*"
]
},
"resolveJsonModule": true,
"types": [
"vite/client",
"vue-macros/macros-global"
],
"strict": true,
"skipLibCheck": true
},
"vueCompilerOptions": {
"plugins": [
"@vue-macros/volar/define-models",
"@vue-macros/volar/define-slots"
]
}
}

View file

@ -1,20 +0,0 @@
import { mergeConfigs, presetWebFonts } from 'unocss'
import { presetWebFontsFonts, sharedUnoConfig } from '../../uno.config'
export default mergeConfigs([
sharedUnoConfig(),
{
presets: [
presetWebFonts({
fonts: {
...presetWebFontsFonts('fontsource'),
},
timeouts: {
warning: 5000,
failure: 10000,
},
}),
],
},
])

View file

@ -1,37 +0,0 @@
import { join, resolve } from 'node:path'
import Vue from '@vitejs/plugin-vue'
import Unocss from 'unocss/vite'
import VueMacros from 'vue-macros/vite'
import { defineConfig } from 'vite'
export default defineConfig({
resolve: {
alias: {
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
},
},
server: {
fs: {
strict: false,
},
},
build: {
emptyOutDir: true,
outDir: resolve(join(import.meta.dirname, 'dist')),
sourcemap: true,
},
plugins: [
VueMacros({
plugins: {
vue: Vue({
include: [/\.vue$/, /\.md$/],
}),
vueJsx: false,
},
betterDefine: false,
}),
Unocss(),
],
})

View file

@ -1,28 +0,0 @@
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'), ''),
},
}
})

View file

@ -1,7 +0,0 @@
name = "airi-server-admin"
compatibility_date = "2025-04-01"
preview_urls = true
[assets]
directory = "./dist"
not_found_handling = "single-page-application"

View file

@ -11,7 +11,7 @@ Auth UI for the hosted AIRI server. It is a Vue/Vite app deployed separately fro
## Do Not Use When ## Do Not Use When
- Building the main stage app sign-in callback pages that consume OIDC tokens. - Building the main stage app sign-in callback pages that consume OIDC tokens.
- Adding admin-only operational pages. Those belong in `apps/ui-admin`. - Adding admin-only operational pages. Those belong in the standalone `proj-airi` admin repository.
## Commands ## Commands

View file

@ -19,7 +19,6 @@
"dev:web": "pnpm -rF @proj-airi/stage-web run dev", "dev:web": "pnpm -rF @proj-airi/stage-web run dev",
"dev:web:https": "pnpm -rF @proj-airi/stage-web run dev:https", "dev:web:https": "pnpm -rF @proj-airi/stage-web run dev:https",
"dev:server-auth": "pnpm -rF @proj-airi/ui-server-auth run dev", "dev:server-auth": "pnpm -rF @proj-airi/ui-server-auth run dev",
"dev:admin": "pnpm -rF @proj-airi/ui-admin run dev",
"dev:pocket:ios": "pnpm -rF @proj-airi/stage-pocket run dev:ios", "dev:pocket:ios": "pnpm -rF @proj-airi/stage-pocket run dev:ios",
"dev:pocket:android": "pnpm -rF @proj-airi/stage-pocket run dev:android", "dev:pocket:android": "pnpm -rF @proj-airi/stage-pocket run dev:android",
"dev:server": "pnpm -rF @proj-airi/server-runtime run dev", "dev:server": "pnpm -rF @proj-airi/server-runtime run dev",

314
pnpm-lock.yaml generated
View file

@ -2257,7 +2257,7 @@ importers:
version: 3.0.2(electron@41.2.1) version: 3.0.2(electron@41.2.1)
'@electron-toolkit/tsconfig': '@electron-toolkit/tsconfig':
specifier: 'catalog:' specifier: 'catalog:'
version: 2.0.0(@types/node@24.12.2) version: 2.0.0(@types/node@25.6.0)
'@electron-toolkit/utils': '@electron-toolkit/utils':
specifier: 'catalog:' specifier: 'catalog:'
version: 4.0.0(electron@41.2.1) version: 4.0.0(electron@41.2.1)
@ -2296,7 +2296,7 @@ importers:
version: 3.1.0 version: 3.1.0
'@intlify/unplugin-vue-i18n': '@intlify/unplugin-vue-i18n':
specifier: 'catalog:' specifier: 'catalog:'
version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@modelcontextprotocol/sdk': '@modelcontextprotocol/sdk':
specifier: 'catalog:' specifier: 'catalog:'
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
@ -2332,10 +2332,10 @@ importers:
version: link:../../packages/ui-transitions version: link:../../packages/ui-transitions
'@proj-airi/unplugin-fetch': '@proj-airi/unplugin-fetch':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@proj-airi/unplugin-live2d-sdk': '@proj-airi/unplugin-live2d-sdk':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) version: 0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@types/audioworklet': '@types/audioworklet':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.0.97 version: 0.0.97
@ -2362,7 +2362,7 @@ importers:
version: 2.10.3 version: 2.10.3
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: 'catalog:' specifier: 'catalog:'
version: 6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/volar': '@vue-macros/volar':
specifier: 'catalog:' specifier: 'catalog:'
version: 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) version: 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
@ -2395,7 +2395,7 @@ importers:
version: 6.8.3 version: 6.8.3
electron-vite: electron-vite:
specifier: 'catalog:' specifier: 'catalog:'
version: 5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
get-port-please: get-port-please:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.2.0 version: 3.2.0
@ -2416,31 +2416,31 @@ importers:
version: 2.2.6 version: 2.2.6
unocss-preset-scrollbar: unocss-preset-scrollbar:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
unplugin-info: unplugin-info:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unplugin-yaml: unplugin-yaml:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 4.1.0(@nuxt/kit@3.20.2(magicast@0.5.2))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite: vite:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-bundle-visualizer: vite-bundle-visualizer:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@4.60.1) version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@4.60.1)
vite-plugin-mkcert: vite-plugin-mkcert:
specifier: 'catalog:' specifier: 'catalog:'
version: 2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-plugin-vue-devtools: vite-plugin-vue-devtools:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) version: 8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
vite-plugin-vue-layouts: vite-plugin-vue-layouts:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) version: 0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
vue-macros: vue-macros:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
vue-tsc: vue-tsc:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.2.6(typescript@5.9.3) version: 3.2.6(typescript@5.9.3)
@ -2836,64 +2836,6 @@ importers:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.2.6(typescript@5.9.3) version: 3.2.6(typescript@5.9.3)
apps/ui-admin:
dependencies:
'@proj-airi/font-chillroundm':
specifier: workspace:^
version: link:../../packages/font-chillroundm
'@proj-airi/stage-shared':
specifier: workspace:^
version: link:../../packages/stage-shared
'@proj-airi/ui':
specifier: workspace:^
version: link:../../packages/ui
'@vueuse/core':
specifier: ^14.2.1
version: 14.2.1(vue@3.5.32(typescript@5.9.3))
nprogress:
specifier: 'catalog:'
version: 0.2.0
pinia:
specifier: 'catalog:'
version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.32(typescript@5.9.3)
vue-router:
specifier: 'catalog:'
version: 5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
vue-sonner:
specifier: 'catalog:'
version: 2.0.9(vue@3.5.32(typescript@5.9.3))
devDependencies:
'@iconify-json/lucide':
specifier: ^1.2.102
version: 1.2.102
'@types/nprogress':
specifier: ^0.2.3
version: 0.2.3
'@unocss/reset':
specifier: ^66.6.8
version: 66.6.8
'@vitejs/plugin-vue':
specifier: ^6.0.6
version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/volar':
specifier: ^3.1.2
version: 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
unocss:
specifier: 'catalog:'
version: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite:
specifier: 'catalog:'
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue-macros:
specifier: 'catalog:'
version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
vue-tsc:
specifier: 'catalog:'
version: 3.2.6(typescript@5.9.3)
apps/ui-server-auth: apps/ui-server-auth:
dependencies: dependencies:
'@date-fns/utc': '@date-fns/utc':
@ -20735,9 +20677,9 @@ snapshots:
dependencies: dependencies:
electron: 41.2.1 electron: 41.2.1
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)': '@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)':
dependencies: dependencies:
'@types/node': 24.12.2 '@types/node': 25.6.0
'@electron-toolkit/utils@4.0.0(electron@41.2.1)': '@electron-toolkit/utils@4.0.0(electron@41.2.1)':
dependencies: dependencies:
@ -21647,31 +21589,6 @@ snapshots:
- supports-color - supports-color
- typescript - typescript
'@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
'@intlify/bundle-utils': 11.0.7(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))
'@intlify/shared': 11.3.2
'@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.32)(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
'@typescript-eslint/scope-manager': 8.58.1
'@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
pathe: 2.0.3
picocolors: 1.1.1
unplugin: 2.3.11
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
optionalDependencies:
vue-i18n: 11.3.2(vue@3.5.32(typescript@5.9.3))
transitivePeerDependencies:
- '@vue/compiler-dom'
- eslint
- rollup
- supports-color
- typescript
'@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))': '@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
@ -23674,35 +23591,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- magicast - magicast
'@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': '@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies: dependencies:
ofetch: 1.5.1 ofetch: 1.5.1
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
yauzl: 3.3.0
transitivePeerDependencies:
- '@types/node'
- '@vitejs/devtools'
- esbuild
- jiti
- less
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
'@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)': '@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)':
dependencies: dependencies:
ofetch: 1.5.1 ofetch: 1.5.1
@ -25249,12 +25142,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': '@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13 '@rolldown/pluginutils': 1.0.0-rc.13
@ -25532,15 +25419,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- vue - vue
'@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
sirv: 3.0.2
vue: 3.5.32(typescript@5.9.3)
optionalDependencies:
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- typescript
'@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': '@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies: dependencies:
sirv: 3.0.2 sirv: 3.0.2
@ -27618,7 +27496,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
electron-vite@5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): electron-vite@5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0)
@ -27626,7 +27504,7 @@ snapshots:
esbuild: 0.25.12 esbuild: 0.25.12
magic-string: 0.30.21 magic-string: 0.30.21
picocolors: 1.1.1 picocolors: 1.1.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -33617,6 +33495,11 @@ snapshots:
'@unocss/preset-mini': 66.6.8 '@unocss/preset-mini': 66.6.8
unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unocss-preset-scrollbar@4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))):
dependencies:
'@unocss/preset-mini': 66.6.8
unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
'@unocss/cli': 66.6.8 '@unocss/cli': 66.6.8
@ -33683,14 +33566,6 @@ snapshots:
unplugin: 2.3.11 unplugin: 2.3.11
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
esbuild: 0.27.2
rolldown: 1.0.0-rc.16
rollup: 4.60.1
unplugin: 2.3.11
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies: optionalDependencies:
esbuild: 0.27.2 esbuild: 0.27.2
@ -33712,19 +33587,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ci-info: 4.4.0
git-url-parse: 16.1.0
simple-git: 3.36.0
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.27.2
rollup: 4.60.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
ci-info: 4.4.0 ci-info: 4.4.0
@ -33818,17 +33680,6 @@ snapshots:
rollup: 2.80.0 rollup: 2.80.0
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-yaml@4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
unplugin: 3.0.0
yaml: 2.8.3
optionalDependencies:
esbuild: 0.27.2
rolldown: 1.0.0-rc.16
rollup: 4.60.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin@2.3.11: unplugin@2.3.11:
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
@ -34056,22 +33907,12 @@ snapshots:
- rollup - rollup
- supports-color - supports-color
vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
birpc: 2.9.0
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client: 2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
birpc: 2.9.0 birpc: 2.9.0
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client: 2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vite-hot-client: 2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-hot-client@2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client@2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vite-hot-client@2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@ -34117,21 +33958,6 @@ snapshots:
- tsx - tsx
- yaml - yaml
vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ansis: 4.2.0
debug: 4.4.3(supports-color@10.2.2)
error-stack-parser-es: 1.0.5
ohash: 2.0.11
open: 10.2.0
perfect-debounce: 2.1.0
sirv: 3.0.2
unplugin-utils: 0.3.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-dev-rpc: 1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
transitivePeerDependencies:
- supports-color
vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
ansis: 4.2.0 ansis: 4.2.0
@ -34163,13 +33989,6 @@ snapshots:
- typescript - typescript
- ws - ws
vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
supports-color: 10.2.2
undici: 8.1.0
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
@ -34188,20 +34007,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
'@vue/devtools-kit': 8.1.1
'@vue/devtools-shared': 8.1.1
sirv: 3.0.2
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-inspect: 11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)): vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)):
dependencies: dependencies:
'@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3)) '@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
@ -34216,21 +34021,6 @@ snapshots:
- supports-color - supports-color
- vue - vue
vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0)
'@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
'@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.29.0)
'@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0)
'@vue/compiler-dom': 3.5.32
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
@ -34246,16 +34036,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
vue-router: 5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
transitivePeerDependencies:
- supports-color
vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies: dependencies:
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
@ -34523,54 +34303,6 @@ snapshots:
- vue-tsc - vue-tsc
- webpack - webpack
vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))
'@vue-macros/boolean-prop': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/chain-call': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/common': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/config': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-emit': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-models': 3.1.2(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-prop': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-props': 3.1.2(@vue-macros/reactivity-transform@3.1.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-props-refs': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-render': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-slots': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-stylex': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/devtools': 3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vue-macros/export-expose': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/export-props': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/export-render': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/hoist-static': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/jsx-directive': 3.1.2(typescript@5.9.3)
'@vue-macros/named-template': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/reactivity-transform': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/script-lang': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-block': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-component': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-sfc': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-bind': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-emits': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-vmodel': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/volar': 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
unplugin: 2.3.11
unplugin-combine: 2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unplugin-vue-define-options: 3.1.2(vue@3.5.32(typescript@5.9.3))
vue: 3.5.32(typescript@5.9.3)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
- '@rspack/core'
- '@vueuse/core'
- esbuild
- rolldown
- rollup
- typescript
- vite
- vue-tsc
- webpack
vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)): vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)):
dependencies: dependencies:
'@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3)) '@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))