diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 1821ad3f..993ca790 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -233,7 +233,8 @@ function traceForDisplay(message: ChatMessage): ChatMessage["runnerTrace"] { function messageDurationMeta(message: ChatMessage): { text: string; label: string } | null { if (message.role !== "agent") return null; - const durationMs = isRunningMessage(message) ? durationSince(message.timing?.startedAt) : finiteDurationMs(message.timing?.durationMs); + const timing = messageTimingForDisplay(message); + const durationMs = isRunningMessage(message) ? durationSince(timing?.startedAt) : finiteDurationMs(timing?.durationMs); if (durationMs === null) return null; const value = formatDuration(durationMs); return { text: `耗时 ${value}`, label: `总耗时:${value}` }; @@ -241,12 +242,17 @@ function messageDurationMeta(message: ChatMessage): { text: string; label: strin function messageActivityMeta(message: ChatMessage): { text: string; label: string } | null { if (!isRunningMessage(message)) return null; - const ageMs = durationSince(message.timing?.lastEventAt); + const timing = messageTimingForDisplay(message); + const ageMs = durationSince(timing?.lastEventAt); if (ageMs === null) return null; const value = formatDuration(ageMs) + "前"; return { text: `最近 ${value}`, label: `最近事件:${value}` }; } +function messageTimingForDisplay(message: ChatMessage): ChatMessage["timing"] { + return message.runnerTrace?.timing ?? message.timing ?? null; +} + function durationSince(timestamp: unknown): number | null { const startedAtMs = timestampMs(timestamp); return startedAtMs === null ? null : Math.max(0, nowMs.value - startedAtMs); diff --git a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts index c52b0ee7..1d316771 100644 --- a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts +++ b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts @@ -118,7 +118,128 @@ export function mergeRunnerTrace(previous: ChatMessage["runnerTrace"], next: Non : keepPreviousEvents && previousAuthoritative && next.eventsCompacted === true ? previousEvents : keepPreviousEvents ? mergeTraceEvents(previousEvents, nextEvents) : nextEvents.length > previousEvents.length ? mergeTraceEvents(previousEvents, nextEvents) : nextEvents; const eventCount = keepPreviousEvents ? previous.eventCount ?? events.length : next.eventCount ?? previous.eventCount ?? events.length; - return { ...previous, ...next, events, eventCount, updatedAt: next.updatedAt ?? previous.updatedAt ?? new Date().toISOString() }; + const timing = mergeTraceTimingProjection(previous, next, { preserveVisibleTerminalDuration: traceTimingWasRunning(previous) && traceTimingIsTerminal(next) }); + return { + ...previous, + ...next, + events, + eventCount, + timing, + startedAt: timing?.startedAt ?? next.startedAt ?? previous.startedAt ?? null, + lastEventAt: timing?.lastEventAt ?? next.lastEventAt ?? previous.lastEventAt ?? null, + finishedAt: timing?.finishedAt ?? next.finishedAt ?? previous.finishedAt ?? null, + durationMs: timing?.durationMs ?? next.durationMs ?? previous.durationMs ?? null, + updatedAt: next.updatedAt ?? previous.updatedAt ?? new Date().toISOString() + }; +} + +function mergeTraceTimingProjection(previous: unknown, next: unknown, options: { preserveVisibleTerminalDuration?: boolean } = {}): TraceSnapshot["timing"] | null { + const previousTiming = traceTimingCandidate(previous); + const nextTiming = traceTimingCandidate(next); + if (!previousTiming && !nextTiming) return null; + if (!previousTiming) return nextTiming; + if (!nextTiming) return previousTiming; + const startedAt = earliestTraceTimestamp(previousTiming.startedAt, nextTiming.startedAt); + const lastEventAt = latestTraceTimestamp(previousTiming.lastEventAt, nextTiming.lastEventAt); + const finishedAt = latestTraceTimestamp(previousTiming.finishedAt, nextTiming.finishedAt); + const elapsedDurationMs = traceElapsedMs(startedAt, finishedAt ?? lastEventAt); + const visibleTerminalDurationMs = options.preserveVisibleTerminalDuration === true ? traceElapsedMs(startedAt, new Date().toISOString()) : null; + const durationMs = maxTraceDuration(previousTiming.durationMs, nextTiming.durationMs, elapsedDurationMs, visibleTerminalDurationMs); + return { + ...previousTiming, + ...nextTiming, + startedAt, + lastEventAt, + finishedAt, + durationMs, + observedAt: latestTraceTimestamp(previousTiming.observedAt, nextTiming.observedAt), + lastEventAgeMs: null, + valuesRedacted: previousTiming.valuesRedacted !== false && nextTiming.valuesRedacted !== false + }; +} + +function traceTimingCandidate(value: unknown): TraceSnapshot["timing"] | null { + const record = traceRecord(value); + const timing = traceRecord(record?.timing); + if (!record && !timing) return null; + const startedAt = firstTraceTimestamp(timing?.startedAt, record?.startedAt); + const lastEventAt = firstTraceTimestamp(timing?.lastEventAt, record?.lastEventAt); + const finishedAt = firstTraceTimestamp(timing?.finishedAt, record?.finishedAt); + const observedAt = firstTraceTimestamp(timing?.observedAt, record?.observedAt); + const durationMs = maxTraceDuration(timing?.durationMs, record?.durationMs); + const lastEventAgeMs = finiteTraceDuration(timing?.lastEventAgeMs ?? record?.lastEventAgeMs); + if (!startedAt && !lastEventAt && !finishedAt && !observedAt && durationMs === null && lastEventAgeMs === null) return null; + return { ...(timing ?? {}), startedAt, lastEventAt, finishedAt, observedAt, durationMs, lastEventAgeMs, valuesRedacted: timing?.valuesRedacted !== false }; +} + +function traceRecord(value: unknown): Record | null { + return value && typeof value === "object" ? value as Record : null; +} + +function firstTraceTimestamp(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value !== "string") continue; + const text = value.trim(); + if (!text) continue; + if (Number.isFinite(Date.parse(text))) return text; + } + return null; +} + +function earliestTraceTimestamp(...values: unknown[]): string | null { + return selectTraceTimestamp("earliest", values); +} + +function latestTraceTimestamp(...values: unknown[]): string | null { + return selectTraceTimestamp("latest", values); +} + +function selectTraceTimestamp(mode: "earliest" | "latest", values: unknown[]): string | null { + let selected: { value: string; ms: number } | null = null; + for (const value of values) { + const timestamp = firstTraceTimestamp(value); + if (!timestamp) continue; + const ms = Date.parse(timestamp); + if (!selected || (mode === "earliest" ? ms < selected.ms : ms > selected.ms)) selected = { value: timestamp, ms }; + } + return selected?.value ?? null; +} + +function traceElapsedMs(startedAt: unknown, endedAt: unknown): number | null { + const start = startedAt == null ? NaN : Date.parse(String(startedAt)); + const end = endedAt == null ? NaN : Date.parse(String(endedAt)); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null; + return Math.trunc(end - start); +} + +function maxTraceDuration(...values: unknown[]): number | null { + let max: number | null = null; + for (const value of values) { + const duration = finiteTraceDuration(value); + if (duration === null) continue; + if (max === null || duration > max) max = duration; + } + return max; +} + +function finiteTraceDuration(value: unknown): number | null { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null; +} + +function traceTimingWasRunning(value: unknown): boolean { + const record = traceRecord(value); + if (!record) return false; + const status = typeof record.status === "string" ? record.status.trim().toLowerCase() : ""; + if (["completed", "failed", "cancelled", "canceled", "error"].includes(status)) return false; + const timing = traceTimingCandidate(value); + return !timing?.finishedAt; +} + +function traceTimingIsTerminal(value: unknown): boolean { + const record = traceRecord(value); + const status = typeof record?.status === "string" ? record.status.trim().toLowerCase() : ""; + return ["completed", "failed", "cancelled", "canceled", "error"].includes(status) || traceTimingCandidate(value)?.finishedAt != null; } function mergeTraceEvents(previousEvents: TraceEvent[], nextEvents: TraceEvent[]): TraceEvent[] { @@ -298,22 +419,24 @@ function mergeTraceSnapshots(previous: TraceSnapshot | null, next: TraceSnapshot const previousEvents = Array.isArray(previous.events) ? previous.events : []; const nextEvents = Array.isArray(next.events) ? next.events : []; const events = mergeTraceEvents(previousEvents, nextEvents); + const timing = mergeTraceTimingProjection(previous, next, { preserveVisibleTerminalDuration: traceTimingWasRunning(previous) && traceTimingIsTerminal(next) }); return { ...previous, ...next, events, eventCount: next.eventCount ?? previous.eventCount ?? events.length, - timing: next.timing ?? previous.timing ?? null, - startedAt: next.startedAt ?? next.timing?.startedAt ?? previous.startedAt ?? previous.timing?.startedAt ?? null, - lastEventAt: next.lastEventAt ?? next.timing?.lastEventAt ?? previous.lastEventAt ?? previous.timing?.lastEventAt ?? null, - finishedAt: next.finishedAt ?? next.timing?.finishedAt ?? previous.finishedAt ?? previous.timing?.finishedAt ?? null, - durationMs: next.durationMs ?? next.timing?.durationMs ?? previous.durationMs ?? previous.timing?.durationMs ?? null, + timing, + startedAt: timing?.startedAt ?? next.startedAt ?? next.timing?.startedAt ?? previous.startedAt ?? previous.timing?.startedAt ?? null, + lastEventAt: timing?.lastEventAt ?? next.lastEventAt ?? next.timing?.lastEventAt ?? previous.lastEventAt ?? previous.timing?.lastEventAt ?? null, + finishedAt: timing?.finishedAt ?? next.finishedAt ?? next.timing?.finishedAt ?? previous.finishedAt ?? previous.timing?.finishedAt ?? null, + durationMs: timing?.durationMs ?? next.durationMs ?? next.timing?.durationMs ?? previous.durationMs ?? previous.timing?.durationMs ?? null, lastEventLabel: next.lastEventLabel ?? previous.lastEventLabel ?? undefined, updatedAt: next.updatedAt ?? previous.updatedAt ?? new Date().toISOString() }; } function traceSnapshotWithTurnStatus(trace: TraceSnapshot, turn: TraceSnapshot): TraceSnapshot { + const timing = mergeTraceTimingProjection(trace, turn, { preserveVisibleTerminalDuration: traceTimingWasRunning(trace) && traceTimingIsTerminal(turn) }); return { ...trace, traceId: trace.traceId ?? turn.traceId, @@ -325,11 +448,11 @@ function traceSnapshotWithTurnStatus(trace: TraceSnapshot, turn: TraceSnapshot): terminalEvidence: trace.terminalEvidence ?? turn.terminalEvidence, traceSummary: trace.traceSummary ?? turn.traceSummary, error: trace.error ?? turn.error, - timing: turn.timing ?? trace.timing ?? null, - startedAt: turn.startedAt ?? turn.timing?.startedAt ?? trace.startedAt ?? trace.timing?.startedAt ?? null, - lastEventAt: turn.lastEventAt ?? turn.timing?.lastEventAt ?? trace.lastEventAt ?? trace.timing?.lastEventAt ?? null, - finishedAt: turn.finishedAt ?? turn.timing?.finishedAt ?? trace.finishedAt ?? trace.timing?.finishedAt ?? null, - durationMs: turn.durationMs ?? turn.timing?.durationMs ?? trace.durationMs ?? trace.timing?.durationMs ?? null, + timing, + startedAt: timing?.startedAt ?? turn.startedAt ?? turn.timing?.startedAt ?? trace.startedAt ?? trace.timing?.startedAt ?? null, + lastEventAt: timing?.lastEventAt ?? turn.lastEventAt ?? turn.timing?.lastEventAt ?? trace.lastEventAt ?? trace.timing?.lastEventAt ?? null, + finishedAt: timing?.finishedAt ?? turn.finishedAt ?? turn.timing?.finishedAt ?? trace.finishedAt ?? trace.timing?.finishedAt ?? null, + durationMs: timing?.durationMs ?? turn.durationMs ?? turn.timing?.durationMs ?? trace.durationMs ?? trace.timing?.durationMs ?? null, projection: trace.projection ?? turn.projection ?? null, projectionStatus: trace.projectionStatus ?? turn.projectionStatus ?? null, projectionHealth: trace.projectionHealth ?? turn.projectionHealth ?? null,