feat: record task attempt chains
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Popover as AntPopover } from 'antd';
|
||||
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react';
|
||||
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import type { ConsoleData } from '../app-state';
|
||||
@@ -433,6 +434,7 @@ function TaskPanel(props: {
|
||||
<TableHead>RequestID</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>尝试链路</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>Token</TableHead>
|
||||
@@ -556,6 +558,9 @@ function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayT
|
||||
{props.task.requestedModel && props.task.requestedModel !== resolvedModel && <small>{props.task.requestedModel}</small>}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="taskRecordAttemptCell">
|
||||
<TaskAttemptChain task={props.task} />
|
||||
</TableCell>
|
||||
<TableCell>{props.task.modelType || '-'}</TableCell>
|
||||
<TableCell>{props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'}</TableCell>
|
||||
<TableCell className="taskRecordTokenCell">{tokenUsage}</TableCell>
|
||||
@@ -572,11 +577,103 @@ function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayT
|
||||
);
|
||||
}
|
||||
|
||||
function TaskAttemptChain(props: { task: GatewayTask }) {
|
||||
const attempts = props.task.attempts ?? [];
|
||||
if (!attempts.length) return <span>-</span>;
|
||||
|
||||
return (
|
||||
<AntPopover
|
||||
align={{ offset: [0, 8] }}
|
||||
content={<TaskAttemptPopoverContent task={props.task} />}
|
||||
overlayClassName="taskRecordAttemptAntPopover"
|
||||
placement="bottomLeft"
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<button className="taskRecordAttemptCount" type="button" aria-label={attempts.map(taskAttemptTitle).join('\n')}>
|
||||
{attempts.length} 次尝试
|
||||
</button>
|
||||
</AntPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskAttemptPopoverContent(props: { task: GatewayTask }) {
|
||||
const attempts = props.task.attempts ?? [];
|
||||
return (
|
||||
<span className="taskRecordAttemptPopover" role="tooltip">
|
||||
{attempts.map((attempt) => (
|
||||
<span
|
||||
key={attempt.id || `${props.task.id}-${attempt.attemptNo}`}
|
||||
className={`taskRecordAttemptDetail ${attempt.status === 'failed' ? 'failed' : attempt.status === 'succeeded' ? 'succeeded' : ''}`}
|
||||
>
|
||||
<span className="taskRecordAttemptDetailHeader">
|
||||
<strong>#{attempt.attemptNo} {taskAttemptTarget(attempt)}</strong>
|
||||
<Badge variant={attempt.status === 'succeeded' ? 'success' : attempt.status === 'failed' ? 'destructive' : 'secondary'}>{taskAttemptStatusText(attempt.status)}</Badge>
|
||||
</span>
|
||||
<small>{taskAttemptMeta(attempt)}</small>
|
||||
{attempt.status === 'failed' && <span className="taskRecordAttemptError">{taskAttemptFailureReason(attempt)}</span>}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function taskAttemptTitle(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||||
const parts = [
|
||||
`#${attempt.attemptNo}`,
|
||||
attempt.platformName || attempt.provider || attempt.clientId || '',
|
||||
attempt.status,
|
||||
attempt.errorMessage || attempt.errorCode || metadataString(attempt.metrics, 'error') || '',
|
||||
].filter(Boolean);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function taskAttemptTarget(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||||
return attempt.platformName || attempt.provider || attempt.clientId || `尝试 ${attempt.attemptNo}`;
|
||||
}
|
||||
|
||||
function taskAttemptStatusText(status: string) {
|
||||
if (status === 'succeeded') return '成功';
|
||||
if (status === 'failed') return '失败';
|
||||
if (status === 'running') return '运行中';
|
||||
return status || '-';
|
||||
}
|
||||
|
||||
function taskAttemptMeta(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||||
const values = [
|
||||
attempt.providerModelName || attempt.modelName || attempt.modelAlias,
|
||||
attempt.requestId ? `RequestID ${attempt.requestId}` : '',
|
||||
attempt.responseDurationMs ? formatDuration(attempt.responseDurationMs) : '',
|
||||
].filter(Boolean);
|
||||
return values.join(' · ') || attempt.clientId || '-';
|
||||
}
|
||||
|
||||
function taskAttemptFailureReason(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||||
const detail = firstText(
|
||||
attempt.errorMessage,
|
||||
metadataString(attempt.metrics, 'error'),
|
||||
metadataString(attempt.metrics, 'message'),
|
||||
);
|
||||
const code = firstText(attempt.errorCode, metadataString(attempt.metrics, 'errorCode'));
|
||||
if (detail && code && detail !== code) return `${detail}(${code})`;
|
||||
return detail || code || '失败';
|
||||
}
|
||||
function formatCellValue(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') return '-';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function firstText(...values: Array<unknown>) {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function metadataString(metadata: Record<string, unknown> | undefined, key: string) {
|
||||
const value = metadata?.[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function formatTokenUsage(usage: Record<string, unknown>) {
|
||||
const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens);
|
||||
const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens);
|
||||
|
||||
Reference in New Issue
Block a user