feat: 优化用量状态同步机制和 OpenAI CLI 流式转换

- 增加状态回退保护,防止异步响应覆盖已知状态
- 添加轮询并发保护 (pollInFlight),避免重复请求
- 支持 cancelled 状态筛选和显示
- 前端 mergeRecordStatus 保护活跃记录状态
- 后端 streaming 状态同步更新改为使用当前 DB 会话
- OpenAI CLI 流式转换增加工具调用事件支持
- 轮询接口新增 target_model 字段返回
- 修复迁移脚本 inspector 缓存问题,改用 information_schema
This commit is contained in:
fawney19
2026-02-04 03:17:55 +08:00
parent f3e2f84b38
commit 7f09f191a1
17 changed files with 957 additions and 84 deletions

View File

@@ -213,6 +213,7 @@ export const usageApi = {
api_format?: string | null
endpoint_api_format?: string | null
has_format_conversion?: boolean | null
target_model?: string | null
}>
}> {
const params = ids?.length ? { ids: ids.join(',') } : {}

View File

@@ -1,11 +1,12 @@
<template>
<Card class="overflow-hidden">
<div class="px-3 py-2 border-b">
<Card class="overflow-hidden flex flex-col">
<div class="px-3 py-2 border-b flex-shrink-0">
<h3 class="text-sm font-medium">
按API格式分析
</h3>
</div>
<Table class="text-sm">
<div class="overflow-auto max-h-[320px]">
<Table class="text-sm">
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
@@ -64,6 +65,7 @@
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</template>

View File

@@ -1,11 +1,12 @@
<template>
<Card class="overflow-hidden">
<div class="px-3 py-2 border-b">
<Card class="overflow-hidden flex flex-col">
<div class="px-3 py-2 border-b flex-shrink-0">
<h3 class="text-sm font-medium">
按模型分析
</h3>
</div>
<Table class="text-sm">
<div class="overflow-auto max-h-[320px]">
<Table class="text-sm">
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
@@ -64,6 +65,7 @@
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</template>

View File

@@ -1,11 +1,12 @@
<template>
<Card class="overflow-hidden">
<div class="px-3 py-2 border-b">
<Card class="overflow-hidden flex flex-col">
<div class="px-3 py-2 border-b flex-shrink-0">
<h3 class="text-sm font-medium">
按提供商分析
</h3>
</div>
<Table class="text-sm">
<div class="overflow-auto max-h-[320px]">
<Table class="text-sm">
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
@@ -70,6 +71,7 @@
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</template>

View File

@@ -134,18 +134,15 @@
<SelectItem value="standard">
标准
</SelectItem>
<SelectItem value="pending">
等待中
</SelectItem>
<SelectItem value="streaming">
传输中
</SelectItem>
<SelectItem value="completed">
完成
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
</SelectContent>
</Select>
@@ -415,6 +412,13 @@
>
失败
</Badge>
<Badge
v-else-if="record.status === 'cancelled'"
variant="outline"
class="whitespace-nowrap border-amber-500/50 text-amber-600 dark:text-amber-400"
>
已取消
</Badge>
<Badge
v-else-if="record.is_stream"
variant="secondary"
@@ -732,7 +736,8 @@ function getApiFormatTooltip(record: UsageRecord): string {
// 如果发生了格式转换或同族格式差异,显示详细信息
if (shouldShowFormatConversion(record)) {
const endpointDisplayFormat = formatApiFormat(record.endpoint_api_format!)
const endpointApiFormat = record.endpoint_api_format ?? record.api_format
const endpointDisplayFormat = formatApiFormat(endpointApiFormat)
const conversionType = record.has_format_conversion ? '格式转换' : '格式兼容(无需转换)'
return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n${conversionType}`
}

View File

@@ -164,7 +164,9 @@ export function useUsageData(options: UseUsageDataOptions) {
}))
// 用户页面:记录直接从 userData 获取(数量较少)
currentRecords.value = (userData.records || []) as UsageRecord[]
// 使用 mergeRecordStatus 保护已有的活跃状态,避免轮询更新被覆盖
const nextRecords = (userData.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = currentRecords.value.length
// 从记录中提取筛选选项和 API 格式统计
@@ -272,12 +274,14 @@ export function useUsageData(options: UseUsageDataOptions) {
}
const response = await usageApi.getAllUsageRecords(params)
currentRecords.value = (response.records || []) as UsageRecord[]
const nextRecords = (response.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = response.total || 0
} else {
// 用户页面:使用用户 API
const userData = await meApi.getUsage(params)
currentRecords.value = (userData.records || []) as UsageRecord[]
const nextRecords = (userData.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = userData.pagination?.total || currentRecords.value.length
}
} catch (error) {
@@ -289,6 +293,79 @@ export function useUsageData(options: UseUsageDataOptions) {
}
}
function mergeRecordStatus(
current: UsageRecord[],
next: UsageRecord[]
): UsageRecord[] {
if (!current.length) return next
const statusPriority: Record<string, number> = {
pending: 0,
streaming: 1,
completed: 2,
failed: 2,
cancelled: 2
}
const currentById = new Map<string, UsageRecord>(
current.map(record => [record.id, record])
)
return next.map(record => {
const existing = currentById.get(record.id)
if (!existing) return record
// 确定是否需要保护 status避免刷新把已知状态覆盖为 undefined 或回退)
const hasExistingStatus = typeof existing.status === 'string' && existing.status.length > 0
const hasNextStatus = typeof record.status === 'string' && record.status.length > 0
const currentRank = hasExistingStatus ? (statusPriority[existing.status] ?? -1) : -1
const nextRank = hasNextStatus ? (statusPriority[record.status] ?? -1) : -1
const statusProgressed = hasNextStatus && (
!hasExistingStatus ||
nextRank > currentRank ||
(nextRank === currentRank && existing.status === record.status)
)
const mergedStatus = statusProgressed ? record.status : existing.status
const protectStatus = mergedStatus !== record.status
// 确定是否需要保护 provider避免 pending/unknown 覆盖已有的正确值)
const isPendingProvider = !record.provider || record.provider === 'pending' || record.provider === 'unknown'
const hasValidExistingProvider = existing.provider && existing.provider !== 'pending' && existing.provider !== 'unknown'
const protectProvider = isPendingProvider && hasValidExistingProvider
// 如果需要保护状态,说明本地数据比后端更新,应该保留本地的所有实时更新字段
if (protectStatus) {
return {
...record,
// 保留本地的状态和所有通过轮询更新的字段
status: mergedStatus,
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
input_tokens: existing.input_tokens || record.input_tokens,
output_tokens: existing.output_tokens || record.output_tokens,
cache_creation_input_tokens: existing.cache_creation_input_tokens ?? record.cache_creation_input_tokens,
cache_read_input_tokens: existing.cache_read_input_tokens ?? record.cache_read_input_tokens,
cost: existing.cost || record.cost,
actual_cost: existing.actual_cost ?? record.actual_cost,
response_time_ms: existing.response_time_ms ?? record.response_time_ms,
first_byte_time_ms: existing.first_byte_time_ms ?? record.first_byte_time_ms,
api_format: existing.api_format || record.api_format,
endpoint_api_format: existing.endpoint_api_format || record.endpoint_api_format,
has_format_conversion: existing.has_format_conversion ?? record.has_format_conversion,
api_key_name: existing.api_key_name || record.api_key_name,
rate_multiplier: existing.rate_multiplier ?? record.rate_multiplier,
target_model: existing.target_model || record.target_model
}
}
// 只需要保护 provider
if (protectProvider) {
return {
...record,
provider: existing.provider
}
}
return record
})
}
// 刷新所有数据
async function refreshData(dateRange?: DateRangeParams) {
await loadStats(dateRange)

View File

@@ -70,10 +70,18 @@ export function useUsageFilters(options: UseUsageFiltersOptions) {
records = records.filter(record =>
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
)
} else if (filterStatus.value === 'error') {
} else if (filterStatus.value === 'active') {
records = records.filter(record =>
record.error_message || (record.status_code && record.status_code >= 400)
record.status === 'pending' || record.status === 'streaming'
)
} else if (filterStatus.value === 'failed') {
records = records.filter(record =>
record.status === 'failed' ||
(record.status_code && record.status_code >= 400) ||
record.error_message
)
} else if (filterStatus.value === 'cancelled') {
records = records.filter(record => record.status === 'cancelled')
}
}

View File

@@ -37,7 +37,7 @@ export class OpenAIParser implements ApiFormatParser {
readonly displayName = 'OpenAI'
/**
* 检测是否为 OpenAI 格式
* 检测是否为 OpenAI 格式(包括 Chat Completions 和 CLI/Responses API
*/
detect(requestBody: any, responseBody: any, hint?: string): number {
// 1. 后端提示优先
@@ -52,7 +52,12 @@ export class OpenAIParser implements ApiFormatParser {
if (model.includes('gpt') || model.includes('o1') || model.includes('o3')) return 95
// 3. 检查请求体结构
if (!requestBody?.messages || !Array.isArray(requestBody.messages)) {
// OpenAI CLI (Responses API) 使用 input 字段
const isCliFormat = requestBody?.input !== undefined || requestBody?.instructions !== undefined
// OpenAI Chat Completions 使用 messages 数组
const isChatFormat = requestBody?.messages && Array.isArray(requestBody.messages)
if (!isCliFormat && !isChatFormat) {
return 0
}
@@ -62,7 +67,11 @@ export class OpenAIParser implements ApiFormatParser {
: responseBody
if (respBody) {
// OpenAI 响应特征: choices 数组
// OpenAI CLI 响应特征: type 字段为 response.* 格式
if (this.isCliResponseEvent(respBody)) {
return 95
}
// OpenAI Chat Completions 响应特征: choices 数组
if (respBody.choices || respBody.object?.includes('chat.completion')) {
return 90
}
@@ -73,6 +82,10 @@ export class OpenAIParser implements ApiFormatParser {
}
// 5. 检查 OpenAI 特有的请求结构
if (isCliFormat) {
return 80
}
// OpenAI 的 system 是在 messages 数组中作为 role: system
const hasSystemInMessages = requestBody.messages?.some(
(m: any) => m.role === 'system'
@@ -85,13 +98,36 @@ export class OpenAIParser implements ApiFormatParser {
}
/**
* 解析请求体
* 检查是否为 OpenAI CLI (Responses API) 的响应事件
*/
private isCliResponseEvent(chunk: any): boolean {
const type = chunk?.type
if (typeof type !== 'string') return false
return type.startsWith('response.') || chunk?.object === 'response'
}
/**
* 解析请求体(支持 Chat Completions 和 CLI/Responses API 格式)
*/
parseRequest(requestBody: any): ParsedConversation {
if (!requestBody) {
return createEmptyConversation('openai', '无请求体')
}
// 检测是否为 CLI 格式
const isCliFormat = requestBody.input !== undefined || requestBody.instructions !== undefined
if (isCliFormat) {
return this.parseCliRequest(requestBody)
}
return this.parseChatRequest(requestBody)
}
/**
* 解析 OpenAI Chat Completions 请求
*/
private parseChatRequest(requestBody: any): ParsedConversation {
try {
const result: ParsedConversation = {
messages: [],
@@ -127,13 +163,128 @@ export class OpenAIParser implements ApiFormatParser {
}
/**
* 解析响应体
* 解析 OpenAI CLI (Responses API) 请求
*
* CLI 格式特点:
* - 使用 input 字段(可以是字符串、消息数组或对象)
* - 使用 instructions 字段作为系统指令
*/
private parseCliRequest(requestBody: any): ParsedConversation {
try {
const result: ParsedConversation = {
messages: [],
isStream: requestBody.stream === true,
apiFormat: 'openai',
model: requestBody.model,
}
// 处理 instructions系统指令
if (requestBody.instructions) {
result.system = requestBody.instructions
}
// 处理 input
const input = requestBody.input
if (typeof input === 'string') {
// 简单字符串输入
result.messages.push(createMessage('user', [createTextBlock(input)]))
} else if (Array.isArray(input)) {
// 消息数组
for (const item of input) {
const parsedMsg = this.parseCliInputItem(item)
if (parsedMsg) {
result.messages.push(parsedMsg)
}
}
} else if (input?.messages && Array.isArray(input.messages)) {
// 包装在对象中的消息数组
for (const item of input.messages) {
const parsedMsg = this.parseCliInputItem(item)
if (parsedMsg) {
result.messages.push(parsedMsg)
}
}
}
return result
} catch (e) {
return createEmptyConversation('openai', `CLI 格式解析失败: ${e}`)
}
}
/**
* 解析 CLI 格式的单个输入项
*/
private parseCliInputItem(item: any): ParsedMessage | null {
if (!item) return null
const itemType = item.type
// 标准消息(有 role 字段)
if (itemType === 'message' || item.role) {
const role = this.mapRole(item.role)
const contentBlocks: ContentBlock[] = []
const content = item.content
if (typeof content === 'string') {
contentBlocks.push(createTextBlock(content))
} else if (Array.isArray(content)) {
for (const part of content) {
if (part.type === 'input_text' || part.type === 'output_text' || part.type === 'text') {
contentBlocks.push(createTextBlock(part.text || ''))
}
}
}
if (contentBlocks.length === 0) return null
return createMessage(role, contentBlocks)
}
// function_call -> 工具调用
if (itemType === 'function_call') {
const toolId = item.call_id || item.id || ''
const toolName = item.name || ''
const args = item.arguments || '{}'
return createMessage('assistant', [createToolUseBlock(toolId, toolName, args)])
}
// function_call_output -> 工具结果
if (itemType === 'function_call_output') {
const toolUseId = item.call_id || item.id || ''
const output = typeof item.output === 'string'
? item.output
: JSON.stringify(item.output, null, 2)
return createMessage('tool', [createToolResultBlock(toolUseId, output)])
}
return null
}
/**
* 解析响应体(支持 Chat Completions 和 CLI/Responses API 格式)
*/
parseResponse(responseBody: any): ParsedConversation {
if (!responseBody) {
return createEmptyConversation('openai', '无响应体')
}
// 检测是否为 CLI 格式
const isCliFormat = this.isCliResponseEvent(responseBody) ||
responseBody.object === 'response' ||
responseBody.output !== undefined
if (isCliFormat) {
return this.parseCliResponse(responseBody)
}
return this.parseChatResponse(responseBody)
}
/**
* 解析 OpenAI Chat Completions 响应
*/
private parseChatResponse(responseBody: any): ParsedConversation {
try {
const result: ParsedConversation = {
messages: [],
@@ -175,13 +326,70 @@ export class OpenAIParser implements ApiFormatParser {
}
/**
* 解析流式响应
* 解析 OpenAI CLI (Responses API) 响应
*
* CLI 响应格式: { output: [{ type: "message", content: [...] }] }
*/
private parseCliResponse(responseBody: any): ParsedConversation {
try {
const result: ParsedConversation = {
messages: [],
isStream: false,
apiFormat: 'openai',
model: responseBody.model,
}
const output = responseBody.output
if (!Array.isArray(output)) {
return result
}
for (const item of output) {
if (item?.type === 'message') {
const contentBlocks: ContentBlock[] = []
if (Array.isArray(item.content)) {
for (const content of item.content) {
if (content?.type === 'output_text' && content?.text) {
contentBlocks.push(createTextBlock(content.text))
}
}
}
if (contentBlocks.length > 0) {
result.messages.push(createMessage('assistant', contentBlocks))
}
}
}
return result
} catch (e) {
return createEmptyConversation('openai', `CLI 格式解析失败: ${e}`)
}
}
/**
* 解析流式响应(支持 Chat Completions 和 CLI/Responses API 格式)
*/
parseStreamResponse(chunks: any[]): ParsedConversation {
if (!chunks || chunks.length === 0) {
return createEmptyConversation('openai', '无响应数据')
}
// 检测是否为 CLI 格式
const isCliFormat = chunks.some(chunk => this.isCliResponseEvent(chunk))
if (isCliFormat) {
return this.parseCliStreamResponse(chunks)
}
return this.parseChatStreamResponse(chunks)
}
/**
* 解析 OpenAI Chat Completions 流式响应
*/
private parseChatStreamResponse(chunks: any[]): ParsedConversation {
try {
const result: ParsedConversation = {
messages: [],
@@ -252,6 +460,126 @@ export class OpenAIParser implements ApiFormatParser {
}
}
/**
* 解析 OpenAI CLI (Responses API) 流式响应
*
* 支持的事件类型:
* - response.created: 响应创建
* - response.output_text.delta: 文本增量
* - response.completed: 响应完成(包含完整响应和 usage
* - response.function_call_arguments.delta: 函数调用参数增量
*/
private parseCliStreamResponse(chunks: any[]): ParsedConversation {
try {
const result: ParsedConversation = {
messages: [],
isStream: true,
apiFormat: 'openai',
}
const textParts: string[] = []
const toolCalls = new Map<string, { name: string; id: string; args: string[] }>()
let currentToolId = ''
let currentToolName = ''
for (const chunk of chunks) {
const eventType = chunk.type
// 从 response.created 或 response.completed 提取模型名
if (!result.model) {
const response = chunk.response
if (response?.model) {
result.model = response.model
}
}
// 处理文本增量: response.output_text.delta
if (eventType === 'response.output_text.delta') {
const delta = chunk.delta
if (typeof delta === 'string') {
textParts.push(delta)
} else if (delta?.text) {
textParts.push(delta.text)
}
continue
}
// 处理函数调用输出项添加: response.output_item.added
if (eventType === 'response.output_item.added') {
const item = chunk.item
if (item?.type === 'function_call') {
currentToolId = item.call_id || item.id || ''
currentToolName = item.name || ''
if (currentToolId && !toolCalls.has(currentToolId)) {
toolCalls.set(currentToolId, {
name: currentToolName,
id: currentToolId,
args: [],
})
}
}
continue
}
// 处理函数调用参数增量: response.function_call_arguments.delta
if (eventType === 'response.function_call_arguments.delta') {
const delta = chunk.delta
if (delta && currentToolId && toolCalls.has(currentToolId)) {
toolCalls.get(currentToolId)!.args.push(delta)
}
continue
}
// 处理完成事件: response.completed
// 如果之前没有收集到文本,从完成事件中提取
if (eventType === 'response.completed') {
const response = chunk.response
if (response?.model && !result.model) {
result.model = response.model
}
// 从 output 中提取文本(备用方案)
if (textParts.length === 0 && response?.output) {
for (const item of response.output) {
if (item?.type === 'message' && item?.content) {
for (const content of item.content) {
if (content?.type === 'output_text' && content?.text) {
textParts.push(content.text)
}
}
}
}
}
continue
}
}
const contentBlocks: ContentBlock[] = []
// 文本内容
if (textParts.length > 0) {
contentBlocks.push(createTextBlock(textParts.join('')))
}
// 工具调用
for (const [, call] of toolCalls) {
contentBlocks.push(createToolUseBlock(
call.id,
call.name,
call.args.join('')
))
}
if (contentBlocks.length > 0) {
result.messages.push(createMessage('assistant', contentBlocks))
}
return result
} catch (e) {
return createEmptyConversation('openai', `CLI 格式解析失败: ${e}`)
}
}
/**
* 解析单条消息
*/
@@ -328,13 +656,27 @@ export class OpenAIParser implements ApiFormatParser {
// ============================================================
/**
* 渲染请求体
* 渲染请求体(支持 Chat Completions 和 CLI/Responses API 格式)
*/
renderRequest(requestBody: any): RenderResult {
if (!requestBody) {
return createEmptyRenderResult('无请求体')
}
// 检测是否为 CLI 格式
const isCliFormat = requestBody.input !== undefined || requestBody.instructions !== undefined
if (isCliFormat) {
return this.renderCliRequest(requestBody)
}
return this.renderChatRequest(requestBody)
}
/**
* 渲染 OpenAI Chat Completions 请求
*/
private renderChatRequest(requestBody: any): RenderResult {
try {
const blocks: RenderBlock[] = []
const isStream = requestBody.stream === true
@@ -366,7 +708,104 @@ export class OpenAIParser implements ApiFormatParser {
}
/**
* 渲染响应体
* 渲染 OpenAI CLI (Responses API) 请求
*/
private renderCliRequest(requestBody: any): RenderResult {
try {
const blocks: RenderBlock[] = []
const isStream = requestBody.stream === true
// 渲染 instructions系统指令
if (requestBody.instructions) {
blocks.push(createMessageBlock('system', [
createTextRenderBlock(requestBody.instructions),
], { roleLabel: 'Instructions' }))
}
// 渲染 input
const input = requestBody.input
if (typeof input === 'string') {
// 简单字符串输入
blocks.push(createMessageBlock('user', [
createTextRenderBlock(input),
], { roleLabel: 'User' }))
} else if (Array.isArray(input)) {
// 消息数组
for (const item of input) {
const msgBlock = this.renderCliInputItem(item)
if (msgBlock) {
blocks.push(msgBlock)
}
}
} else if (input?.messages && Array.isArray(input.messages)) {
// 包装在对象中的消息数组
for (const item of input.messages) {
const msgBlock = this.renderCliInputItem(item)
if (msgBlock) {
blocks.push(msgBlock)
}
}
}
return { blocks, isStream }
} catch (e) {
return createEmptyRenderResult(`CLI 格式渲染失败: ${e}`)
}
}
/**
* 渲染 CLI 格式的单个输入项
*/
private renderCliInputItem(item: any): RenderBlock | null {
if (!item) return null
const itemType = item.type
// 标准消息
if (itemType === 'message' || item.role) {
const role = this.mapRole(item.role)
const contentBlocks: RenderBlock[] = []
const content = item.content
if (typeof content === 'string') {
contentBlocks.push(createTextRenderBlock(content))
} else if (Array.isArray(content)) {
for (const part of content) {
if (part.type === 'input_text' || part.type === 'output_text' || part.type === 'text') {
contentBlocks.push(createTextRenderBlock(part.text || ''))
}
}
}
if (contentBlocks.length === 0) return null
return createMessageBlock(role, contentBlocks, { roleLabel: this.getRoleLabel(role) })
}
// function_call -> 工具调用
if (itemType === 'function_call') {
const toolName = item.name || '工具调用'
const args = this.formatJson(item.arguments)
return createMessageBlock('assistant', [
createToolUseRenderBlock(toolName, args, item.call_id || item.id),
], { roleLabel: 'Assistant', badges: [createBadgeBlock('工具调用', 'outline')] })
}
// function_call_output -> 工具结果
if (itemType === 'function_call_output') {
const output = typeof item.output === 'string'
? item.output
: JSON.stringify(item.output, null, 2)
return createMessageBlock('tool', [
createToolResultRenderBlock(output),
], { roleLabel: 'Tool', badges: [createBadgeBlock('工具结果', 'outline')] })
}
return null
}
/**
* 渲染响应体(支持 Chat Completions 和 CLI/Responses API 格式)
*/
renderResponse(responseBody: any): RenderResult {
if (!responseBody) {
@@ -378,6 +817,22 @@ export class OpenAIParser implements ApiFormatParser {
return this.renderStreamResponse(responseBody.chunks || [])
}
// 检测是否为 CLI 格式
const isCliFormat = this.isCliResponseEvent(responseBody) ||
responseBody.object === 'response' ||
responseBody.output !== undefined
if (isCliFormat) {
return this.renderCliResponse(responseBody)
}
return this.renderChatResponse(responseBody)
}
/**
* 渲染 OpenAI Chat Completions 响应
*/
private renderChatResponse(responseBody: any): RenderResult {
try {
const blocks: RenderBlock[] = []
@@ -418,6 +873,44 @@ export class OpenAIParser implements ApiFormatParser {
}
}
/**
* 渲染 OpenAI CLI (Responses API) 响应
*/
private renderCliResponse(responseBody: any): RenderResult {
try {
const blocks: RenderBlock[] = []
const output = responseBody.output
if (!Array.isArray(output)) {
return { blocks, isStream: false }
}
for (const item of output) {
if (item?.type === 'message') {
const contentBlocks: RenderBlock[] = []
if (Array.isArray(item.content)) {
for (const content of item.content) {
if (content?.type === 'output_text' && content?.text) {
contentBlocks.push(createTextRenderBlock(content.text))
}
}
}
if (contentBlocks.length > 0) {
blocks.push(createMessageBlock('assistant', contentBlocks, {
roleLabel: 'Assistant',
}))
}
}
}
return { blocks, isStream: false }
} catch (e) {
return createEmptyRenderResult(`CLI 格式渲染失败: ${e}`)
}
}
/**
* 渲染流式响应
*/

View File

@@ -109,8 +109,8 @@ export interface DateRangeParams {
// 时间段选项
export type PeriodValue = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days'
// 筛选状态(包含新的请求状态值
export type FilterStatusValue = '__all__' | 'stream' | 'standard' | 'error' | 'active' | 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
// 筛选状态(简化为常用维度
export type FilterStatusValue = '__all__' | 'stream' | 'standard' | 'active' | 'failed' | 'cancelled'
// 默认统计状态
export function createDefaultStats(): UsageStatsState {

View File

@@ -211,20 +211,10 @@ const filteredRecords = computed(() => {
records = records.filter(record =>
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
)
} else if (filterStatus.value === 'error') {
records = records.filter(record =>
record.error_message || (record.status_code && record.status_code >= 400)
)
} else if (filterStatus.value === 'active') {
records = records.filter(record =>
record.status === 'pending' || record.status === 'streaming'
)
} else if (filterStatus.value === 'pending') {
records = records.filter(record => record.status === 'pending')
} else if (filterStatus.value === 'streaming') {
records = records.filter(record => record.status === 'streaming')
} else if (filterStatus.value === 'completed') {
records = records.filter(record => record.status === 'completed')
} else if (filterStatus.value === 'failed') {
// 失败请求需要同时考虑新旧两种判断方式:
// 1. 新方式status = "failed"
@@ -234,6 +224,8 @@ const filteredRecords = computed(() => {
(record.status_code && record.status_code >= 400) ||
record.error_message
)
} else if (filterStatus.value === 'cancelled') {
records = records.filter(record => record.status === 'cancelled')
}
}
@@ -260,8 +252,12 @@ const GLOBAL_AUTO_REFRESH_INTERVAL = 5000 // 5秒刷新一次全局自动刷
const globalAutoRefresh = ref(false) // 全局自动刷新开关
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
let pollInFlight = false
async function pollActiveRequests() {
if (!hasActiveRequests.value) return
if (pollInFlight) return
pollInFlight = true
try {
// 根据页面类型选择不同的 API
@@ -280,34 +276,53 @@ async function pollActiveRequests() {
continue
}
// 状态变化:completed/failed 需要刷新获取完整数据
if (record.status !== update.status) {
// 状态只允许单向推进避免异步响应回退pending -> streaming -> completed/failed/cancelled
const statusPriority: Record<string, number> = {
pending: 0,
streaming: 1,
completed: 2,
failed: 2,
cancelled: 2
}
const currentRank = record.status ? (statusPriority[record.status] ?? 0) : 0
const newRank = update.status ? (statusPriority[update.status] ?? 0) : 0
const shouldApply = newRank >= currentRank
if (shouldApply && record.status !== update.status) {
record.status = update.status
}
if (update.status === 'completed' || update.status === 'failed') {
if (shouldApply && (update.status === 'completed' || update.status === 'failed')) {
shouldRefresh = true
}
// 进行中状态也需要持续更新provider/key/TTFB 可能在 streaming 后才落库)
record.input_tokens = update.input_tokens
record.output_tokens = update.output_tokens
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
record.cost = update.cost
record.actual_cost = update.actual_cost ?? undefined
record.rate_multiplier = update.rate_multiplier ?? undefined
record.response_time_ms = update.response_time_ms ?? undefined
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
// API 格式/格式转换streaming 时已可确定,轮询时同步更新
if (update.api_format !== undefined) record.api_format = update.api_format
if (update.endpoint_api_format !== undefined) record.endpoint_api_format = update.endpoint_api_format
if (update.has_format_conversion !== undefined) record.has_format_conversion = update.has_format_conversion
// 管理员接口返回额外字段
if ('provider' in update && typeof update.provider === 'string') {
record.provider = update.provider
}
if ('api_key_name' in update) {
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
if (shouldApply) {
// 进行中状态也需要持续更新provider/key/TTFB 可能在 streaming 后才落库)
record.input_tokens = update.input_tokens
record.output_tokens = update.output_tokens
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
record.cost = update.cost
record.actual_cost = update.actual_cost ?? undefined
record.rate_multiplier = update.rate_multiplier ?? undefined
record.response_time_ms = update.response_time_ms ?? undefined
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
// API 格式/格式转换streaming 时已可确定,轮询时同步更新
if (update.api_format != null) record.api_format = update.api_format
if (update.endpoint_api_format != null) record.endpoint_api_format = update.endpoint_api_format
if (update.has_format_conversion != null) record.has_format_conversion = update.has_format_conversion
// 模型映射streaming 时已可确定
if ('target_model' in update && (typeof update.target_model === 'string' || update.target_model === null)) {
record.target_model = update.target_model
}
// 管理员接口返回额外字段
// 只有当返回的 provider 不是 pending/unknown 时才更新,避免覆盖已有的正确值
if ('provider' in update && typeof update.provider === 'string' &&
update.provider !== 'pending' && update.provider !== 'unknown') {
record.provider = update.provider
}
if ('api_key_name' in update) {
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
}
}
}
@@ -316,6 +331,8 @@ async function pollActiveRequests() {
}
} catch (error) {
log.error('轮询活跃请求状态失败:', error)
} finally {
pollInFlight = false
}
}