diff --git a/.github/workflows/deploy-cloudflare-admin-ui.yml b/.github/workflows/deploy-cloudflare-admin-ui.yml deleted file mode 100644 index cc1bd49c2..000000000 --- a/.github/workflows/deploy-cloudflare-admin-ui.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/deploy-cloudflare-workers-dev-server.yml b/.github/workflows/deploy-cloudflare-workers-dev-server.yml index c409a73f0..9a928a762 100644 --- a/.github/workflows/deploy-cloudflare-workers-dev-server.yml +++ b/.github/workflows/deploy-cloudflare-workers-dev-server.yml @@ -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/" diff --git a/apps/server/README.md b/apps/server/README.md index 1e86ba0d0..f60f07a3a 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -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: diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index bf11d7627..3de03c850 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -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 diff --git a/apps/server/src/utils/server-admin-ui.ts b/apps/server/src/utils/server-admin-ui.ts index c027696e0..bd6b0e2cf 100644 --- a/apps/server/src/utils/server-admin-ui.ts +++ b/apps/server/src/utils/server-admin-ui.ts @@ -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: diff --git a/apps/ui-admin/README.md b/apps/ui-admin/README.md deleted file mode 100644 index cd7c93c43..000000000 --- a/apps/ui-admin/README.md +++ /dev/null @@ -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. diff --git a/apps/ui-admin/index.html b/apps/ui-admin/index.html deleted file mode 100644 index c45c50df5..000000000 --- a/apps/ui-admin/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - AIRI Admin - - - - -
- - - - - diff --git a/apps/ui-admin/package.json b/apps/ui-admin/package.json deleted file mode 100644 index 289a24fb2..000000000 --- a/apps/ui-admin/package.json +++ /dev/null @@ -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:" - } -} diff --git a/apps/ui-admin/public/_headers b/apps/ui-admin/public/_headers deleted file mode 100644 index 7f40d0894..000000000 --- a/apps/ui-admin/public/_headers +++ /dev/null @@ -1,4 +0,0 @@ -/assets/* - cache-control: max-age=31536000 - cache-control: immutable - diff --git a/apps/ui-admin/public/_redirects b/apps/ui-admin/public/_redirects deleted file mode 100644 index 7797f7c6a..000000000 --- a/apps/ui-admin/public/_redirects +++ /dev/null @@ -1 +0,0 @@ -/* /index.html 200 diff --git a/apps/ui-admin/src/App.test.ts b/apps/ui-admin/src/App.test.ts deleted file mode 100644 index a5bbd5743..000000000 --- a/apps/ui-admin/src/App.test.ts +++ /dev/null @@ -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() - return { - ...actual, - adminApi: { - me: mocks.me, - }, - } -}) - -describe('admin app shell', () => { - let app: VueApp - 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 = '
' - 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: '
' } }, - ], - }) - 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() -} diff --git a/apps/ui-admin/src/App.vue b/apps/ui-admin/src/App.vue deleted file mode 100644 index 4ecaa30cf..000000000 --- a/apps/ui-admin/src/App.vue +++ /dev/null @@ -1,160 +0,0 @@ - - - diff --git a/apps/ui-admin/src/components/admin-list/AdminListPanel.vue b/apps/ui-admin/src/components/admin-list/AdminListPanel.vue deleted file mode 100644 index d57fa19be..000000000 --- a/apps/ui-admin/src/components/admin-list/AdminListPanel.vue +++ /dev/null @@ -1,187 +0,0 @@ - - - diff --git a/apps/ui-admin/src/components/admin-shell/ApiEnvironmentSelect.vue b/apps/ui-admin/src/components/admin-shell/ApiEnvironmentSelect.vue deleted file mode 100644 index 1ecca4c69..000000000 --- a/apps/ui-admin/src/components/admin-shell/ApiEnvironmentSelect.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/apps/ui-admin/src/components/llm-router/RouterAdvancedJsonPanel.vue b/apps/ui-admin/src/components/llm-router/RouterAdvancedJsonPanel.vue deleted file mode 100644 index f76b1f88b..000000000 --- a/apps/ui-admin/src/components/llm-router/RouterAdvancedJsonPanel.vue +++ /dev/null @@ -1,74 +0,0 @@ - - - diff --git a/apps/ui-admin/src/components/llm-router/RouterDefaultsEditor.vue b/apps/ui-admin/src/components/llm-router/RouterDefaultsEditor.vue deleted file mode 100644 index c2e22bdd8..000000000 --- a/apps/ui-admin/src/components/llm-router/RouterDefaultsEditor.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - diff --git a/apps/ui-admin/src/components/llm-router/RouterModeControl.vue b/apps/ui-admin/src/components/llm-router/RouterModeControl.vue deleted file mode 100644 index af336e76b..000000000 --- a/apps/ui-admin/src/components/llm-router/RouterModeControl.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/apps/ui-admin/src/components/llm-router/RouterPreviewPanel.vue b/apps/ui-admin/src/components/llm-router/RouterPreviewPanel.vue deleted file mode 100644 index 714c98e5f..000000000 --- a/apps/ui-admin/src/components/llm-router/RouterPreviewPanel.vue +++ /dev/null @@ -1,80 +0,0 @@ - - - diff --git a/apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue b/apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue deleted file mode 100644 index c1ee487c0..000000000 --- a/apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue +++ /dev/null @@ -1,177 +0,0 @@ - - - diff --git a/apps/ui-admin/src/components/voice-packs/DatalistField.vue b/apps/ui-admin/src/components/voice-packs/DatalistField.vue deleted file mode 100644 index ec5fe9e31..000000000 --- a/apps/ui-admin/src/components/voice-packs/DatalistField.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/apps/ui-admin/src/main.ts b/apps/ui-admin/src/main.ts deleted file mode 100644 index 4a7817a0a..000000000 --- a/apps/ui-admin/src/main.ts +++ /dev/null @@ -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') diff --git a/apps/ui-admin/src/modules/api.test.ts b/apps/ui-admin/src/modules/api.test.ts deleted file mode 100644 index 25aa69ce8..000000000 --- a/apps/ui-admin/src/modules/api.test.ts +++ /dev/null @@ -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', - ) - }) -}) diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts deleted file mode 100644 index b30e9e802..000000000 --- a/apps/ui-admin/src/modules/api.ts +++ /dev/null @@ -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 -} - -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> - } -} - -export interface AdminRouterConfigResult { - applied: Array> - invalidatedKeys: string[] - preview: Record -} - -export interface AdminRouterConfigCurrent { - request: AdminRouterConfigRequest - preview: Record - 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 - tags?: string[] - languages?: { code: string, title: string }[] - preview_audio_url?: string -} - -export interface SpeechVoicesResult { - voices: SpeechVoice[] - recommended: Record -} - -export interface SpeechTestPayload { - model: string - input: string - voice: string - speed?: number - extra_body?: { - voice_pack?: Record - } -} - -export 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 - 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(path: string, init: RequestInit = {}): Promise { - const endpoint = new URL(`/api/admin${path}`, apiServerUrl()) - return fetchJson(endpoint, init) -} - -async function publicFetch(path: string, init: RequestInit = {}): Promise { - const endpoint = new URL(`/api/v1${path}`, apiServerUrl()) - return fetchJson(endpoint, init) -} - -async function fetchJson(endpoint: URL, init: RequestInit = {}): Promise { - const headers = new Headers(init.headers) - - if (init.body && !headers.has('Content-Type')) - 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 { - 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('/me'), - metrics: () => adminFetch('/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(`/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('/flux-grants?dryRun=true', { - method: 'POST', - body: JSON.stringify(body), - }), - fluxGrant: (body: { amount: number, description: string, emails: string[], idempotencyKey?: string }) => - adminFetch('/flux-grants', { - method: 'POST', - body: JSON.stringify(body), - }), - applyRouterConfig: (body: AdminRouterConfigRequest, dryRun: boolean) => - adminFetch('/config/router', { - method: 'POST', - body: JSON.stringify({ ...body, dryRun }), - }), - routerConfig: () => adminFetch('/config/router'), - speechModels: async (): Promise => { - 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 => { - const query = new URLSearchParams() - query.set('model', model) - const data = await publicFetch>(`/audio/voices?${query.toString()}`) - return { - voices: Array.isArray(data.voices) ? data.voices : [], - recommended: data.recommended && typeof data.recommended === 'object' ? data.recommended : {}, - } - }, - testSpeech: (body: SpeechTestPayload) => - publicFetchBlob('/audio/speech', { - method: 'POST', - body: JSON.stringify(body), - }), - voicePacks: () => adminFetch('/voice-packs'), - createVoicePack: (body: VoicePackPayload) => - adminFetch('/voice-packs', { - method: 'POST', - body: JSON.stringify(body), - }), - updateVoicePack: (id: string, body: Partial) => - adminFetch(`/voice-packs/${encodeURIComponent(id)}`, { - method: 'PATCH', - body: JSON.stringify(body), - }), - disableVoicePack: (id: string) => - adminFetch(`/voice-packs/${encodeURIComponent(id)}/disable`, { - method: 'POST', - }), - capabilityAliases: (surface?: CapabilityAliasSurface) => { - const suffix = surface ? `?surface=${encodeURIComponent(surface)}` : '' - return adminFetch(`/capability-aliases${suffix}`) - }, - syncCapabilityAliases: (surface: CapabilityAliasSurface) => - adminFetch<{ aliases: CapabilityAlias[] }>('/capability-aliases/sync', { - method: 'POST', - body: JSON.stringify({ surface }), - }), - updateCapabilityAlias: (id: string, body: Partial>) => - adminFetch(`/capability-aliases/${encodeURIComponent(id)}`, { - method: 'PATCH', - body: JSON.stringify(body), - }), - updateCapabilityAliasRoute: (id: string, body: Partial>) => - adminFetch(`/capability-aliases/routes/${encodeURIComponent(id)}`, { - method: 'PATCH', - body: JSON.stringify(body), - }), - providerCatalogTtsModels: () => adminFetch('/provider-catalog/tts/models'), - syncProviderCatalogTtsModels: () => - adminFetch<{ models: ProviderCatalogTtsModel[] }>('/provider-catalog/tts/models/sync', { - method: 'POST', - }), - updateProviderCatalogTtsModel: (id: string, body: Partial>) => - adminFetch(`/provider-catalog/tts/models/${encodeURIComponent(id)}`, { - method: 'PATCH', - body: JSON.stringify(body), - }), - providerCatalogTtsVoices: (model: string) => { - const query = new URLSearchParams({ model }) - return adminFetch(`/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>) => - adminFetch(`/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), - }), -} diff --git a/apps/ui-admin/src/modules/router-config-form.test.ts b/apps/ui-admin/src/modules/router-config-form.test.ts deleted file mode 100644 index c9b2e3fd3..000000000 --- a/apps/ui-admin/src/modules/router-config-form.test.ts +++ /dev/null @@ -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".') - }) -}) diff --git a/apps/ui-admin/src/modules/router-config-form.ts b/apps/ui-admin/src/modules/router-config-form.ts deleted file mode 100644 index 49eef8631..000000000 --- a/apps/ui-admin/src/modules/router-config-form.ts +++ /dev/null @@ -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 -export type AliyunNlsRegion = NonNullable - -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 { - const out: NonNullable = {} - 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> { - const parsed = JSON.parse(json) as unknown - if (!isRecord(parsed)) - throw new Error('Default TTS voices must be a JSON object.') - - const out: Record> = {} - 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['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(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 { - 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' -} diff --git a/apps/ui-admin/src/modules/server-admin-context.test.ts b/apps/ui-admin/src/modules/server-admin-context.test.ts deleted file mode 100644 index cfe651640..000000000 --- a/apps/ui-admin/src/modules/server-admin-context.test.ts +++ /dev/null @@ -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 = '' - 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') - }) -}) diff --git a/apps/ui-admin/src/modules/server-admin-context.ts b/apps/ui-admin/src/modules/server-admin-context.ts deleted file mode 100644 index 1c27924a8..000000000 --- a/apps/ui-admin/src/modules/server-admin-context.ts +++ /dev/null @@ -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 - 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 - } -} diff --git a/apps/ui-admin/src/pages/CapabilityAliasesPage.vue b/apps/ui-admin/src/pages/CapabilityAliasesPage.vue deleted file mode 100644 index 93f19c9ac..000000000 --- a/apps/ui-admin/src/pages/CapabilityAliasesPage.vue +++ /dev/null @@ -1,219 +0,0 @@ - - - diff --git a/apps/ui-admin/src/pages/FluxPage.vue b/apps/ui-admin/src/pages/FluxPage.vue deleted file mode 100644 index d7837265a..000000000 --- a/apps/ui-admin/src/pages/FluxPage.vue +++ /dev/null @@ -1,166 +0,0 @@ - - -