mirror of
https://github.com/moeru-ai/airi.git
synced 2026-07-09 15:58:27 +00:00
chore: remove admin dashboard
This commit is contained in:
parent
c7de38b121
commit
63532367a9
46 changed files with 28 additions and 6772 deletions
55
.github/workflows/deploy-cloudflare-admin-ui.yml
vendored
55
.github/workflows/deploy-cloudflare-admin-ui.yml
vendored
|
|
@ -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 }}
|
||||
|
|
@ -130,50 +130,3 @@ jobs:
|
|||
- name: Print preview URL
|
||||
run: |
|
||||
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/"
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ Set this when previewing or deploying auth UI to a different Cloudflare 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@ const EnvSchema = object({
|
|||
AUTH_UI_URL: optional(string(), 'https://accounts.airi.build/ui'),
|
||||
|
||||
// Standalone admin UI base URL. The server keeps `/admin/*` as the historical
|
||||
// entrypoint and redirects those requests here after ui-admin moved out of
|
||||
// the server image.
|
||||
// entrypoint and redirects those requests here after the admin UI moved to
|
||||
// the standalone proj-airi repository.
|
||||
ADMIN_UI_URL: optional(string(), 'https://admin.airi.build'),
|
||||
|
||||
// Canonical user-facing web app origin. Used as the Stripe redirect base
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const SERVER_DEV_ADMIN_UI_URL = 'https://server-dev.airi-server-admin.pag
|
|||
*
|
||||
* Use when:
|
||||
* - 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.
|
||||
*
|
||||
* Expects:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
/assets/*
|
||||
cache-control: max-age=31536000
|
||||
cache-control: immutable
|
||||
|
||||
|
|
@ -1 +0,0 @@
|
|||
/* /index.html 200
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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="{ "alibaba/cosyvoice-v2": { "zh-CN": "longxiaochun_v2" } }"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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')
|
||||
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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),
|
||||
}),
|
||||
}
|
||||
|
|
@ -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".')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 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>
|
||||
|
|
@ -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() ?? '')
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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: { "rate": 1.08, "pitch": 3 }"
|
||||
label="Params JSON"
|
||||
placeholder="{ "rate": 1.08 }"
|
||||
:required="false"
|
||||
:rows="9"
|
||||
textarea-class="font-mono text-xs leading-5"
|
||||
/>
|
||||
|
||||
<Callout v-if="paramsError" label="Invalid params" theme="orange">
|
||||
{{ paramsError }}
|
||||
</Callout>
|
||||
<Callout v-else-if="formError" label="Missing fields" theme="orange">
|
||||
{{ formError }}
|
||||
</Callout>
|
||||
</div>
|
||||
|
||||
<aside :class="['space-y-4']">
|
||||
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-4']">
|
||||
<div :class="['mb-3', 'flex', 'items-center', 'gap-2']">
|
||||
<span :class="['i-lucide-waveform', 'text-neutral-500']" />
|
||||
<h3 :class="['text-sm', 'font-semibold']">
|
||||
Test Audio
|
||||
</h3>
|
||||
</div>
|
||||
<FieldTextArea
|
||||
v-model="testText"
|
||||
description="Generates audio with the current model, voice, rate, pitch, and volume before saving."
|
||||
label="Test text"
|
||||
:required="false"
|
||||
:rows="4"
|
||||
/>
|
||||
<div :class="['mt-3', 'flex', 'justify-end']">
|
||||
<Button
|
||||
icon="i-lucide-play"
|
||||
label="Test"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="paramsError != null || formError != null"
|
||||
:loading="testing"
|
||||
@click="testVoicePack"
|
||||
/>
|
||||
</div>
|
||||
<audio
|
||||
v-if="testAudioUrl"
|
||||
:class="['mt-4', 'w-full']"
|
||||
:src="testAudioUrl"
|
||||
controls
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
|
||||
<h3 :class="['text-sm', 'font-semibold']">
|
||||
Catalog Status
|
||||
</h3>
|
||||
<dl :class="['mt-3', 'space-y-2', 'text-xs', 'text-neutral-600']">
|
||||
<div :class="['flex', 'justify-between', 'gap-3']">
|
||||
<dt>Models</dt>
|
||||
<dd :class="['font-mono']">
|
||||
{{ models.length }}
|
||||
</dd>
|
||||
</div>
|
||||
<div :class="['flex', 'justify-between', 'gap-3']">
|
||||
<dt>Voices</dt>
|
||||
<dd :class="['font-mono']">
|
||||
{{ voices.length }}
|
||||
</dd>
|
||||
</div>
|
||||
<div :class="['flex', 'justify-between', 'gap-3']">
|
||||
<dt>Recommended</dt>
|
||||
<dd :class="['truncate', 'font-mono']">
|
||||
{{ firstRecommendedVoiceId(recommendedVoices) || 'none' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div :class="['border-t', 'border-neutral-200', 'pt-4', 'xl:col-span-2']">
|
||||
<div :class="['flex', 'flex-wrap', 'justify-between', 'gap-2']">
|
||||
<Button
|
||||
v-if="isEditing && selectedPack?.enabled"
|
||||
icon="i-lucide-ban"
|
||||
label="Disable"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="danger"
|
||||
:loading="saving"
|
||||
@click="disablePack"
|
||||
/>
|
||||
<span v-else />
|
||||
<div :class="['flex', 'flex-wrap', 'gap-2']">
|
||||
<Button icon="i-lucide-x" label="Cancel" size="sm" type="button" variant="secondary" @click="router.push('/voice-packs')" />
|
||||
<Button
|
||||
icon="i-lucide-save"
|
||||
label="Save"
|
||||
size="sm"
|
||||
type="submit"
|
||||
:disabled="loading || paramsError != null || formError != null"
|
||||
:loading="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
@ -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(),
|
||||
],
|
||||
})
|
||||
|
|
@ -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'), ''),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
@ -11,7 +11,7 @@ Auth UI for the hosted AIRI server. It is a Vue/Vite app deployed separately fro
|
|||
## Do Not Use When
|
||||
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
"dev:web": "pnpm -rF @proj-airi/stage-web run dev",
|
||||
"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:admin": "pnpm -rF @proj-airi/ui-admin run dev",
|
||||
"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:server": "pnpm -rF @proj-airi/server-runtime run dev",
|
||||
|
|
|
|||
314
pnpm-lock.yaml
generated
314
pnpm-lock.yaml
generated
|
|
@ -2257,7 +2257,7 @@ importers:
|
|||
version: 3.0.2(electron@41.2.1)
|
||||
'@electron-toolkit/tsconfig':
|
||||
specifier: 'catalog:'
|
||||
version: 2.0.0(@types/node@24.12.2)
|
||||
version: 2.0.0(@types/node@25.6.0)
|
||||
'@electron-toolkit/utils':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.0(electron@41.2.1)
|
||||
|
|
@ -2296,7 +2296,7 @@ importers:
|
|||
version: 3.1.0
|
||||
'@intlify/unplugin-vue-i18n':
|
||||
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':
|
||||
specifier: 'catalog:'
|
||||
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
|
||||
|
|
@ -2332,10 +2332,10 @@ importers:
|
|||
version: link:../../packages/ui-transitions
|
||||
'@proj-airi/unplugin-fetch':
|
||||
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':
|
||||
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':
|
||||
specifier: 'catalog:'
|
||||
version: 0.0.97
|
||||
|
|
@ -2362,7 +2362,7 @@ importers:
|
|||
version: 2.10.3
|
||||
'@vitejs/plugin-vue':
|
||||
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':
|
||||
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))
|
||||
|
|
@ -2395,7 +2395,7 @@ importers:
|
|||
version: 6.8.3
|
||||
electron-vite:
|
||||
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:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.0
|
||||
|
|
@ -2416,31 +2416,31 @@ importers:
|
|||
version: 2.2.6
|
||||
unocss-preset-scrollbar:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
specifier: 'catalog:'
|
||||
version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@4.60.1)
|
||||
vite-plugin-mkcert:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.6(typescript@5.9.3)
|
||||
|
|
@ -2836,64 +2836,6 @@ importers:
|
|||
specifier: 'catalog:'
|
||||
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:
|
||||
dependencies:
|
||||
'@date-fns/utc':
|
||||
|
|
@ -20735,9 +20677,9 @@ snapshots:
|
|||
dependencies:
|
||||
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:
|
||||
'@types/node': 24.12.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@electron-toolkit/utils@4.0.0(electron@41.2.1)':
|
||||
dependencies:
|
||||
|
|
@ -21647,31 +21589,6 @@ snapshots:
|
|||
- 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@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))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
|
||||
|
|
@ -23674,35 +23591,11 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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))':
|
||||
dependencies:
|
||||
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)
|
||||
|
||||
'@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)':
|
||||
dependencies:
|
||||
ofetch: 1.5.1
|
||||
|
|
@ -25249,12 +25142,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.13
|
||||
|
|
@ -25532,15 +25419,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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))':
|
||||
dependencies:
|
||||
sirv: 3.0.2
|
||||
|
|
@ -27618,7 +27496,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@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
|
||||
magic-string: 0.30.21
|
||||
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:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -33617,6 +33495,11 @@ snapshots:
|
|||
'@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-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)):
|
||||
dependencies:
|
||||
'@unocss/cli': 66.6.8
|
||||
|
|
@ -33683,14 +33566,6 @@ snapshots:
|
|||
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@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)):
|
||||
optionalDependencies:
|
||||
esbuild: 0.27.2
|
||||
|
|
@ -33712,19 +33587,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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)):
|
||||
dependencies:
|
||||
ci-info: 4.4.0
|
||||
|
|
@ -33818,17 +33680,6 @@ snapshots:
|
|||
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)
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
|
|
@ -34056,22 +33907,12 @@ snapshots:
|
|||
- rollup
|
||||
- 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)):
|
||||
dependencies:
|
||||
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-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)):
|
||||
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)
|
||||
|
|
@ -34117,21 +33958,6 @@ snapshots:
|
|||
- tsx
|
||||
- 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)):
|
||||
dependencies:
|
||||
ansis: 4.2.0
|
||||
|
|
@ -34163,13 +33989,6 @@ snapshots:
|
|||
- typescript
|
||||
- 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)):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
|
|
@ -34188,20 +34007,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
|
||||
|
|
@ -34216,21 +34021,6 @@ snapshots:
|
|||
- supports-color
|
||||
- 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)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
|
@ -34246,16 +34036,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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)):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
|
|
@ -34523,54 +34303,6 @@ snapshots:
|
|||
- 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@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)):
|
||||
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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue