Merge pull request #410 from Entropy-Xu/codex/codex-image-progress-heartbeat

Add Codex image progress heartbeat
This commit is contained in:
fawney19
2026-05-10 17:57:33 +08:00
committed by GitHub
48 changed files with 3232 additions and 221 deletions

View File

@@ -63,6 +63,7 @@
:password-policy-level="systemConfig.password_policy_level"
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
:enable-format-conversion="systemConfig.enable_format_conversion"
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
:loading="basicConfigLoading"
:has-changes="hasBasicConfigChanges"
@save="saveBasicConfig"
@@ -72,6 +73,7 @@
@update:password-policy-level="systemConfig.password_policy_level = $event"
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
@update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event"
/>
<!-- 请求记录配置 -->

View File

@@ -150,6 +150,27 @@
</div>
</div>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="enable-openai-image-sync-heartbeat"
:checked="enableOpenaiImageSyncHeartbeat"
@update:checked="$emit('update:enableOpenaiImageSyncHeartbeat', $event)"
/>
<div>
<Label
for="enable-openai-image-sync-heartbeat"
class="cursor-pointer"
>
同步生图心跳
</Label>
<p class="text-xs text-muted-foreground">
开启后同步生图外层 HTTP 状态固定为 200上游失败需读取响应体 error.upstream_status
</p>
</div>
</div>
</div>
</div>
</CardSection>
</template>
@@ -173,6 +194,7 @@ defineProps<{
passwordPolicyLevel: string
autoDeleteExpiredKeys: boolean
enableFormatConversion: boolean
enableOpenaiImageSyncHeartbeat: boolean
loading: boolean
hasChanges: boolean
}>()
@@ -185,5 +207,6 @@ defineEmits<{
'update:passwordPolicyLevel': [value: string]
'update:autoDeleteExpiredKeys': [value: boolean]
'update:enableFormatConversion': [value: boolean]
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]
}>()
</script>

View File

