From 0049b246c1a0f534eeb6cf6ff66d50d313662553 Mon Sep 17 00:00:00 2001 From: wangbo Date: Mon, 11 May 2026 22:39:45 +0800 Subject: [PATCH] feat: record task attempt chains --- .../httpapi/core_flow_integration_test.go | 25 ++++ apps/api/internal/runner/recording.go | 68 ++++++++- apps/api/internal/runner/service.go | 28 +++- apps/api/internal/store/postgres.go | 37 +++++ apps/api/internal/store/runtime_types.go | 1 + apps/api/internal/store/tasks_runtime.go | 140 +++++++++++++++++- apps/web/src/pages/WorkspacePage.tsx | 97 ++++++++++++ apps/web/src/styles.css | 73 ++++++++- packages/contracts/src/index.ts | 32 ++++ 9 files changed, 492 insertions(+), 9 deletions(-) diff --git a/apps/api/internal/httpapi/core_flow_integration_test.go b/apps/api/internal/httpapi/core_flow_integration_test.go index b056b5e..bf09f5b 100644 --- a/apps/api/internal/httpapi/core_flow_integration_test.go +++ b/apps/api/internal/httpapi/core_flow_integration_test.go @@ -722,6 +722,31 @@ WHERE reference_type = 'gateway_task' if failoverTask.Task.Status != "succeeded" { t.Fatalf("failover task should succeed through second client: %+v", failoverTask.Task) } + var failoverDetail struct { + Attempts []struct { + AttemptNo int `json:"attemptNo"` + PlatformName string `json:"platformName"` + Status string `json:"status"` + Retryable bool `json:"retryable"` + ErrorCode string `json:"errorCode"` + ErrorMessage string `json:"errorMessage"` + ResponseMS int64 `json:"responseDurationMs"` + } `json:"attempts"` + Metrics map[string]any `json:"metrics"` + } + doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+failoverTask.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &failoverDetail) + if len(failoverDetail.Attempts) != 2 { + t.Fatalf("failover task history should include two attempts, got %+v", failoverDetail.Attempts) + } + if failoverDetail.Attempts[0].PlatformName != "OpenAI Retryable Failure" || failoverDetail.Attempts[0].Status != "failed" || !failoverDetail.Attempts[0].Retryable || failoverDetail.Attempts[0].ErrorCode == "" { + t.Fatalf("first failover attempt should preserve failed platform and reason: %+v", failoverDetail.Attempts[0]) + } + if failoverDetail.Attempts[1].PlatformName != "OpenAI Retry Success" || failoverDetail.Attempts[1].Status != "succeeded" || failoverDetail.Attempts[1].ResponseMS <= 0 { + t.Fatalf("second failover attempt should preserve successful platform: %+v", failoverDetail.Attempts[1]) + } + if summary, ok := failoverDetail.Metrics["attempts"].([]any); !ok || len(summary) != 2 { + t.Fatalf("task metrics should keep attempt-chain summary, got %+v", failoverDetail.Metrics) + } var degradePolicySet struct { ID string `json:"id"` diff --git a/apps/api/internal/runner/recording.go b/apps/api/internal/runner/recording.go index 6eae8e3..1ed1a54 100644 --- a/apps/api/internal/runner/recording.go +++ b/apps/api/internal/runner/recording.go @@ -40,7 +40,8 @@ func buildSuccessRecord(task store.GatewayTask, user *auth.User, body map[string } func taskMetrics(task store.GatewayTask, user *auth.User, body map[string]any, candidate store.RuntimeModelCandidate, response clients.Response, simulated bool) map[string]any { - metrics := map[string]any{ + metrics := attemptMetrics(candidate, 0, simulated) + for key, value := range map[string]any{ "kind": task.Kind, "runMode": task.RunMode, "requestedModel": task.Model, @@ -57,6 +58,8 @@ func taskMetrics(task store.GatewayTask, user *auth.User, body map[string]any, c "queueKey": candidate.QueueKey, "requestId": response.RequestID, "simulated": simulated, + } { + metrics[key] = value } if user != nil { metrics["apiKeyId"] = user.APIKeyID @@ -94,6 +97,29 @@ func taskMetrics(task store.GatewayTask, user *auth.User, body map[string]any, c return metrics } +func attemptMetrics(candidate store.RuntimeModelCandidate, attemptNo int, simulated bool) map[string]any { + metrics := map[string]any{ + "resolvedModel": candidate.ModelName, + "modelName": candidate.ModelName, + "modelAlias": candidate.ModelAlias, + "providerModel": candidate.ProviderModelName, + "canonicalModel": candidate.CanonicalModelKey, + "modelType": candidate.ModelType, + "provider": candidate.Provider, + "platformId": candidate.PlatformID, + "platformKey": candidate.PlatformKey, + "platformName": candidate.PlatformName, + "platformModelId": candidate.PlatformModelID, + "clientId": candidate.ClientID, + "queueKey": candidate.QueueKey, + "simulated": simulated, + } + if attemptNo > 0 { + metrics["attempt"] = attemptNo + } + return metrics +} + func usageToMap(usage clients.Usage) map[string]any { out := map[string]any{} if usage.InputTokens > 0 { @@ -172,6 +198,46 @@ func failureMetrics(err error, simulated bool) (string, map[string]any, time.Tim return meta.RequestID, metrics, meta.ResponseStartedAt, meta.ResponseFinishedAt, meta.ResponseDurationMS } +func mergeMetrics(values ...map[string]any) map[string]any { + out := map[string]any{} + for _, value := range values { + for key, item := range value { + out[key] = item + } + } + return out +} + +func summarizeAttempts(attempts []store.TaskAttempt) []map[string]any { + items := make([]map[string]any, 0, len(attempts)) + for _, attempt := range attempts { + item := map[string]any{ + "attempt": attempt.AttemptNo, + "status": attempt.Status, + "platformId": attempt.PlatformID, + "platformName": attempt.PlatformName, + "provider": attempt.Provider, + "platformModelId": attempt.PlatformModelID, + "modelName": attempt.ModelName, + "providerModelName": attempt.ProviderModelName, + "modelAlias": attempt.ModelAlias, + "modelType": attempt.ModelType, + "clientId": attempt.ClientID, + "queueKey": attempt.QueueKey, + "requestId": attempt.RequestID, + "retryable": attempt.Retryable, + "simulated": attempt.Simulated, + "errorCode": attempt.ErrorCode, + "errorMessage": attempt.ErrorMessage, + "responseDurationMs": attempt.ResponseDurationMS, + "startedAt": attempt.StartedAt, + "finishedAt": attempt.FinishedAt, + } + items = append(items, item) + } + return items +} + func messageCount(body map[string]any) int { messages, _ := body["messages"].([]any) return len(messages) diff --git a/apps/api/internal/runner/service.go b/apps/api/internal/runner/service.go index 4fdc45a..37a4eea 100644 --- a/apps/api/internal/runner/service.go +++ b/apps/api/internal/runner/service.go @@ -85,6 +85,7 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut if err == nil { billings := s.billings(ctx, user, task.Kind, body, candidate, response, isSimulation(task, candidate)) record := buildSuccessRecord(task, user, body, candidate, response, billings, isSimulation(task, candidate)) + record.Metrics = s.withAttemptHistory(ctx, task.ID, record.Metrics) finished, finishErr := s.store.FinishTaskSuccess(ctx, store.FinishTaskSuccessInput{ TaskID: task.ID, Result: response.Result, @@ -161,6 +162,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user Status: "running", Simulated: simulated, RequestSnapshot: body, + Metrics: attemptMetrics(candidate, attemptNo, simulated), }) if err != nil { return clients.Response{}, err @@ -172,7 +174,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user AttemptID: attemptID, Status: "failed", Retryable: false, - Metrics: map[string]any{"error": err.Error(), "candidateModel": candidate.ModelName, "clientId": candidate.ClientID}, + Metrics: mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), map[string]any{"error": err.Error(), "retryable": false}), ErrorCode: "rate_limit", ErrorMessage: err.Error(), }) @@ -224,6 +226,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user responseDurationMS = 0 } } + metrics = mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), metrics) _ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{ AttemptID: attemptID, Status: "failed", @@ -242,9 +245,10 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user } uploadedResult, err := s.uploadGeneratedAssets(ctx, response.Result) if err != nil { - metrics := taskMetrics(task, user, body, candidate, response, simulated) - metrics["error"] = err.Error() - metrics["retryable"] = clients.IsRetryable(err) + metrics := mergeMetrics(taskMetrics(task, user, body, candidate, response, simulated), map[string]any{ + "error": err.Error(), + "retryable": clients.IsRetryable(err), + }) _ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{ AttemptID: attemptID, Status: "failed", @@ -299,6 +303,7 @@ func (s *Service) clientFor(candidate store.RuntimeModelCandidate, simulated boo func (s *Service) failTask(ctx context.Context, taskID string, code string, message string, simulated bool, cause error) (store.GatewayTask, error) { requestID, metrics, responseStartedAt, responseFinishedAt, responseDurationMS := failureMetrics(cause, simulated) + metrics = s.withAttemptHistory(ctx, taskID, metrics) failed, err := s.store.FinishTaskFailure(ctx, store.FinishTaskFailureInput{ TaskID: taskID, Code: code, @@ -318,6 +323,21 @@ func (s *Service) failTask(ctx context.Context, taskID string, code string, mess return failed, nil } +func (s *Service) withAttemptHistory(ctx context.Context, taskID string, metrics map[string]any) map[string]any { + attempts, err := s.store.ListTaskAttempts(ctx, taskID) + if err != nil { + s.logger.Warn("list task attempts for metrics failed", "taskID", taskID, "error", err) + return metrics + } + if len(attempts) == 0 { + return metrics + } + metrics = mergeMetrics(metrics) + metrics["attemptCount"] = len(attempts) + metrics["attempts"] = summarizeAttempts(attempts) + return metrics +} + func (s *Service) emit(ctx context.Context, taskID string, eventType string, status string, phase string, progress float64, message string, payload map[string]any, simulated bool) error { event, err := s.store.AddTaskEvent(ctx, taskID, eventType, status, phase, progress, message, payload, simulated) if err != nil { diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index fc8729b..a3de2e3 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -393,6 +393,7 @@ type GatewayTask struct { Error string `json:"error,omitempty"` ErrorCode string `json:"errorCode,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` + Attempts []TaskAttempt `json:"attempts,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } @@ -424,6 +425,37 @@ type TaskEvent struct { CreatedAt time.Time `json:"createdAt"` } +type TaskAttempt struct { + ID string `json:"id"` + TaskID string `json:"taskId"` + AttemptNo int `json:"attemptNo"` + PlatformID string `json:"platformId,omitempty"` + PlatformName string `json:"platformName,omitempty"` + Provider string `json:"provider,omitempty"` + PlatformModelID string `json:"platformModelId,omitempty"` + ModelName string `json:"modelName,omitempty"` + ProviderModelName string `json:"providerModelName,omitempty"` + ModelAlias string `json:"modelAlias,omitempty"` + ModelType string `json:"modelType,omitempty"` + ClientID string `json:"clientId,omitempty"` + QueueKey string `json:"queueKey"` + Status string `json:"status"` + Retryable bool `json:"retryable"` + Simulated bool `json:"simulated"` + RequestID string `json:"requestId,omitempty"` + Usage map[string]any `json:"usage,omitempty"` + Metrics map[string]any `json:"metrics,omitempty"` + RequestSnapshot map[string]any `json:"requestSnapshot,omitempty"` + ResponseSnapshot map[string]any `json:"responseSnapshot,omitempty"` + ResponseStartedAt string `json:"responseStartedAt,omitempty"` + ResponseFinishedAt string `json:"responseFinishedAt,omitempty"` + ResponseDurationMS int64 `json:"responseDurationMs"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + StartedAt time.Time `json:"startedAt"` + FinishedAt string `json:"finishedAt,omitempty"` +} + func (s *Store) ListPlatforms(ctx context.Context) ([]Platform, error) { rows, err := s.pool.Query(ctx, ` SELECT id::text, provider, platform_key, name, COALESCE(internal_name, ''), COALESCE(base_url, ''), auth_type, status, priority, @@ -1623,6 +1655,11 @@ SELECT `+gatewayTaskColumns+` if err != nil { return GatewayTask{}, err } + attempts, err := s.ListTaskAttempts(ctx, task.ID) + if err != nil { + return GatewayTask{}, err + } + task.Attempts = attempts return task, nil } diff --git a/apps/api/internal/store/runtime_types.go b/apps/api/internal/store/runtime_types.go index 9dc32f8..faeb7e7 100644 --- a/apps/api/internal/store/runtime_types.go +++ b/apps/api/internal/store/runtime_types.go @@ -104,6 +104,7 @@ type CreateTaskAttemptInput struct { Status string Simulated bool RequestSnapshot map[string]any + Metrics map[string]any } type FinishTaskAttemptInput struct { diff --git a/apps/api/internal/store/tasks_runtime.go b/apps/api/internal/store/tasks_runtime.go index 42db75f..4b1f77d 100644 --- a/apps/api/internal/store/tasks_runtime.go +++ b/apps/api/internal/store/tasks_runtime.go @@ -132,6 +132,10 @@ LIMIT $8 OFFSET $9`, queryArgs...) if err := rows.Err(); err != nil { return TaskListResult{}, err } + items, err = s.attachTaskAttempts(ctx, items) + if err != nil { + return TaskListResult{}, err + } return TaskListResult{ Items: items, Total: total, @@ -163,6 +167,7 @@ WHERE id = $1::uuid`, taskID, modelType, string(normalizedJSON)) func (s *Store) CreateTaskAttempt(ctx context.Context, input CreateTaskAttemptInput) (string, error) { requestJSON, _ := json.Marshal(emptyObjectIfNil(input.RequestSnapshot)) + metricsJSON, _ := json.Marshal(emptyObjectIfNil(input.Metrics)) tx, err := s.pool.Begin(ctx) if err != nil { return "", err @@ -173,11 +178,11 @@ func (s *Store) CreateTaskAttempt(ctx context.Context, input CreateTaskAttemptIn err = tx.QueryRow(ctx, ` INSERT INTO gateway_task_attempts ( task_id, attempt_no, platform_id, platform_model_id, client_id, queue_key, - status, simulated, request_snapshot + status, simulated, request_snapshot, metrics ) VALUES ( $1::uuid, $2, NULLIF($3, '')::uuid, NULLIF($4, '')::uuid, NULLIF($5, ''), $6, - $7, $8, $9::jsonb + $7, $8, $9::jsonb, $10::jsonb ) RETURNING id::text`, input.TaskID, @@ -189,6 +194,7 @@ RETURNING id::text`, firstNonEmpty(input.Status, "running"), input.Simulated, string(requestJSON), + string(metricsJSON), ).Scan(&attemptID) if err != nil { return "", err @@ -202,6 +208,136 @@ WHERE id = $1::uuid`, input.TaskID, input.AttemptNo); err != nil { return attemptID, tx.Commit(ctx) } +func (s *Store) attachTaskAttempts(ctx context.Context, items []GatewayTask) ([]GatewayTask, error) { + if len(items) == 0 { + return items, nil + } + taskIDs := make([]string, 0, len(items)) + for _, item := range items { + taskIDs = append(taskIDs, item.ID) + } + attemptsByTaskID, err := s.listTaskAttemptsByTaskIDs(ctx, taskIDs) + if err != nil { + return nil, err + } + for index := range items { + items[index].Attempts = attemptsByTaskID[items[index].ID] + } + return items, nil +} + +func (s *Store) ListTaskAttempts(ctx context.Context, taskID string) ([]TaskAttempt, error) { + attemptsByTaskID, err := s.listTaskAttemptsByTaskIDs(ctx, []string{taskID}) + if err != nil { + return nil, err + } + return attemptsByTaskID[taskID], nil +} + +func (s *Store) listTaskAttemptsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]TaskAttempt, error) { + itemsByTaskID := map[string][]TaskAttempt{} + if len(taskIDs) == 0 { + return itemsByTaskID, nil + } + rows, err := s.pool.Query(ctx, ` +SELECT a.id::text, a.task_id::text, a.attempt_no, + COALESCE(a.platform_id::text, ''), COALESCE(p.name, ''), COALESCE(p.provider, ''), + COALESCE(a.platform_model_id::text, ''), COALESCE(pm.model_name, ''), + COALESCE(NULLIF(pm.provider_model_name, ''), pm.model_name, ''), + COALESCE(pm.model_alias, ''), + COALESCE(a.client_id, ''), a.queue_key, a.status, a.retryable, a.simulated, + COALESCE(a.request_id, ''), COALESCE(a.usage, '{}'::jsonb), COALESCE(a.metrics, '{}'::jsonb), + a.request_snapshot, COALESCE(a.response_snapshot, '{}'::jsonb), + COALESCE(a.response_started_at::text, ''), COALESCE(a.response_finished_at::text, ''), + COALESCE(a.response_duration_ms, 0), COALESCE(a.error_code, ''), COALESCE(a.error_message, ''), + a.started_at, COALESCE(a.finished_at::text, '') +FROM gateway_task_attempts a +LEFT JOIN integration_platforms p ON p.id = a.platform_id +LEFT JOIN platform_models pm ON pm.id = a.platform_model_id +WHERE a.task_id::text = ANY($1) +ORDER BY a.task_id, a.attempt_no`, taskIDs) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + item, err := scanTaskAttempt(rows) + if err != nil { + return nil, err + } + itemsByTaskID[item.TaskID] = append(itemsByTaskID[item.TaskID], item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return itemsByTaskID, nil +} + +func scanTaskAttempt(scanner taskScanner) (TaskAttempt, error) { + var item TaskAttempt + var usageBytes []byte + var metricsBytes []byte + var requestBytes []byte + var responseBytes []byte + if err := scanner.Scan( + &item.ID, + &item.TaskID, + &item.AttemptNo, + &item.PlatformID, + &item.PlatformName, + &item.Provider, + &item.PlatformModelID, + &item.ModelName, + &item.ProviderModelName, + &item.ModelAlias, + &item.ClientID, + &item.QueueKey, + &item.Status, + &item.Retryable, + &item.Simulated, + &item.RequestID, + &usageBytes, + &metricsBytes, + &requestBytes, + &responseBytes, + &item.ResponseStartedAt, + &item.ResponseFinishedAt, + &item.ResponseDurationMS, + &item.ErrorCode, + &item.ErrorMessage, + &item.StartedAt, + &item.FinishedAt, + ); err != nil { + return TaskAttempt{}, err + } + item.Usage = decodeObject(usageBytes) + item.Metrics = decodeObject(metricsBytes) + item.RequestSnapshot = decodeObject(requestBytes) + item.ResponseSnapshot = decodeObject(responseBytes) + enrichTaskAttemptFromMetrics(&item) + return item, nil +} + +func enrichTaskAttemptFromMetrics(item *TaskAttempt) { + if item == nil || len(item.Metrics) == 0 { + return + } + item.PlatformID = firstNonEmpty(item.PlatformID, taskAttemptMetricString(item.Metrics, "platformId")) + item.PlatformName = firstNonEmpty(item.PlatformName, taskAttemptMetricString(item.Metrics, "platformName")) + item.Provider = firstNonEmpty(item.Provider, taskAttemptMetricString(item.Metrics, "provider")) + item.PlatformModelID = firstNonEmpty(item.PlatformModelID, taskAttemptMetricString(item.Metrics, "platformModelId")) + item.ModelName = firstNonEmpty(item.ModelName, taskAttemptMetricString(item.Metrics, "resolvedModel"), taskAttemptMetricString(item.Metrics, "modelName")) + item.ProviderModelName = firstNonEmpty(item.ProviderModelName, taskAttemptMetricString(item.Metrics, "providerModel")) + item.ModelAlias = firstNonEmpty(item.ModelAlias, taskAttemptMetricString(item.Metrics, "modelAlias")) + item.ModelType = firstNonEmpty(item.ModelType, taskAttemptMetricString(item.Metrics, "modelType")) + item.ClientID = firstNonEmpty(item.ClientID, taskAttemptMetricString(item.Metrics, "clientId")) +} + +func taskAttemptMetricString(metrics map[string]any, key string) string { + value, _ := metrics[key].(string) + return strings.TrimSpace(value) +} + func (s *Store) FinishTaskAttempt(ctx context.Context, input FinishTaskAttemptInput) error { responseJSON, _ := json.Marshal(emptyObjectIfNil(input.ResponseSnapshot)) usageJSON, _ := json.Marshal(emptyObjectIfNil(input.Usage)) diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index 82b2710..edcbc43 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -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: { RequestID 状态 模型 + 尝试链路 类型 API Key Token @@ -556,6 +558,9 @@ function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayT {props.task.requestedModel && props.task.requestedModel !== resolvedModel && {props.task.requestedModel}} + + + {props.task.modelType || '-'} {props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'} {tokenUsage} @@ -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 -; + + return ( + } + overlayClassName="taskRecordAttemptAntPopover" + placement="bottomLeft" + trigger={['hover', 'focus']} + > + + + ); +} + +function TaskAttemptPopoverContent(props: { task: GatewayTask }) { + const attempts = props.task.attempts ?? []; + return ( + + {attempts.map((attempt) => ( + + + #{attempt.attemptNo} {taskAttemptTarget(attempt)} + {taskAttemptStatusText(attempt.status)} + + {taskAttemptMeta(attempt)} + {attempt.status === 'failed' && {taskAttemptFailureReason(attempt)}} + + ))} + + ); +} + +function taskAttemptTitle(attempt: NonNullable[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[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[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[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) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +} + +function metadataString(metadata: Record | undefined, key: string) { + const value = metadata?.[key]; + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + function formatTokenUsage(usage: Record) { 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); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 0c60743..eb010bd 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -261,8 +261,8 @@ strong { } .taskRecordTable .shTableRow { - grid-template-columns: minmax(190px, 0.95fr) minmax(220px, 1.05fr) minmax(94px, 0.42fr) minmax(280px, 1.55fr) minmax(126px, 0.58fr) minmax(150px, 0.7fr) minmax(154px, 0.66fr) minmax(82px, 0.38fr) minmax(98px, 0.45fr) minmax(150px, 0.7fr) minmax(130px, 0.58fr); - min-width: 1674px; + grid-template-columns: minmax(190px, 0.9fr) minmax(220px, 1fr) minmax(94px, 0.4fr) minmax(280px, 1.45fr) minmax(104px, 0.42fr) minmax(126px, 0.55fr) minmax(150px, 0.66fr) minmax(154px, 0.62fr) minmax(82px, 0.36fr) minmax(98px, 0.42fr) minmax(150px, 0.66fr) minmax(130px, 0.54fr); + min-width: 1778px; align-items: start; } @@ -354,6 +354,75 @@ strong { word-break: break-word; } +.taskRecordAttemptCell { + overflow: visible; + white-space: normal; +} + +.taskRecordAttemptCount { + display: inline-flex; + align-items: center; + justify-content: flex-start; + min-height: 1.5rem; + padding: 0; + border: 0; + background: transparent; + color: var(--text-strong); + cursor: default; + font: inherit; + font-weight: var(--font-weight-medium); +} + +.taskRecordAttemptAntPopover { + z-index: 1200; +} + +.taskRecordAttemptPopover { + display: grid; + width: min(34rem, calc(100vw - 2rem)); + gap: 0.6rem; +} + +.taskRecordAttemptDetail { + display: grid; + min-width: 0; + gap: 0.35rem; + padding-bottom: 0.6rem; + border-bottom: 1px solid var(--border); +} + +.taskRecordAttemptDetail:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.taskRecordAttemptDetailHeader { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.taskRecordAttemptDetailHeader strong { + min-width: 0; + color: var(--text-strong); + font-weight: var(--font-weight-semibold); + overflow-wrap: anywhere; +} + +.taskRecordAttemptDetail small { + color: var(--text-soft); + font-size: var(--font-size-xs); + line-height: 1.4; +} + +.taskRecordAttemptError { + color: var(--destructive); + font-size: var(--font-size-xs); + line-height: 1.45; + overflow-wrap: anywhere; +} .taskRecordJsonButton { width: 100%; justify-content: flex-start; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 72cad1e..757783b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -715,10 +715,42 @@ export interface GatewayTask { error?: string; errorCode?: string; errorMessage?: string; + attempts?: GatewayTaskAttempt[]; createdAt: string; updatedAt: string; } +export interface GatewayTaskAttempt { + id: string; + taskId: string; + attemptNo: number; + platformId?: string; + platformName?: string; + provider?: string; + platformModelId?: string; + modelName?: string; + providerModelName?: string; + modelAlias?: string; + modelType?: string; + clientId?: string; + queueKey: string; + status: 'running' | 'succeeded' | 'failed' | string; + retryable: boolean; + simulated: boolean; + requestId?: string; + usage?: Record; + metrics?: Record; + requestSnapshot?: Record; + responseSnapshot?: Record; + responseStartedAt?: string; + responseFinishedAt?: string; + responseDurationMs?: number; + errorCode?: string; + errorMessage?: string; + startedAt: string; + finishedAt?: string; +} + export interface GatewayTaskEvent { id: string; taskId: string;