From 1353d76e0763486a797ec096ba10266fc6512f47 Mon Sep 17 00:00:00 2001 From: AAEE86 Date: Fri, 31 Jul 2026 10:59:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Responses=20WebSocket=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8E=E7=94=A8=E9=87=8F=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provider 表单支持开启 Responses WebSocket;用量列表、状态与详情 区分 WebSocket 请求。 --- frontend/src/api/endpoints/providers.ts | 3 +++ frontend/src/api/endpoints/types/provider.ts | 6 ++++++ frontend/src/api/me.ts | 2 ++ frontend/src/api/usage.ts | 2 ++ .../components/ProviderFormDialog.vue | 20 +++++++++++++++++++ ...iderFormDialog.responses-websocket.spec.ts | 18 +++++++++++++++++ .../usage/components/UsageRecordsTable.vue | 17 ++++++++++++++++ .../__tests__/UsageRecordsTable.spec.ts | 11 ++++++++++ .../usage/composables/useUsageData.ts | 1 + frontend/src/features/usage/types.ts | 1 + .../usage/utils/__tests__/status.spec.ts | 7 +++++++ frontend/src/features/usage/utils/status.ts | 6 ++++++ frontend/src/views/shared/Usage.vue | 3 +++ 13 files changed, 97 insertions(+) create mode 100644 frontend/src/features/providers/components/__tests__/ProviderFormDialog.responses-websocket.spec.ts diff --git a/frontend/src/api/endpoints/providers.ts b/frontend/src/api/endpoints/providers.ts index eb8c590f4..4df9d509e 100644 --- a/frontend/src/api/endpoints/providers.ts +++ b/frontend/src/api/endpoints/providers.ts @@ -55,6 +55,7 @@ function normalizeProviderSummary( kiro_simulated_cache_enabled: provider.kiro_simulated_cache_enabled ?? false, max_transfer_count: provider.max_transfer_count ?? 0, max_transfer_timeout_seconds: provider.max_transfer_timeout_seconds ?? 0, + responses_websocket_enabled: provider.responses_websocket_enabled ?? false, } } @@ -115,6 +116,7 @@ export async function updateProvider( website: string provider_priority: number keep_priority_on_conversion: boolean + responses_websocket_enabled: boolean billing_type: 'monthly_quota' | 'pay_as_you_go' | 'free_tier' monthly_quota_usd: number quota_reset_day: number @@ -158,6 +160,7 @@ export async function createProvider( quota_expires_at?: string provider_priority?: number keep_priority_on_conversion?: boolean + responses_websocket_enabled?: boolean is_active?: boolean max_retries?: number max_transfer_count?: number diff --git a/frontend/src/api/endpoints/types/provider.ts b/frontend/src/api/endpoints/types/provider.ts index 1aadc704a..e2b6980b4 100644 --- a/frontend/src/api/endpoints/types/provider.ts +++ b/frontend/src/api/endpoints/types/provider.ts @@ -185,9 +185,14 @@ export interface CodexProviderConfig { [key: string]: unknown } +export interface ResponsesWebSocketProviderConfig { + enabled: boolean +} + export interface ProviderConfig { chat_pii_redaction?: ChatPiiRedactionProviderConfig codex?: CodexProviderConfig + responses_websocket?: ResponsesWebSocketProviderConfig pool_advanced?: PoolAdvancedConfig failover_rules?: FailoverRulesConfig claude_code_advanced?: ClaudeCodeAdvancedConfig @@ -913,6 +918,7 @@ export interface ProviderWithEndpointsSummary { ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter) codex_fingerprint_convergence_enabled?: boolean kiro_simulated_cache_enabled?: boolean + responses_websocket_enabled?: boolean ops_quota_alert_enabled?: boolean created_at: string updated_at: string diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index e91dbea7f..108fb8ffc 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -73,6 +73,7 @@ export interface UsageRecordDetail { updated_at?: string | null response_time_updated_at?: string | null is_stream: boolean + is_websocket?: boolean upstream_is_stream?: boolean client_requested_stream?: boolean client_is_stream?: boolean @@ -370,6 +371,7 @@ export const meApi = { api_format?: string | null endpoint_api_format?: string | null is_stream?: boolean | null + is_websocket?: boolean | null upstream_is_stream?: boolean | null client_requested_stream?: boolean | null client_is_stream?: boolean | null diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 425a78c26..0028146e7 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -33,6 +33,7 @@ export interface UsageRecord { first_byte_time_ms?: number | null end_to_end_time_ms?: number | null end_to_end_first_byte_time_ms?: number | null + is_websocket?: boolean created_at: string updated_at?: string | null response_time_updated_at?: string | null @@ -570,6 +571,7 @@ export const usageApi = { api_format?: string | null endpoint_api_format?: string | null is_stream?: boolean | null + is_websocket?: boolean | null upstream_is_stream?: boolean | null client_requested_stream?: boolean | null client_is_stream?: boolean | null diff --git a/frontend/src/features/providers/components/ProviderFormDialog.vue b/frontend/src/features/providers/components/ProviderFormDialog.vue index 74edf1799..387823d55 100644 --- a/frontend/src/features/providers/components/ProviderFormDialog.vue +++ b/frontend/src/features/providers/components/ProviderFormDialog.vue @@ -357,6 +357,19 @@ /> +
+
+ {{ legacyT('Responses WebSocket 模式') }} +

+ {{ legacyT('允许此提供商处理标准 Responses API WebSocket 请求。仅在已验证兼容性后启用。') }} +

+
+ +
+
{{ legacyT('敏感信息保护') }} @@ -481,6 +494,8 @@ const form = ref({ codex_fingerprint_convergence_enabled: false, // Kiro 专属配置 kiro_simulated_cache_enabled: false, + // Responses WebSocket 配置 + responses_websocket_enabled: false, }) // 重置表单 @@ -513,6 +528,8 @@ function resetForm() { codex_fingerprint_convergence_enabled: false, // Kiro 专属配置 kiro_simulated_cache_enabled: false, + // Responses WebSocket 配置 + responses_websocket_enabled: false, } } @@ -549,6 +566,8 @@ function loadProviderData() { codex_fingerprint_convergence_enabled: props.provider.codex_fingerprint_convergence_enabled ?? false, // Kiro 专属配置 kiro_simulated_cache_enabled: props.provider.kiro_simulated_cache_enabled ?? false, + // Responses WebSocket 配置 + responses_websocket_enabled: props.provider.responses_websocket_enabled ?? false, } } @@ -608,6 +627,7 @@ const handleSubmit = async () => { quota_last_reset_at: quotaLastResetAt, quota_expires_at: quotaExpiresAt, keep_priority_on_conversion: form.value.keep_priority_on_conversion, + responses_websocket_enabled: form.value.responses_websocket_enabled, is_active: form.value.is_active, // 请求配置 max_retries: form.value.max_retries ?? undefined, diff --git a/frontend/src/features/providers/components/__tests__/ProviderFormDialog.responses-websocket.spec.ts b/frontend/src/features/providers/components/__tests__/ProviderFormDialog.responses-websocket.spec.ts new file mode 100644 index 000000000..1e38a7543 --- /dev/null +++ b/frontend/src/features/providers/components/__tests__/ProviderFormDialog.responses-websocket.spec.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +function readSource(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8') +} + +describe('ProviderFormDialog Responses WebSocket switch', () => { + it('displays and submits the switch for every provider type', () => { + const source = readSource('src/features/providers/components/ProviderFormDialog.vue') + + expect(source).toContain('Responses WebSocket 模式') + expect(source).toContain('responses_websocket_enabled') + expect(source).toContain('responses_websocket_enabled: form.value.responses_websocket_enabled') + expect(source).not.toContain("v-if=\"form.provider_type === 'codex'\"") + }) +}) diff --git a/frontend/src/features/usage/components/UsageRecordsTable.vue b/frontend/src/features/usage/components/UsageRecordsTable.vue index ea849110a..d07b283b2 100644 --- a/frontend/src/features/usage/components/UsageRecordsTable.vue +++ b/frontend/src/features/usage/components/UsageRecordsTable.vue @@ -277,6 +277,14 @@ > 取消 + + WS + 已取消 + + WS + { expect(root.textContent).toContain('gpt-5') }) + it('shows WS in the type column for completed WebSocket usage records', () => { + const root = mountUsageRecordsTable([buildRecord({ + is_websocket: true, + status: 'completed', + })]) + + const badges = [...root.querySelectorAll('[data-usage-transport="websocket"]')] + expect(badges.length).toBeGreaterThan(0) + expect(badges.every(badge => badge.textContent?.trim() === 'WS')).toBe(true) + }) + it('shows reasoning effort next to the model name', () => { const root = mountUsageRecordsTable([buildRecord({ requested_reasoning_effort: 'xhigh', diff --git a/frontend/src/features/usage/composables/useUsageData.ts b/frontend/src/features/usage/composables/useUsageData.ts index 5103efd71..2d68c340f 100644 --- a/frontend/src/features/usage/composables/useUsageData.ts +++ b/frontend/src/features/usage/composables/useUsageData.ts @@ -647,6 +647,7 @@ export function useUsageData(options: UseUsageDataOptions) { ? (record.image_progress ?? existing.image_progress) : existing.image_progress, is_stream: upstreamIsStream, + is_websocket: mergeBooleanTrueWins(existing.is_websocket, record.is_websocket), upstream_is_stream: upstreamIsStream, client_requested_stream: clientRequestedStream, client_is_stream: clientIsStream, diff --git a/frontend/src/features/usage/types.ts b/frontend/src/features/usage/types.ts index cb7433876..c13d0176d 100644 --- a/frontend/src/features/usage/types.ts +++ b/frontend/src/features/usage/types.ts @@ -120,6 +120,7 @@ export interface UsageRecord { end_to_end_time_ms?: number | null // 客户端从请求进入网关到完成的总耗时 end_to_end_first_byte_time_ms?: number | null // 客户端从请求进入网关到首字节的耗时 is_stream: boolean + is_websocket?: boolean upstream_is_stream?: boolean client_requested_stream?: boolean client_is_stream?: boolean diff --git a/frontend/src/features/usage/utils/__tests__/status.spec.ts b/frontend/src/features/usage/utils/__tests__/status.spec.ts index 93cf8e886..a94629b33 100644 --- a/frontend/src/features/usage/utils/__tests__/status.spec.ts +++ b/frontend/src/features/usage/utils/__tests__/status.spec.ts @@ -6,6 +6,7 @@ import { hasUsageRetry, isUsageRecordFailed, isUsageRecordSuccessful, + isUsageWebSocket, mapRequestStatusToTimelineStatus, normalizeRequestStatus, resolveDisplayRequestStatus, @@ -177,6 +178,12 @@ describe('usage status helpers', () => { expect(hasUsageRetry(buildUsageRecord({ has_retry: undefined }))).toBe(false) }) + it('recognizes persisted WebSocket usage records', () => { + expect(isUsageWebSocket(buildUsageRecord({ is_websocket: true }))).toBe(true) + expect(isUsageWebSocket(buildUsageRecord({ is_websocket: false }))).toBe(false) + expect(isUsageWebSocket(buildUsageRecord({ is_websocket: undefined }))).toBe(false) + }) + it('prefers symmetric stream aliases when present', () => { expect(formatUsageStreamLabel(buildUsageRecord({ is_stream: true, diff --git a/frontend/src/features/usage/utils/status.ts b/frontend/src/features/usage/utils/status.ts index 9ed987fc2..e93f2b948 100644 --- a/frontend/src/features/usage/utils/status.ts +++ b/frontend/src/features/usage/utils/status.ts @@ -49,6 +49,12 @@ export function hasUsageRetry( return record.has_retry === true } +export function isUsageWebSocket( + record: Pick +): boolean { + return record.is_websocket === true +} + export function resolveUsageStreamModes( record: Pick< UsageRecord, diff --git a/frontend/src/views/shared/Usage.vue b/frontend/src/views/shared/Usage.vue index 70a64ae20..92e313732 100644 --- a/frontend/src/views/shared/Usage.vue +++ b/frontend/src/views/shared/Usage.vue @@ -574,6 +574,9 @@ async function pollActiveRequests() { record.is_stream = update.is_stream record.upstream_is_stream = update.is_stream } + if (typeof update.is_websocket === 'boolean') { + record.is_websocket = record.is_websocket === true || update.is_websocket + } if (typeof update.client_is_stream === 'boolean') { record.client_is_stream = update.client_is_stream record.client_requested_stream = update.client_is_stream