@@ -19,6 +19,8 @@ export interface SystemConfig {
auto_delete_expired_keys: boolean
// 格式转换
enable_format_conversion: boolean
// 同步生图心跳
enable_openai_image_sync_heartbeat: boolean
// 请求记录
request_record_level: string
max_request_body_size: number
@@ -58,6 +60,8 @@ const CONFIG_KEYS = [
'auto_delete_expired_keys',
// 格式转换
'enable_format_conversion',
// 同步生图心跳
'enable_openai_image_sync_heartbeat',
// 请求记录
'request_record_level',
'max_request_body_size',
@@ -98,6 +102,8 @@ function createDefaultConfig(): SystemConfig {
auto_delete_expired_keys: false,
// 格式转换
enable_format_conversion: false,
// 同步生图心跳
enable_openai_image_sync_heartbeat: false,
// 请求记录
request_record_level: 'basic',
max_request_body_size: 1048576,
@@ -160,7 +166,8 @@ export function useSystemConfig() {
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
systemConfig.value.password_policy_level !== originalConfig.value.password_policy_level ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion ||
systemConfig.value.enable_openai_image_sync_heartbeat !== originalConfig.value.enable_openai_image_sync_heartbeat
)
})
@@ -340,6 +347,11 @@ export function useSystemConfig() {
value: systemConfig.value.enable_format_conversion,
description: '全局格式转换开关:开启时强制允许所有提供商的格式转换',
},
{
key: 'enable_openai_image_sync_heartbeat',
value: systemConfig.value.enable_openai_image_sync_heartbeat,
description: '同步生图心跳开关:开启后外层 HTTP 状态固定为 200上游失败写入响应体',
},
]
await Promise.all(
@@ -356,6 +368,8 @@ export function useSystemConfig() {
systemConfig.value.auto_delete_expired_keys
originalConfig.value.enable_format_conversion =
systemConfig.value.enable_format_conversion
originalConfig.value.enable_openai_image_sync_heartbeat =
systemConfig.value.enable_openai_image_sync_heartbeat
}
success('基础配置已保存')
} catch (err) {

View File

@@ -119,6 +119,7 @@
:is-open="detailModalOpen"
:request-id="selectedRequestId"
@close="detailModalOpen = false"
@request-state="handleDetailRequestState"
/>
</div>
</template>
@@ -129,6 +130,7 @@ import { useRoute } from 'vue-router'
import { useLocalStorage } from '@vueuse/core'
import { useAuthStore } from '@/stores/auth'
import { usageApi } from '@/api/usage'
import type { ImageProgress } from '@/api/requestTrace'
import { usersApi } from '@/api/users'
import { meApi } from '@/api/me'
import { dashboardApi } from '@/api/dashboard'
@@ -153,7 +155,7 @@ import {
isUsageUpstreamStream,
resolveDisplayRequestStatus,
} from '@/features/usage/utils/status'
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
import type { DateRangeParams, FilterStatusValue, RequestStatus } from '@/features/usage/types'
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
import { log } from '@/utils/logger'
import type { ActivityHeatmap } from '@/types/activity'
@@ -445,12 +447,16 @@ async function pollActiveRequests() {
const shouldApply = newRank >= currentRank
const updateHasFailureSignal =
(typeof update.status_code === 'number' && update.status_code >= 400) ||
(typeof update.error_message === 'string' && update.error_message.trim().length > 0)
(typeof update.error_message === 'string' && update.error_message.trim().length > 0) ||
update.image_progress?.phase === 'failed'
const shouldApplyData = shouldApply || updateHasFailureSignal
if (shouldApply && record.status !== update.status) {
record.status = update.status
}
if ('image_progress' in update) {
record.image_progress = update.image_progress ?? null
}
if (shouldApplyData) {
// 进行中状态也需要持续更新provider/key/TTFB 可能在 streaming 后才落库)
record.input_tokens = update.input_tokens
@@ -877,6 +883,63 @@ function showRequestDetail(id: string) {
detailModalOpen.value = true
}
function sameImageProgress(left?: ImageProgress | null, right?: ImageProgress | null): boolean {
if (!left && !right) return true
if (!left || !right) return false
return left.phase === right.phase &&
left.upstream_ttfb_ms === right.upstream_ttfb_ms &&
left.upstream_sse_frame_count === right.upstream_sse_frame_count &&
left.last_upstream_event === right.last_upstream_event &&
left.last_upstream_frame_at_unix_ms === right.last_upstream_frame_at_unix_ms &&
left.partial_image_count === right.partial_image_count &&
left.last_client_visible_event === right.last_client_visible_event &&
left.downstream_heartbeat_count === right.downstream_heartbeat_count &&
left.last_downstream_heartbeat_at_unix_ms === right.last_downstream_heartbeat_at_unix_ms &&
left.downstream_heartbeat_interval_ms === right.downstream_heartbeat_interval_ms
}
function handleDetailRequestState(update: {
id: string
status?: RequestStatus
statusCode?: number | null
responseTimeMs?: number | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
}) {
const record = currentRecords.value.find(record => record.id === update.id)
if (!record) return
const statusPriority: Record<RequestStatus, number> = {
pending: 0,
streaming: 1,
completed: 2,
failed: 2,
cancelled: 2,
}
if (update.status) {
const currentRank = record.status ? statusPriority[record.status] : 0
const nextRank = statusPriority[update.status]
if (nextRank >= currentRank) {
record.status = update.status
}
}
if ('statusCode' in update) {
record.status_code = update.statusCode ?? undefined
}
if ('responseTimeMs' in update && update.responseTimeMs != null) {
record.response_time_ms = update.responseTimeMs
}
if ('imageProgress' in update) {
const nextProgress = update.imageProgress ?? null
if (!sameImageProgress(record.image_progress, nextProgress)) {
record.image_progress = nextProgress
}
}
if ('errorMessage' in update) {
record.error_message = update.errorMessage ?? undefined
}
}
function prefetchRequestDetail(id: string) {
if (!isAdminPage.value) return
void dashboardApi.prefetchRequestDetail(id).catch(error => {