diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index db11d2a7f125..95ad9c42a480 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -19,6 +19,17 @@ nothing. While no Codex runtime row is stored, ## Codex gateway borrowing +The Extensions navigation exposes three administrator pages: settings at +`/admin/codex-gateway-borrow`, route status at +`/admin/codex-gateway-borrow/status`, and manual comparison/history at +`/admin/codex-pelican-comparison`. Opening or navigating these pages only reads +local data. Settings drafts contain configuration IDs/options only, persist in +the browser session, and never affect the saved configuration used by status or +generation. Only explicit actions save, prepare, verify or generate. The status +page polls read-only status every five seconds while preparation/verification is +active; local countdowns expire displayed routes without renewing them. Leaving +the comparison page cancels its active generation. + `GET/PUT /admin/codex-gateway-borrow/config` owns one JSON setting, `codex_gateway_borrow_config`: `enabled`, `source_account_ids`, `target_account_ids` and `models`. A missing row is off with empty account diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index a6f718262d8d..e3255bae73a5 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -143,6 +143,10 @@ jobs: src/api/admin/__tests__/codexGatewayBorrow.spec.ts \ src/components/admin/codex/__tests__/BorrowPelicanPreview.spec.ts \ src/views/admin/__tests__/CodexGatewayBorrowView.spec.ts \ + src/views/admin/__tests__/CodexGatewayBorrowStatusView.spec.ts \ + src/views/admin/__tests__/CodexPelicanComparisonView.spec.ts \ + src/components/admin/__tests__/CodexBorrowNav.spec.ts \ + src/router/__tests__/codex-borrow-routes.spec.ts \ src/components/account/__tests__/EditAccountModal.spec.ts \ src/utils/__tests__/openaiProtocolModeEdits.spec.ts \ src/components/account/__tests__/ModelWhitelistSelector.spec.ts \ diff --git a/frontend/src/components/admin/CodexBorrowNav.vue b/frontend/src/components/admin/CodexBorrowNav.vue new file mode 100644 index 000000000000..48d959a5995d --- /dev/null +++ b/frontend/src/components/admin/CodexBorrowNav.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/components/admin/__tests__/CodexBorrowNav.spec.ts b/frontend/src/components/admin/__tests__/CodexBorrowNav.spec.ts new file mode 100644 index 000000000000..f1e2aeaa8d4b --- /dev/null +++ b/frontend/src/components/admin/__tests__/CodexBorrowNav.spec.ts @@ -0,0 +1,52 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import { describe, expect, it, vi } from 'vitest' +import CodexBorrowNav from '../CodexBorrowNav.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})) + +const paths = [ + '/admin/codex-gateway-borrow', + '/admin/codex-gateway-borrow/status', + '/admin/codex-pelican-comparison', +] + +async function renderNav(path: string) { + const router = createRouter({ + history: createMemoryHistory(), + routes: paths.map(routePath => ({ path: routePath, component: { template: '
' } })), + }) + await router.push(path) + await router.isReady() + const wrapper = mount(CodexBorrowNav, { global: { plugins: [router] } }) + return { router, wrapper } +} + +describe('Codex borrowing page navigation', () => { + it.each(paths)('renders real links and marks only the current page on %s', async path => { + const { wrapper } = await renderNav(path) + + expect(wrapper.findAll('a').map(link => link.attributes('href'))).toEqual(paths) + expect(wrapper.findAll('a[aria-current="page"]')).toHaveLength(1) + expect(wrapper.get('a[aria-current="page"]').attributes('href')).toBe(path) + wrapper.unmount() + }) + + it('navigates between pages and supports browser Back', async () => { + const { router, wrapper } = await renderNav(paths[0]) + + await wrapper.get(`a[href="${paths[1]}"]`).trigger('click') + await flushPromises() + expect(router.currentRoute.value.path).toBe(paths[1]) + await wrapper.get(`a[href="${paths[2]}"]`).trigger('click') + await flushPromises() + expect(router.currentRoute.value.path).toBe(paths[2]) + + router.back() + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(paths[1])) + expect(wrapper.get('a[aria-current="page"]').attributes('href')).toBe(paths[1]) + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index ea2313e8af87..c650b5e59845 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -113,7 +113,7 @@ :key="item.path" :to="item.path" class="sidebar-link mb-1" - :class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }" + :class="{ 'sidebar-link-active': route.path === item.path, 'sidebar-link-collapsed': sidebarCollapsed }" :title="sidebarCollapsed ? item.label : undefined" @click="handleMenuItemClick(item.path)" > @@ -507,6 +507,14 @@ const GatewayBorrowIcon = { render: () => h(Icon, { name: 'link', 'aria-hidden': 'true' }) } +const GatewayBorrowStatusIcon = { + render: () => h(Icon, { name: 'checkCircle', 'aria-hidden': 'true' }) +} + +const PelicanComparisonIcon = { + render: () => h(Icon, { name: 'grid', 'aria-hidden': 'true' }) +} + const BellIcon = { render: () => h( @@ -834,6 +842,8 @@ const adminExtensionNavItems = computed((): NavItem[] => [ { path: '/admin/system-prompts', label: t('nav.systemPrompts'), icon: DocumentIcon }, { path: '/admin/codex-runtime', label: t('nav.codexRuntime'), icon: PluginIcon }, { path: '/admin/codex-gateway-borrow', label: t('nav.codexGatewayBorrow'), icon: GatewayBorrowIcon }, + { path: '/admin/codex-gateway-borrow/status', label: t('nav.codexGatewayBorrowStatus'), icon: GatewayBorrowStatusIcon }, + { path: '/admin/codex-pelican-comparison', label: t('nav.codexPelicanComparison'), icon: PelicanComparisonIcon }, { path: '/admin/reasoning-recovery', label: t('nav.reasoningRecovery'), icon: RecoveryIcon }, ]) diff --git a/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts b/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts index a56b74b12d3a..127b4764c7c7 100644 --- a/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts +++ b/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts @@ -62,15 +62,15 @@ vi.mock('vue-i18n', async importOriginal => { } }) -async function renderSidebar(options: { admin?: boolean } = {}) { +async function renderSidebar(options: { admin?: boolean; simple?: boolean; path?: string } = {}) { mocks.authStore.isAdmin = options.admin === true - mocks.authStore.isSimpleMode = true + mocks.authStore.isSimpleMode = options.simple !== false const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: { template: '
' } }], }) - await router.push(options.admin ? '/admin/dashboard' : '/dashboard') + await router.push(options.path ?? (options.admin ? '/admin/dashboard' : '/dashboard')) await router.isReady() return mount(AppSidebar, { @@ -97,27 +97,42 @@ describe('AppSidebar simple mode extensions', () => { document.documentElement.classList.remove('dark') }) - it('renders no extensions section for a regular user', async () => { - const wrapper = await renderSidebar() + it.each([true, false])('renders no extensions section for a regular user (simple: %s)', async simple => { + const wrapper = await renderSidebar({ simple }) expect(wrapper.find('[data-testid="sidebar-extensions"]').exists()).toBe(false) - expect(wrapper.find('a[href="/usage"]').exists()).toBe(false) + if (simple) expect(wrapper.find('a[href="/usage"]').exists()).toBe(false) + wrapper.unmount() }) - it('renders admin extensions for an admin in simple mode', async () => { - const wrapper = await renderSidebar({ admin: true }) + it.each([true, false])('renders the three admin page entries in order (simple: %s)', async simple => { + const wrapper = await renderSidebar({ admin: true, simple }) expect(extensionLinks(wrapper)).toEqual([ '/admin/system-prompts', '/admin/codex-runtime', '/admin/codex-gateway-borrow', + '/admin/codex-gateway-borrow/status', + '/admin/codex-pelican-comparison', '/admin/reasoning-recovery', ]) - expect(wrapper.text()).not.toContain('nav.myAccount') + if (simple) expect(wrapper.text()).not.toContain('nav.myAccount') wrapper.unmount() - const regularUser = await renderSidebar() + const regularUser = await renderSidebar({ simple }) expect(regularUser.find('a[href="/admin/codex-gateway-borrow"]').exists()).toBe(false) + expect(regularUser.find('a[href="/admin/codex-gateway-borrow/status"]').exists()).toBe(false) + expect(regularUser.find('a[href="/admin/codex-pelican-comparison"]').exists()).toBe(false) regularUser.unmount() }) + + it('marks only Borrow Status active on its own route', async () => { + const wrapper = await renderSidebar({ admin: true, path: '/admin/codex-gateway-borrow/status' }) + const section = wrapper.get('[data-testid="sidebar-extensions"]') + + expect(section.get('a[href="/admin/codex-gateway-borrow/status"]').classes()).toContain('sidebar-link-active') + expect(section.get('a[href="/admin/codex-gateway-borrow"]').classes()).not.toContain('sidebar-link-active') + expect(section.findAll('.sidebar-link-active')).toHaveLength(1) + wrapper.unmount() + }) }) diff --git a/frontend/src/composables/useCodexBorrowUI.ts b/frontend/src/composables/useCodexBorrowUI.ts new file mode 100644 index 000000000000..5898e1369c0b --- /dev/null +++ b/frontend/src/composables/useCodexBorrowUI.ts @@ -0,0 +1,95 @@ +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { accountsAPI } from '@/api/admin/accounts' +import type { BorrowTargetStatus } from '@/api/admin/codexGatewayBorrow' +import type { AccountListItem } from '@/types' +import { resolveOpenAIWSModeFromExtra } from '@/utils/openaiWsMode' + +/** Inventory is local to each page; never discover models or test accounts here. */ +export function useCodexBorrowInventory(signal: AbortSignal) { + const { t, te } = useI18n() + const accounts = ref([]) + const accountMap = computed(() => new Map(accounts.value.map(account => [account.id, account]))) + function accountById(id: number) { return accountMap.value.get(id) } + function accountName(id: number) { return accountById(id)?.name || t('admin.codexGatewayBorrow.unknownAccount') } + function accountStateLabel(account?: AccountListItem) { + if (!account) return t('admin.codexGatewayBorrow.unknownAccount') + const key = `admin.codexGatewayBorrow.states.${account.status}` + const parts = [te(key) ? t(key) : account.status] + if (!account.schedulable) parts.push(t('admin.codexGatewayBorrow.paused')) + if (account.rate_limit_reset_at && Date.parse(account.rate_limit_reset_at) > Date.now()) parts.push(t('admin.codexGatewayBorrow.rateLimited')) + return parts.join(' · ') + } + function proxyLabel(account?: AccountListItem) { + if (!account) return '—' + if (account.proxy) return `${account.proxy.name} #${account.proxy.id}` + if (account.proxy_id) return `#${account.proxy_id}` + return t('admin.codexGatewayBorrow.direct') + } + function wsLabel(account: AccountListItem) { + const keys = ['openai_oauth_responses_websockets_v2_mode', 'openai_oauth_responses_websockets_v2_enabled', 'responses_websockets_v2_enabled', 'openai_ws_enabled'] + if (!keys.some(key => account.extra?.[key] !== undefined)) return t('admin.codexGatewayBorrow.wsUnspecified') + return resolveOpenAIWSModeFromExtra(account.extra, { modeKey: keys[0], enabledKey: keys[1], fallbackEnabledKeys: keys.slice(2) }) + } + function modelMappingLabel(account: AccountListItem) { + const mapping = account.credentials?.model_mapping + if (!mapping || typeof mapping !== 'object' || !Object.keys(mapping).length) return t('admin.codexGatewayBorrow.noModelRestriction') + const entries = Object.entries(mapping).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + return `${t('admin.codexGatewayBorrow.localMappings')}: ${entries.map(([from, to]) => from === to ? from : `${from} → ${to}`).join(', ')}` + } + async function loadAccounts() { + const found: AccountListItem[] = [] + for (let page = 1; !signal.aborted; page++) { + const response = await accountsAPI.list(page, 100, { platform: 'openai', types: 'oauth,setup-token' }, { signal }) + found.push(...response.items.filter(account => account.platform === 'openai' && (account.type === 'oauth' || account.type === 'setup-token'))) + if (!response.items.length || page * 100 >= response.total) break + } + if (!signal.aborted) accounts.value = found + } + return { accounts, loadAccounts, accountById, accountName, accountStateLabel, proxyLabel, wsLabel, modelMappingLabel } +} + +/** A local tick expires the display without requesting or renewing any route. */ +export function useCodexBorrowClock() { + const now = ref(Date.now()) + const serverClockKnown = ref(false) + const serverOffsetMs = ref(0) + let timer: ReturnType | undefined + function receiveServerTime(generatedAt: string) { + const time = Date.parse(generatedAt) + serverClockKnown.value = Number.isFinite(time) + if (serverClockKnown.value) { + serverOffsetMs.value = time - Date.now() + now.value = time + } + } + function remainingSeconds(expiresAt?: string) { + const expires = expiresAt ? Date.parse(expiresAt) : NaN + return Number.isFinite(expires) ? Math.max(0, Math.ceil((expires - now.value) / 1000)) : 0 + } + function cacheUsable(target?: BorrowTargetStatus) { + return !!target?.cache_valid && (!target.expires_at || Date.parse(target.expires_at) > now.value) + } + onMounted(() => { timer = setInterval(() => { now.value = Date.now() + serverOffsetMs.value }, 1000) }) + onBeforeUnmount(() => { if (timer !== undefined) clearInterval(timer) }) + return { now, serverClockKnown, serverOffsetMs, receiveServerTime, cacheUsable, remainingSeconds } +} + +export function borrowTargetState(target: BorrowTargetStatus | undefined, now: number): 'ready' | 'expired' | 'validating' | 'waiting' | 'rejected' { + if (!target) return 'waiting' + if (target.cache_valid && (!target.expires_at || Date.parse(target.expires_at) > now)) return 'ready' + if (target.state === 'expired' || ((target.cache_valid || target.state === 'ready' || target.state === 'valid') && !!target.expires_at && Date.parse(target.expires_at) <= now)) return 'expired' + if (target.state === 'validating' || target.reason === 'validating') return 'validating' + if (['waiting', 'missing', 'disabled'].includes(target.state) || ['not_prepared', 'not_verified', 'not_configured', 'disabled'].includes(target.reason)) return 'waiting' + return 'rejected' +} + +const knownReasons = new Set([ + 'not_prepared', 'not_verified', 'not_configured', 'disabled', 'route_expired', + 'source_qualified', 'source_probe_failed', 'source_response_not_qualified', 'source_routing_cookie_unavailable', + 'target_probe_passed', 'target_probe_failed', 'target_route_changed', 'target_state_missing', 'target_state_changed', 'validating', + 'candidate_ready', 'validated', 'no_matched_cache' +]) +export function borrowReasonLabel(reason: string, t: (key: string) => string): string { + return knownReasons.has(reason) ? t(`admin.codexGatewayBorrow.reasons.${reason}`) : reason +} diff --git a/frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts b/frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts index 81a185c5f554..867fee70d2f7 100644 --- a/frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts +++ b/frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts @@ -1,22 +1,34 @@ export default { codexGatewayBorrow: { - title: 'Codex Gateway Borrow', - description: 'Choose source and target accounts, inspect the shared candidate and per-account, per-model validation caches, and run the pelican animation test when needed.', - configuration: 'Borrow configuration', + title: 'Codex Borrow Settings', + description: 'Choose the source accounts that provide borrow routes, the target accounts that use them, and the models, then save the configuration.', + navLabel: 'Codex borrowing and Pelican Comparison', + navSettings: 'Borrow Settings', + navStatus: 'Borrow Status', + navComparison: 'Pelican Comparison', + configuration: 'Choose accounts and models', enabled: 'Enable gateway borrowing', on: 'Enabled', off: 'Disabled', - saveHint: 'Saving while enabled starts one preparation on the server. Turning it off or opening this page does not generate model output.', + saveHint: 'Saving while enabled starts one background preparation, collecting source routes and validating targets in two rounds when needed.', + savedConfiguration: 'Saved configuration', + savedSelection: '{sources} sources · {targets} targets · {models} models', + draftHint: 'Unsaved changes stay in this browser session. Switching pages does not save them; Borrow Status and Pelican Comparison always use the saved configuration.', + unsavedChanges: 'Unsaved changes', + resetDraft: 'Discard unsaved changes', models: 'Borrow models', - sources: 'Source accounts', - sourcesHint: 'Choose accounts that provide the shared candidate. Source and target selections must be disjoint.', - targets: 'Target accounts', - targetsHint: 'Choose borrowing targets. Each target and model needs successful validation before its cache is usable.', + sources: 'Accounts providing borrow routes (source)', + sourcesHint: 'Source accounts provide borrow routes. An account cannot be both a source and a target.', + targets: 'Accounts using borrow routes (target)', + targetsHint: 'After validation, target accounts use the source route to access selected models.', searchAccounts: 'Search account name or ID', noAccounts: 'No matching local OpenAI OAuth-like accounts', missingAccounts: 'Selected accounts absent from the current local inventory: {ids}', localInventoryHint: 'This list reads local accounts only. Accounts are selected manually and no upstream model catalog is requested. Existing proxies, WS settings, and model mappings are shown for reference; address missing conditions in account settings.', - proxy: 'Existing proxy', + proxy: 'Bound exit', + accountDetails: 'Account details', + selected: 'Selected', + selectedAsOtherRole: 'Selected for the other role', direct: 'Direct', shadowOf: 'Shadow account; parent: {name} #{id}', wsUnspecified: 'Not specified on this account', @@ -28,11 +40,47 @@ export default { disjointRequired: 'Source and target account selections must be disjoint.', selectionRequired: 'Choose at least one source, one target, and one model before enabling.', invalidConfig: 'The server returned an invalid borrow configuration', - saved: 'Borrow configuration saved', - loadFailed: 'Unable to load borrow configuration or status', + saved: 'Configuration saved', + currentPreparationState: 'Current preparation status', + preparationIdle: 'No preparation is currently running', + viewBorrowStatus: 'View Borrow Status', + statusReadFailed: 'Unable to read preparation status; the configuration save is unaffected.', + loadFailed: 'Unable to read borrow configuration', + statusLoadFailed: 'Unable to read borrow status', + inventoryLoadFailed: 'Unable to read local account inventory', saveFailed: 'Unable to save borrow configuration', refreshStatus: 'Refresh status', - borrowStatus: 'Borrow status and validation', + borrowStatus: 'Borrow status', + statusTitle: 'Codex Borrow Status', + statusDescription: 'Inspect borrow routes for the saved configuration, then prepare or revalidate when needed.', + preparationState: 'Preparation progress', + idle: 'No preparation in progress', + readyPairs: 'Usable account / model pairs', + pairCount: '{ready} / {total}', + currentSource: 'Current source', + candidateExpired: 'Source route expired', + validationMeaning: '“Route validation passed” means the borrowing conditions are met. Inspect artwork quality on the Pelican Comparison page.', + prepareRoutes: 'Prepare borrow routes', + prepareRoutesHint: 'Prepare the saved sources and targets, collecting a source route and validating account / model pairs in two rounds when needed.', + revalidatePair: 'Revalidate this account / model', + revalidateHint: 'Revalidate this account and model with two requests, preparing a source route first when needed.', + pollingHint: 'While preparation or validation runs, refresh read-only status every 5 seconds, then stop. Expiry updates locally in the browser.', + technicalDetails: 'Technical details', + rawState: 'Server state label', + rawReason: 'Raw reason', + fullError: 'Full error', + stateMinted: 'STATE obtained in first request', + stateUnavailable: 'This endpoint does not expose the STATE value.', + targetsStatus: 'Target accounts and models', + noConfiguredTargets: 'No target accounts and models are saved yet. Complete Borrow Settings first.', + preparationPending: 'Preparing borrow routes', + lineStates: { + waiting: 'Not prepared', + validating: 'Preparing', + ready: 'Route validation passed', + expired: 'Expired', + rejected: 'Validation failed', + }, observedAt: 'Status read at', prepare: 'Prepare shared candidate', preparing: 'Preparing', @@ -40,10 +88,10 @@ export default { prepareFailed: 'Preparation failed', sharedCandidate: 'Shared candidate source', candidateFingerprint: 'Candidate fingerprint', - noCandidate: 'No shared candidate', - sourceStates: 'Source states', + noCandidate: 'No borrowed source route has been acquired yet', + sourceStates: 'Source acquisition records', accountModel: 'Account / model', - validation: 'Validation and cache', + validation: 'Route status', expiryCooldown: 'Times and cooldown', actions: 'Actions', cacheReady: 'Matching cache available', @@ -59,22 +107,22 @@ export default { validating: 'Validating', verifyFailed: 'Verification failed', noTargets: 'No target validation records yet', - pelicanTitle: 'Pelican bicycle animation test', - pelicanHint: 'Each selected account and model generates one artwork after manual submission. Only a matching, valid borrow cache is used. Results are for observation.', - fixedPrompt: 'Fixed prompt (read only)', - testModels: 'Test models and reasoning effort', - testAccounts: 'Test accounts (manual selection)', - effort: 'Effort', + pelicanTitle: 'Pelican Comparison', + pelicanHint: 'Generate works using prepared borrow routes. Each selected account and model produces one work for administrators to inspect.', + fixedPrompt: 'Fixed prompt', + testModels: 'Participating models and reasoning effort', + testAccounts: 'Participating accounts', + effort: 'Reasoning effort', willSkip: 'Will skip without generation', - executionHint: 'At most 3 jobs run concurrently; models for the same account run serially, with a 90-second limit per item. Paused, errored, or rate-limited accounts with valid caches may be explicitly selected. Tests do not recover accounts or rerun automatically. Stopping or leaving this page disconnects the stream.', - startTest: 'Generate {count} artworks', + executionHint: 'At most 3 jobs run concurrently; models for the same account run serially, with a 90-second limit per item. Paused, errored, or rate-limited accounts with valid routes may be explicitly selected. Generation is for observation. Stopping or leaving this page cancels queued and in-flight generation.', + startTest: 'Generate works ({count} account/model combinations)', stop: 'Stop generation', stoppedHint: 'The generation connection has stopped and unfinished items are cancelled. Received results remain visible; the final record can be read from server history.', testFailed: 'Generation connection failed', clockUnavailable: 'The server clock is not available yet. Refresh status before submitting a generation task.', resultViews: 'Result views', - results: 'Artworks', - history: '24-hour history', + results: 'Current works', + history: 'History', replayed: 'Existing task; no duplicate generation', historyHint: 'History is stored on the server for 24 hours. Refreshing and viewing read records without retesting; expired records are removed automatically.', refreshHistory: 'Refresh history', @@ -86,14 +134,48 @@ export default { taskId: 'Task ID', duration: 'Duration', previewPending: 'Waiting for generation output', + previewMissingHtml: 'No preview could be extracted. Open the original answer.', + previewNotGenerated: 'No work was generated. See the status and reason.', enlarge: 'Enlarge preview', rawAnswer: 'Raw answer', htmlSource: 'HTML source', rawResponse: 'Raw upstream response', downloadAnswer: 'Download raw answer', + reasons: { + not_prepared: 'Borrow routes have not been prepared yet', + not_verified: 'This account and model have not completed route validation', + not_configured: 'This account and model are absent from the saved configuration', + disabled: 'Borrowing is currently disabled', + route_expired: 'The borrow route has expired; prepare or validate it again', + source_qualified: 'The source account can provide a borrow route', + source_probe_failed: 'Source route collection failed; see technical details', + source_response_not_qualified: 'The source response did not meet the borrowing conditions', + source_routing_cookie_unavailable: 'The source response did not include borrowable route information', + target_probe_passed: 'Both route validation requests passed for this account and model', + target_probe_failed: 'Route validation failed for this account and model; see technical details', + target_route_changed: 'The route changed during validation; validate it again', + target_state_missing: 'The first response had no session state to validate continuation', + target_state_changed: 'The session state differed between the two responses; validate again', + validating: 'Validating the borrow route for this account and model', + candidate_ready: 'The source route has been collected', + validated: 'The borrow route passed validation for this account and model', + no_matched_cache: 'This account and model have no usable borrow route yet', + }, states: { pending: 'Pending', running: 'Running', complete: 'Complete', failed: 'Failed', incomplete: 'Incomplete', cancelled: 'Cancelled', skipped: 'Skipped', active: 'Active', inactive: 'Inactive', disabled: 'Disabled', error: 'Error', validating: 'Validating', valid: 'Valid', ready: 'Ready', expired: 'Expired', cooldown: 'Cooldown', unavailable: 'Unavailable', missing: 'Missing', preparing: 'Preparing', waiting: 'Not prepared', rejected: 'Validation rejected' } + }, + codexPelicanComparison: { + title: 'Pelican Comparison', + description: 'Generate works using prepared borrowed routes and compare accounts and models side by side.', + viewStatus: 'View borrow status', + refreshAvailability: 'Refresh route status', + viewPrompt: 'View prompt', + noTargets: 'No target accounts have been saved. Choose accounts in borrow settings, then prepare routes.', + disabledHint: 'Borrowing is currently disabled. Enable and save it in borrow settings, then prepare routes on the status page.', + results: 'Current works', + history: 'History', + routeReason: 'Route reason', } } diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index a0b9ffbfbfd7..a9afc27e57f8 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -223,7 +223,9 @@ export default { auditLogs: 'Audit Logs', systemPrompts: 'System Prompts', reasoningRecovery: 'Reasoning Recovery', - codexGatewayBorrow: 'Codex Gateway Borrow', + codexGatewayBorrow: 'Codex Borrow Settings', + codexGatewayBorrowStatus: 'Codex Borrow Status', + codexPelicanComparison: 'Pelican Comparison', }, // Auth diff --git a/frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts b/frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts index 2f3a9745b8f7..f15776853cb1 100644 --- a/frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts +++ b/frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts @@ -1,22 +1,34 @@ export default { codexGatewayBorrow: { - title: 'Codex 网关借用', - description: '选择来源账号与借用目标,查看共享候选及逐账号、逐模型的验证缓存,再按需运行鹈鹕动画测试。', - configuration: '借用配置', + title: 'Codex 借用设置', + description: '选择提供借用线路的来源账号、使用线路的目标账号和模型,再保存配置。', + navLabel: 'Codex 借用与鹈鹕对比', + navSettings: '借用设置', + navStatus: '借用状态', + navComparison: '鹈鹕对比', + configuration: '选择账号与模型', enabled: '启用网关借用', on: '已启用', off: '已关闭', - saveHint: '启用后保存配置会由服务端启动一次准备;关闭或仅打开本页不会发起模型生成。', + saveHint: '启用状态下保存会启动一次后台准备,需要时采集来源线路并对目标执行两轮验证。', + savedConfiguration: '已保存配置', + savedSelection: '来源 {sources} 个 · 目标 {targets} 个 · 模型 {models} 个', + draftHint: '未保存的修改仅保留在当前浏览器会话中。切换页面不会自动保存;借用状态与鹈鹕对比始终使用已保存配置。', + unsavedChanges: '有未保存的修改', + resetDraft: '放弃未保存的修改', models: '借用模型', - sources: '来源账号', - sourcesHint: '选择提供共享候选的账号;来源与目标不能重叠。', - targets: '目标账号', - targetsHint: '选择借用目标。每个目标、模型须验证成功后才有可用缓存。', + sources: '提供借用线路的账号(来源)', + sourcesHint: '来源账号负责提供借用线路,来源与目标不能是同一个账号。', + targets: '使用借用线路的账号(目标)', + targetsHint: '目标账号通过验证后,使用来源账号提供的线路访问所选模型。', searchAccounts: '搜索账号名称或 ID', noAccounts: '没有匹配的本地 OpenAI OAuth 类账号', missingAccounts: '当前本地列表中不存在的已选账号:{ids}', localInventoryHint: '列表只读取本地账号数据,不自动全选、不查询上游模型清单。代理、WS 设置及模型映射只供查看;缺少条件时请到账号设置处理。', - proxy: '现有代理', + proxy: '绑定出口', + accountDetails: '账号详情', + selected: '已选', + selectedAsOtherRole: '已选作另一角色', direct: '直连', shadowOf: '影子账号,母账号:{name} #{id}', wsUnspecified: '未在账号上指定', @@ -28,11 +40,47 @@ export default { disjointRequired: '来源账号与目标账号不能重叠。', selectionRequired: '启用时至少选择一个来源、一个目标及一个模型。', invalidConfig: '服务端返回的借用配置无效', - saved: '借用配置已保存', - loadFailed: '加载借用配置或状态失败', + saved: '配置已保存', + currentPreparationState: '实际准备状态', + preparationIdle: '当前没有准备任务在运行', + viewBorrowStatus: '查看借用状态', + statusReadFailed: '读取准备状态失败;配置保存结果不受影响。', + loadFailed: '读取借用配置失败', + statusLoadFailed: '读取借用状态失败', + inventoryLoadFailed: '读取本地账号列表失败', saveFailed: '保存借用配置失败', refreshStatus: '刷新状态', - borrowStatus: '借用状态与验证', + borrowStatus: '借用状态', + statusTitle: 'Codex 借用状态', + statusDescription: '查看已保存配置的借用线路,按需准备或重新验证。', + preparationState: '准备进度', + idle: '当前没有准备任务', + readyPairs: '可使用的账号/模型', + pairCount: '{ready} / {total}', + currentSource: '当前来源', + candidateExpired: '来源线路已过期', + validationMeaning: '“线路验证通过”表示满足借用条件;作品质量请在鹈鹕对比页观察。', + prepareRoutes: '准备借用线路', + prepareRoutesHint: '准备已保存的来源与目标;需要时采集来源线路,并对账号/模型执行两轮验证。', + revalidatePair: '重新验证该账号/模型', + revalidateHint: '重新验证这一账号与模型(两轮请求);需要时先准备来源线路。', + pollingHint: '准备或验证期间每 5 秒只读刷新状态;结束后停止。有效期在浏览器本地更新。', + technicalDetails: '技术详情', + rawState: '服务端状态标识', + rawReason: '原始原因', + fullError: '完整错误', + stateMinted: '首轮取得 STATE', + stateUnavailable: '此接口未提供 STATE 原值。', + targetsStatus: '目标账号与模型', + noConfiguredTargets: '尚未保存目标账号与模型,请先完成借用设置。', + preparationPending: '正在准备借用线路', + lineStates: { + waiting: '尚未准备', + validating: '准备中', + ready: '线路验证通过', + expired: '已过期', + rejected: '验证失败', + }, observedAt: '状态读取时间', prepare: '准备共享候选', preparing: '正在准备', @@ -40,10 +88,10 @@ export default { prepareFailed: '准备失败', sharedCandidate: '共享候选来源', candidateFingerprint: '候选指纹', - noCandidate: '没有共享候选', - sourceStates: '来源状态', + noCandidate: '尚未取得可借用的来源线路', + sourceStates: '来源采集记录', accountModel: '账号 / 模型', - validation: '验证与缓存', + validation: '线路状态', expiryCooldown: '时间与冷却', actions: '操作', cacheReady: '匹配缓存可用', @@ -59,22 +107,22 @@ export default { validating: '正在验证', verifyFailed: '验证失败', noTargets: '尚无目标验证记录', - pelicanTitle: '鹈鹕骑自行车动画测试', - pelicanHint: '手动提交后,每个所选账号与模型生成一份作品。只使用匹配且有效的借用缓存;结果仅供观察。', - fixedPrompt: '固定提示词(只读)', - testModels: '测试模型与推理强度', - testAccounts: '测试账号(手动选择)', - effort: '推理强度', + pelicanTitle: '鹈鹕对比', + pelicanHint: '使用已经准备好的借用线路生成作品。每个所选账号与模型生成一份,作品效果由管理员观察。', + fixedPrompt: '固定题目', + testModels: '参与模型与思考强度', + testAccounts: '参与账号', + effort: '思考强度', willSkip: '将跳过,不发起生成', - executionHint: '最多同时执行 3 个任务,同一账号的模型串行,每项最多 90 秒。暂停、错误或限流账号有有效缓存时可明确选中;测试不会恢复账号,也不会自动重测。停止或离开本页会中断连接。', - startTest: '生成 {count} 份作品', + executionHint: '最多同时执行 3 个任务,同一账号的模型串行,每项最多 90 秒。暂停、错误或限流账号有有效线路时可明确选中;生成仅供观察。停止或离开本页会取消剩余任务和在途生成。', + startTest: '生成作品({count} 个账号/模型组合)', stop: '停止生成', stoppedHint: '生成连接已停止,尚未结束的项目已取消;已收到的结果保留,可从服务端历史查看最终记录。', testFailed: '生成连接失败', clockUnavailable: '尚未取得服务端时间;刷新状态后才能提交生成任务。', resultViews: '结果视图', - results: '作品', - history: '24 小时历史', + results: '本次作品', + history: '历史记录', replayed: '已有任务,未重复生成', historyHint: '历史保存在服务端 24 小时。刷新和查看只读取记录,不会重测;到期后自动移除。', refreshHistory: '刷新历史', @@ -86,14 +134,48 @@ export default { taskId: '任务 ID', duration: '耗时', previewPending: '等待生成结果', + previewMissingHtml: '无法提取预览,可展开回答原文查看', + previewNotGenerated: '未生成作品,请查看状态与原因', enlarge: '放大预览', rawAnswer: '回答原文', htmlSource: 'HTML 源码', rawResponse: '上游响应原文', downloadAnswer: '下载回答原文', + reasons: { + not_prepared: '尚未准备借用线路', + not_verified: '此账号与模型尚未完成线路验证', + not_configured: '此账号与模型未包含在已保存配置中', + disabled: '借用功能当前关闭', + route_expired: '借用线路已过期,请重新准备或验证', + source_qualified: '来源账号可以提供借用线路', + source_probe_failed: '采集来源线路失败,请查看技术详情', + source_response_not_qualified: '来源响应未满足借用条件', + source_routing_cookie_unavailable: '来源响应缺少可借用的线路信息', + target_probe_passed: '此账号与模型的两轮线路验证已通过', + target_probe_failed: '此账号与模型的线路验证失败,请查看技术详情', + target_route_changed: '验证时线路发生变化,请重新验证', + target_state_missing: '首轮响应缺少会话状态,无法验证续接', + target_state_changed: '两轮响应的会话状态不一致,请重新验证', + validating: '正在验证此账号与模型的借用线路', + candidate_ready: '来源线路已采集', + validated: '此账号与模型的借用线路已验证通过', + no_matched_cache: '此账号与模型尚无可用的借用线路', + }, states: { pending: '等待中', running: '执行中', complete: '完成', failed: '失败', incomplete: '未完整结束', cancelled: '已取消', skipped: '跳过', active: '正常', inactive: '未激活', disabled: '已禁用', error: '错误', validating: '验证中', valid: '验证通过', ready: '可用', expired: '已到期', cooldown: '冷却中', unavailable: '不可用', missing: '缺失', preparing: '准备中', waiting: '尚未准备', rejected: '验证未通过' } + }, + codexPelicanComparison: { + title: '鹈鹕对比', + description: '使用已经准备好的借用线路生成作品,并排观察不同账号和模型的表现。', + viewStatus: '查看借用状态', + refreshAvailability: '刷新线路状态', + viewPrompt: '查看题目', + noTargets: '尚未保存目标账号。请先在借用设置页选择账号,再准备借用线路。', + disabledHint: '借用功能当前关闭。启用并保存配置后,可以在借用状态页准备线路。', + results: '本次作品', + history: '历史记录', + routeReason: '线路原因', } } diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 7991112ab941..bf000300ec98 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -223,7 +223,9 @@ export default { auditLogs: '操作日志', systemPrompts: '系统提示词', reasoningRecovery: '推理恢复', - codexGatewayBorrow: 'Codex 网关借用', + codexGatewayBorrow: 'Codex 借用设置', + codexGatewayBorrowStatus: 'Codex 借用状态', + codexPelicanComparison: '鹈鹕对比', }, // Auth diff --git a/frontend/src/router/__tests__/codex-borrow-routes.spec.ts b/frontend/src/router/__tests__/codex-borrow-routes.spec.ts new file mode 100644 index 000000000000..9daa99222060 --- /dev/null +++ b/frontend/src/router/__tests__/codex-borrow-routes.spec.ts @@ -0,0 +1,106 @@ +import type { RouteRecordRaw } from 'vue-router' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +type GuardTarget = { + path: string + fullPath: string + name?: RouteRecordRaw['name'] + params: Record + meta: Record +} +type NavigationGuard = (to: GuardTarget, from: Record, next: ReturnType) => Promise + +const harness = vi.hoisted(() => ({ + routes: [] as RouteRecordRaw[], + guard: null as NavigationGuard | null, +})) +const authStore = vi.hoisted(() => ({ + checkAuth: vi.fn(), + isAuthenticated: true, + isAdmin: true, + isSimpleMode: false, + hasPendingAuthSession: false, +})) +const appStore = vi.hoisted(() => ({ + siteName: 'Sub2API', + backendModeEnabled: false, + publicSettingsLoaded: true, + cachedPublicSettings: { custom_menu_items: [] }, +})) + +vi.mock('vue-router', () => ({ + createWebHistory: vi.fn(() => ({})), + createRouter: vi.fn((options: { routes: RouteRecordRaw[] }) => { + harness.routes = options.routes + return { + beforeEach: vi.fn((guard: NavigationGuard) => { harness.guard = guard }), + afterEach: vi.fn(), + onError: vi.fn(), + } + }), +})) +vi.mock('@/stores/auth', () => ({ useAuthStore: () => authStore })) +vi.mock('@/stores/app', () => ({ useAppStore: () => appStore })) +vi.mock('@/stores/adminSettings', () => ({ + useAdminSettingsStore: () => ({ customMenuItems: [] }), +})) +vi.mock('@/stores/adminCompliance', () => ({ + useAdminComplianceStore: () => ({ initialized: true }), +})) +vi.mock('@/composables/useNavigationLoading', () => ({ + useNavigationLoadingState: () => ({ startNavigation: vi.fn(), endNavigation: vi.fn() }), +})) +vi.mock('@/composables/useRoutePrefetch', () => ({ + useRoutePrefetch: () => ({ triggerPrefetch: vi.fn() }), +})) +vi.mock('@/router/title', () => ({ resolveRouteDocumentTitle: () => 'Sub2API' })) + +const borrowRoutes = [ + { path: '/admin/codex-gateway-borrow', name: 'AdminCodexGatewayBorrow' }, + { path: '/admin/codex-gateway-borrow/status', name: 'AdminCodexGatewayBorrowStatus' }, + { path: '/admin/codex-pelican-comparison', name: 'AdminCodexPelicanComparison' }, +] + +async function navigate(path: string) { + const record = harness.routes.find(route => route.path === path) + if (!record || !harness.guard) throw new Error(`Missing route or guard for ${path}`) + const next = vi.fn() + await harness.guard({ path, fullPath: path, name: record.name, params: {}, meta: record.meta ?? {} }, {}, next) + return next +} + +describe('Codex borrow page routes', () => { + beforeAll(async () => { await import('@/router') }) + beforeEach(() => { + authStore.isAuthenticated = true + authStore.isAdmin = true + authStore.isSimpleMode = false + }) + + it.each(borrowRoutes)('keeps $path directly addressable and protected', ({ path, name }) => { + const record = harness.routes.find(route => route.path === path) + + expect(record?.name).toBe(name) + expect(record?.meta).toMatchObject({ requiresAuth: true, requiresAdmin: true }) + expect(record).not.toHaveProperty('redirect') + }) + + it.each(borrowRoutes)('allows admins to open $path in simple mode', async ({ path }) => { + authStore.isSimpleMode = true + const next = await navigate(path) + + expect(next).toHaveBeenCalledOnce() + expect(next).toHaveBeenCalledWith() + }) + + it.each(borrowRoutes)('blocks regular users from $path', async ({ path }) => { + authStore.isAdmin = false + expect(await navigate(path)).toHaveBeenCalledWith('/dashboard') + }) + + it.each(borrowRoutes)('requires sign-in for $path and preserves the return address', async ({ path }) => { + authStore.isAuthenticated = false + authStore.isAdmin = false + expect(await navigate(path)).toHaveBeenCalledWith({ path: '/login', query: { redirect: path } }) + }) +}) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index aee0cb43c659..edbedae45971 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -547,7 +547,19 @@ const routes: RouteRecordRaw[] = [ path: '/admin/codex-gateway-borrow', name: 'AdminCodexGatewayBorrow', component: () => import('@/views/admin/CodexGatewayBorrowView.vue'), - meta: { requiresAuth: true, requiresAdmin: true, title: 'Codex Gateway Borrow', titleKey: 'nav.codexGatewayBorrow' } + meta: { requiresAuth: true, requiresAdmin: true, title: 'Codex Borrow Settings', titleKey: 'nav.codexGatewayBorrow' } + }, + { + path: '/admin/codex-gateway-borrow/status', + name: 'AdminCodexGatewayBorrowStatus', + component: () => import('@/views/admin/CodexGatewayBorrowStatusView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: 'Codex Borrow Status', titleKey: 'nav.codexGatewayBorrowStatus' } + }, + { + path: '/admin/codex-pelican-comparison', + name: 'AdminCodexPelicanComparison', + component: () => import('@/views/admin/CodexPelicanComparisonView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: 'Pelican Comparison', titleKey: 'nav.codexPelicanComparison' } }, { path: '/admin/reasoning-recovery', diff --git a/frontend/src/views/admin/CodexGatewayBorrowStatusView.vue b/frontend/src/views/admin/CodexGatewayBorrowStatusView.vue new file mode 100644 index 000000000000..8b57d1f115fb --- /dev/null +++ b/frontend/src/views/admin/CodexGatewayBorrowStatusView.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/frontend/src/views/admin/CodexGatewayBorrowView.vue b/frontend/src/views/admin/CodexGatewayBorrowView.vue index 5131578ff5e5..4700171da0de 100644 --- a/frontend/src/views/admin/CodexGatewayBorrowView.vue +++ b/frontend/src/views/admin/CodexGatewayBorrowView.vue @@ -1,243 +1,128 @@