From 4a9498d7200f173f99aa2df12b26cf6f5f2f4657 Mon Sep 17 00:00:00 2001 From: lyon Date: Sat, 20 Jun 2026 13:55:45 +0800 Subject: [PATCH] fix(workbench): expose code agent timing metadata --- internal/cloud/server-workbench-http.test.ts | 53 ++++++++- internal/cloud/server-workbench-http.ts | 103 ++++++++++++++++- .../cloud/workbench-projection-writer.test.ts | 20 ++++ internal/cloud/workbench-projection-writer.ts | 70 ++++++++++- internal/cloud/workbench-turn-projection.ts | 109 ++++++++++++++++++ .../scripts/workbench-e2e-server.ts | 4 +- web/hwlab-cloud-web/src/api/workbench.ts | 41 ++++++- .../workbench/ConversationPanel.vue | 52 ++++++++- .../src/composables/useTraceSubscription.ts | 36 +++++- web/hwlab-cloud-web/src/stores/workbench.ts | 91 ++++++++++++--- web/hwlab-cloud-web/src/styles/workbench.css | 35 ++++++ web/hwlab-cloud-web/src/types/index.ts | 28 +++++ .../real-captures/d601-v03-redacted.json | 16 +-- .../tests/workbench-e2e/fixtures/selectors.ts | 1 + .../specs/message-timing.spec.ts | 29 +++++ 15 files changed, 644 insertions(+), 44 deletions(-) create mode 100644 web/hwlab-cloud-web/tests/workbench-e2e/specs/message-timing.spec.ts diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index d1051cf9..215e4549 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -365,6 +365,8 @@ test("workbench read model exposes session, messages, turn, and trace without wr assert.equal(sessions.body.contractVersion, "workbench-sessions-v1"); assert.equal(sessions.body.sessions[0].sessionId, session.id); assert.equal(sessions.body.sessions[0].turnSummary.traceId, traceId); + assert.equal(sessions.body.sessions[0].turnSummary.lastEventAt, "2026-06-17T00:00:03.000Z"); + assert.equal(sessions.body.sessions[0].turnSummary.durationMs, 3000); assert.equal(factQueries[0].projectId, undefined); assert.equal(factQueries[0].limit, 21); assert.equal(factQueries[0].sessionOrder, "updated_desc"); @@ -394,18 +396,27 @@ test("workbench read model exposes session, messages, turn, and trace without wr assert.equal(messages.body.messages[0].parts[0].text, "ping"); assert.equal(messages.body.hasMore, true); + const allMessages = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}/messages?limit=2`); + const agentMessage = allMessages.body.messages.find((message) => message.role !== "user"); + assert.equal(agentMessage.lastEventAt, "2026-06-17T00:00:03.000Z"); + assert.equal(agentMessage.durationMs, 3000); + const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`); assert.equal(turn.status, 200); assert.equal(turn.body.contractVersion, "workbench-turn-snapshot-v1"); assert.equal(turn.body.turn.traceId, traceId); assert.equal(turn.body.turn.userMessageId, messages.body.messages[0].messageId); assert.equal(turn.body.turn.terminal, true); + assert.equal(turn.body.turn.startedAt, "2026-06-17T00:00:00.000Z"); + assert.equal(turn.body.turn.finishedAt, "2026-06-17T00:00:03.000Z"); + assert.equal(turn.body.turn.durationMs, 3000); const trace = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=1`); assert.equal(trace.status, 200); assert.equal(trace.body.contractVersion, "workbench-trace-events-v1"); assert.equal(trace.body.events.length, 1); assert.equal(trace.body.hasMore, true); + assert.equal(trace.body.durationMs, 3000); const emptyTracePage = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?afterProjectedSeq=99`); assert.equal(emptyTracePage.status, 200); @@ -1423,8 +1434,9 @@ function buildDurableFactsForSession({ session, events = [], status = null, fina const traceId = session.lastTraceId ?? session.session?.lastTraceId; const projectedStatus = normalizeTestStatus(status ?? session.status); const terminal = testTerminalStatuses.has(projectedStatus); - const normalizedMessages = normalizeTestMessages(session, { traceId, status: projectedStatus, finalText, terminal }); const normalizedEvents = normalizeTestEvents(events, session, { traceId }); + const timing = testTimingProjection({ session, events: normalizedEvents, status: projectedStatus, terminal }); + const normalizedMessages = normalizeTestMessages(session, { traceId, status: projectedStatus, finalText, terminal, timing }); const explicitLastProjectedSeq = lastProjectedSeq !== null && lastProjectedSeq !== undefined ? Number(lastProjectedSeq) : NaN; const projectedSeq = Number.isFinite(explicitLastProjectedSeq) ? explicitLastProjectedSeq : Math.max(normalizedEvents.length, normalizedMessages.length, 0); const checkpointStatus = projectionStatus ?? (terminal ? "caught_up" : "projecting"); @@ -1445,6 +1457,11 @@ function buildDurableFactsForSession({ session, events = [], status = null, fina sourceEventId: traceId, terminal, sealed: terminal, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, createdAt: session.startedAt ?? session.createdAt ?? session.updatedAt ?? null, updatedAt: session.updatedAt ?? session.endedAt ?? session.startedAt ?? null, valuesRedacted: true @@ -1481,6 +1498,11 @@ function buildDurableFactsForSession({ session, events = [], status = null, fina sealed: terminal, finalResponse: finalText ? { text: finalText, status: projectedStatus, traceId, valuesPrinted: false } : null, diagnostic: { projectionStatus: checkpointStatus, projectionHealth, valuesRedacted: true }, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, createdAt: session.startedAt ?? session.updatedAt ?? null, updatedAt: session.updatedAt ?? null, valuesRedacted: true @@ -1500,6 +1522,11 @@ function buildDurableFactsForSession({ session, events = [], status = null, fina terminal, sealed: terminal, diagnostic: { projectionStatus: checkpointStatus, projectionHealth, valuesRedacted: true }, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, createdAt: session.startedAt ?? session.updatedAt ?? null, updatedAt: session.updatedAt ?? null, valuesRedacted: true @@ -1507,7 +1534,7 @@ function buildDurableFactsForSession({ session, events = [], status = null, fina }; } -function normalizeTestMessages(session, { traceId, status, finalText, terminal }) { +function normalizeTestMessages(session, { traceId, status, finalText, terminal, timing }) { const raw = Array.isArray(session.session?.messages) ? session.session.messages : []; return raw.map((message, index) => { const role = message.role ?? (index % 2 === 0 ? "user" : "agent"); @@ -1527,6 +1554,7 @@ function normalizeTestMessages(session, { traceId, status, finalText, terminal } terminal: isAssistant && terminal, sealed: isAssistant && terminal, text, + ...(isAssistant ? { timing, startedAt: timing.startedAt, lastEventAt: timing.lastEventAt, finishedAt: timing.finishedAt, durationMs: timing.durationMs } : {}), createdAt: message.createdAt ?? session.startedAt ?? session.updatedAt ?? null, updatedAt: message.updatedAt ?? session.updatedAt ?? null, valuesRedacted: true @@ -1534,6 +1562,27 @@ function normalizeTestMessages(session, { traceId, status, finalText, terminal } }); } +function testTimingProjection({ session, events = [], status, terminal }) { + const startedAt = timestampIso(session.startedAt ?? session.createdAt ?? events[0]?.createdAt ?? events[0]?.occurredAt); + const lastEvent = events.at(-1) ?? null; + const lastEventAt = timestampIso(lastEvent?.updatedAt ?? lastEvent?.createdAt ?? lastEvent?.occurredAt ?? session.updatedAt); + const finishedAt = terminal ? timestampIso(session.endedAt ?? lastEventAt ?? session.updatedAt) : null; + const durationMs = elapsedMs(startedAt, finishedAt ?? lastEventAt); + return { startedAt, lastEventAt, finishedAt, durationMs, status, valuesRedacted: true }; +} + +function timestampIso(value) { + const ms = Date.parse(String(value ?? "")); + return Number.isFinite(ms) ? new Date(ms).toISOString() : null; +} + +function elapsedMs(startedAt, endedAt) { + const start = Date.parse(String(startedAt ?? "")); + const end = Date.parse(String(endedAt ?? "")); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null; + return end - start; +} + function normalizeTestEvents(events, session, { traceId }) { return events.map((event, index) => { const projectedSeq = Number.isFinite(Number(event.projectedSeq)) ? Number(event.projectedSeq) : Number.isFinite(Number(event.seq)) ? Number(event.seq) : index + 1; diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 04912258..65ebf13b 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -14,7 +14,7 @@ import { sendJson } from "./server-http-utils.ts"; import { createWorkbenchReadModel } from "./workbench-read-model.ts"; -import { createWorkbenchTurnProjection, durableTraceStatus, RUNNING_STATUSES, TERMINAL_STATUSES } from "./workbench-turn-projection.ts"; +import { createWorkbenchTurnProjection, createWorkbenchTurnTimingProjection, durableTraceStatus, RUNNING_STATUSES, TERMINAL_STATUSES } from "./workbench-turn-projection.ts"; const DEFAULT_PAGE_LIMIT = 50; const DEFAULT_SESSION_LIST_LIMIT = 20; @@ -436,8 +436,10 @@ function factSessionSummary(session, facts = {}) { const traceId = factLastTraceId(session) ?? factLatestTraceIdForSession(facts, sessionId); const projection = traceId ? factProjectionForTrace(facts, traceId) : null; const turn = traceId ? factTurnForTrace(facts, traceId) : null; + const checkpoint = traceId ? factCheckpointForTrace(facts, traceId) : null; const messages = factMessagesForSession(session, facts); const status = normalizeStatus(turn?.status ?? session?.status); + const timing = factTimingProjection({ turn, checkpoint, session, status }); return { sessionId, threadId: safeOpaqueId(session?.threadId) ?? (textValue(session?.threadId) || null), @@ -467,6 +469,11 @@ function factSessionSummary(session, facts = {}) { projectionHealth: projection?.projectionHealth ?? null, staleMs: projection?.staleMs ?? null, blocker: projection?.blocker ?? null, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, updatedAt: factUpdatedAt(turn) } : null, valuesRedacted: true @@ -503,30 +510,45 @@ function factMessagesForSession(session, facts = {}) { return factArray(facts.messages) .filter((message) => message.sessionId === sessionId) .sort(compareFactMessagesAsc) - .map((message) => factMessageDto(message, partsByMessageId.get(message.messageId) ?? [])) + .map((message) => factMessageDto(message, partsByMessageId.get(message.messageId) ?? [], facts)) .filter(Boolean); } -function factMessageDto(message, parts = []) { +function factMessageDto(message, parts = [], facts = {}) { const messageId = safeMessageId(message?.messageId) || textValue(message?.messageId); if (!messageId) return null; const traceId = safeTraceId(message?.traceId) ?? null; + const role = textValue(message?.role) || "agent"; + const status = normalizeStatus(message?.status); + const timing = isAssistantLikeRole(role) + ? factTimingProjection({ + message, + turn: traceId ? factTurnForTrace(facts, traceId, message?.turnId) : null, + checkpoint: traceId ? factCheckpointForTrace(facts, traceId) : null, + status + }) + : factTimingProjection({ message, status }); const text = projectionText(message?.text, message?.content, message?.message, message?.finalResponse); const normalizedParts = parts.length > 0 ? [...parts].sort(compareFactPartsAsc).map((part) => factPartDto(part, messageId, traceId)).filter(Boolean) : text ? [partFact({ type: "text", text, status: message?.status }, 0, messageId, traceId)] : []; return { messageId, - role: textValue(message?.role) || "agent", + role, sessionId: (safeSessionId(message?.sessionId) ?? textValue(message?.sessionId)) || null, traceId, turnId: safeTurnId(message?.turnId) || traceId, - status: normalizeStatus(message?.status), + status, parts: normalizedParts, text: text || "", textPreview: text ? text.slice(0, 240) : null, createdAt: textValue(message?.createdAt) || null, updatedAt: factUpdatedAt(message), + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, projectionStatus: null, projectionHealth: null, valuesRedacted: message?.valuesRedacted !== false @@ -569,6 +591,7 @@ function factTurnSnapshot({ turn = null, session = null, facts = {}, traceId, tu const checkpoint = safeTrace ? factCheckpointForTrace(facts, safeTrace) : null; const finalText = projectionText(turn?.finalResponse, turn?.assistantText, turn?.text, assistantMessage?.text); const trace = factTraceSnapshot(facts, safeTrace); + const timing = factTimingProjection({ turn, checkpoint, trace, session, status }); return { turnId: resolvedTurnId, traceId: safeTrace, @@ -581,6 +604,11 @@ function factTurnSnapshot({ turn = null, session = null, facts = {}, traceId, tu assistantMessageId: assistantMessage?.messageId ?? turn?.messageId ?? null, assistantText: finalText ?? null, finalResponse: turn?.finalResponse ?? (finalText ? { text: finalText, status, traceId: safeTrace, valuesPrinted: false } : null), + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, agentRun: checkpoint ? { runId: textValue(checkpoint.runId) || null, commandId: textValue(checkpoint.commandId) || null, @@ -592,6 +620,11 @@ function factTurnSnapshot({ turn = null, session = null, facts = {}, traceId, tu traceId: safeTrace, status: trace.status, eventCount: trace.eventCount, + timing: trace.timing, + startedAt: trace.startedAt, + lastEventAt: trace.lastEventAt, + finishedAt: trace.finishedAt, + durationMs: trace.durationMs, updatedAt: trace.updatedAt }, urls: { @@ -610,13 +643,20 @@ function factTraceSnapshot(facts = {}, traceId) { .filter(Boolean); const status = durableTraceStatus(events); const lastEvent = events.at(-1) ?? null; + const checkpoint = factCheckpointForTrace(facts, safeTrace); + const timing = factTimingProjection({ trace: { traceId: safeTrace, status, events, lastEvent, updatedAt: lastEvent?.updatedAt ?? lastEvent?.createdAt ?? checkpoint?.updatedAt ?? null }, checkpoint, status }); return { traceId: safeTrace, status, eventCount: events.length, events, lastEvent, - updatedAt: lastEvent?.updatedAt ?? lastEvent?.createdAt ?? factCheckpointForTrace(facts, safeTrace)?.updatedAt ?? null, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, + updatedAt: lastEvent?.updatedAt ?? lastEvent?.createdAt ?? checkpoint?.updatedAt ?? null, valuesRedacted: true, secretMaterialStored: false }; @@ -659,6 +699,7 @@ function factProjectionForTrace(facts = {}, traceId) { const projectionStatus = normalizeProjectionStatus(checkpoint.projectionStatus); const projectionHealth = normalizeProjectionHealth(checkpoint.projectionHealth, projectionStatus); const diagnostic = objectValue(checkpoint.diagnostic); + const timing = factTimingProjection({ checkpoint }); return { projectionStatus, projectionHealth, @@ -667,11 +708,56 @@ function factProjectionForTrace(facts = {}, traceId) { sourceCommandId: textValue(checkpoint.commandId ?? checkpoint.sourceCommandId) || null, staleMs: null, blocker: diagnostic.blocker ?? checkpoint.blocker ?? null, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, updatedAt: factUpdatedAt(checkpoint), valuesRedacted: true }; } +function factTimingProjection({ message = null, turn = null, checkpoint = null, trace = null, session = null, status = null } = {}) { + const records = [message, turn, checkpoint, trace, session].filter(Boolean); + const directTiming = records.map((record) => objectValue(record?.timing)).find(Boolean) ?? null; + const direct = { + status, + timing: directTiming, + startedAt: firstFactTimestamp(records, "startedAt"), + lastEventAt: firstFactTimestamp(records, "lastEventAt"), + finishedAt: firstFactTimestamp(records, "finishedAt"), + durationMs: firstFactDuration(records), + updatedAt: firstFactTimestamp(records, "updatedAt") + }; + return createWorkbenchTurnTimingProjection({ result: direct, trace, session, status }); +} + +function firstFactTimestamp(records, key) { + for (const record of records) { + const nested = objectValue(record?.timing); + const timestamp = timestampIso(record?.[key] ?? nested?.[key]); + if (timestamp) return timestamp; + } + return null; +} + +function firstFactDuration(records) { + for (const record of records) { + const nested = objectValue(record?.timing); + for (const value of [record?.durationMs, nested?.durationMs]) { + const number = Number(value); + if (Number.isFinite(number) && number >= 0) return Math.trunc(number); + } + } + return null; +} + +function timestampIso(value) { + const ms = Date.parse(String(value ?? "")); + return Number.isFinite(ms) ? new Date(ms).toISOString() : null; +} + function factCheckpointForTrace(facts = {}, traceId) { const safeTrace = safeTraceId(traceId); if (!safeTrace) return null; @@ -950,6 +1036,11 @@ async function handleWorkbenchTraceEventPage(response, url, options, actor, rawT sourceCommandId: responseProjection.sourceCommandId, staleMs: responseProjection.staleMs, blocker: responseProjection.blocker, + timing: responseProjection.timing, + startedAt: responseProjection.startedAt, + lastEventAt: responseProjection.lastEventAt, + finishedAt: responseProjection.finishedAt, + durationMs: responseProjection.durationMs, valuesRedacted: true, secretMaterialStored: false }); diff --git a/internal/cloud/workbench-projection-writer.test.ts b/internal/cloud/workbench-projection-writer.test.ts index b4c97561..e7aad81c 100644 --- a/internal/cloud/workbench-projection-writer.test.ts +++ b/internal/cloud/workbench-projection-writer.test.ts @@ -48,6 +48,14 @@ test("workbench projection writer commits terminal owner evidence as sealed dura traceId: "trc_writer_terminal", status: "completed", assistantText: "final answer", + runnerTrace: { + traceId: "trc_writer_terminal", + status: "completed", + startedAt: "2026-06-20T10:59:30.000Z", + lastEventAt: "2026-06-20T11:00:00.000Z", + finishedAt: "2026-06-20T11:00:00.000Z", + elapsedMs: 30000 + }, agentRun: { runId: "run_writer_terminal", commandId: "cmd_writer_terminal", lastSeq: 42 }, updatedAt: "2026-06-20T11:00:00.000Z" }, @@ -72,8 +80,13 @@ test("workbench projection writer commits terminal owner evidence as sealed dura assert.equal(facts.turns[0].terminal, true); assert.equal(facts.turns[0].sealed, true); assert.equal(facts.turns[0].finalResponse.text, "final answer"); + assert.equal(facts.messages.find((message) => message.messageId === "msg_writer_agent").lastEventAt, "2026-06-20T11:00:00.000Z"); + assert.equal(facts.messages.find((message) => message.messageId === "msg_writer_agent").durationMs, 30000); + assert.equal(facts.turns[0].startedAt, "2026-06-20T10:59:30.000Z"); + assert.equal(facts.turns[0].durationMs, 30000); assert.equal(facts.checkpoints[0].projectionStatus, "caught_up"); assert.equal(facts.checkpoints[0].sealed, true); + assert.equal(facts.checkpoints[0].timing.finishedAt, "2026-06-20T11:00:00.000Z"); }); test("workbench projection event commit writes trace event and checkpoint facts", async () => { @@ -102,6 +115,7 @@ test("workbench projection event commit writes trace event and checkpoint facts" label: "agentrun:backend:running", runId: "run_writer_event", commandId: "cmd_writer_event", + startedAt: "2026-06-20T11:00:30.000Z", createdAt: "2026-06-20T11:01:00.000Z" } }); @@ -118,6 +132,7 @@ test("workbench projection event commit writes trace event and checkpoint facts" terminal: true, runId: "run_writer_event", commandId: "cmd_writer_event", + startedAt: "2026-06-20T11:00:30.000Z", createdAt: "2026-06-20T11:02:00.000Z" } }); @@ -129,6 +144,8 @@ test("workbench projection event commit writes trace event and checkpoint facts" assert.equal(factWrites[1].params.facts.traceEvents[0].sealed, true); assert.equal(factWrites[1].params.facts.traceEvents[0].projectedSeq, 2); assert.equal(factWrites[1].params.facts.checkpoints[0].projectionStatus, "caught_up"); + assert.equal(factWrites[1].params.facts.checkpoints[0].durationMs, 90000); + assert.equal(factWrites[1].params.facts.checkpoints[0].finishedAt, "2026-06-20T11:02:00.000Z"); }); test("workbench projection event writer allocates projectedSeq idempotently by source event identity", async () => { @@ -145,6 +162,7 @@ test("workbench projection event writer allocates projectedSeq idempotently by s label: "agentrun:backend:running", runId: "run_writer_idempotent", commandId: "cmd_writer_idempotent", + startedAt: "2026-06-20T12:00:01.000Z", createdAt: "2026-06-20T12:00:01.000Z" }; await writeWorkbenchProjectionEvent({ runtimeStore, event: repeatedSourceEvent }); @@ -164,4 +182,6 @@ test("workbench projection event writer allocates projectedSeq idempotently by s assert.deepEqual(loaded.facts.traceEvents.map((event) => event.projectedSeq), [1, 2]); assert.deepEqual(loaded.facts.traceEvents.map((event) => event.sourceSeq), [70, 71]); assert.equal(loaded.facts.checkpoints[0].projectedSeq, 2); + assert.equal(loaded.facts.checkpoints[0].lastEventAt, "2026-06-20T12:00:02.000Z"); + assert.equal(loaded.facts.checkpoints[0].durationMs, 1000); }); diff --git a/internal/cloud/workbench-projection-writer.ts b/internal/cloud/workbench-projection-writer.ts index 638e40a3..8aae5b08 100644 --- a/internal/cloud/workbench-projection-writer.ts +++ b/internal/cloud/workbench-projection-writer.ts @@ -6,7 +6,7 @@ import { createHash } from "node:crypto"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { safeTraceId } from "./server-http-utils.ts"; -import { createWorkbenchTurnProjection, normalizeWorkbenchStatus, projectionDiagnostics, TERMINAL_STATUSES } from "./workbench-turn-projection.ts"; +import { createWorkbenchTurnProjection, createWorkbenchTurnTimingProjection, normalizeWorkbenchStatus, projectionDiagnostics, TERMINAL_STATUSES } from "./workbench-turn-projection.ts"; export async function writeWorkbenchProjectionSession({ accessController, runtimeStore = null, traceStore = defaultCodeAgentTraceStore, traceId, ownerUserId, ownerRole = null, sessionId = null, projectId = null, conversationId = null, threadId = null, status = "active", session = {}, payload = null, params = {}, preserveLastTraceId = false } = {}) { if (!ownerUserId || typeof accessController?.recordAgentSessionOwner !== "function") return null; @@ -63,6 +63,21 @@ export async function writeWorkbenchProjectionEvent({ runtimeStore = null, event const turnId = textValue(event.turnId) || traceId; const terminal = event.terminal === true || TERMINAL_STATUSES.has(normalizeWorkbenchStatus(event.status)); const status = terminal ? normalizeWorkbenchStatus(event.status) : "running"; + const explicitStartedAt = optionalTimestampValue(event.startedAt ?? event.traceStartedAt ?? event.runnerStartedAt ?? event.agentRun?.startedAt ?? (sourceSeq <= 1 ? occurredAt : null)); + const timing = createWorkbenchTurnTimingProjection({ + result: { ...event, status, updatedAt: occurredAt, startedAt: explicitStartedAt, finishedAt: terminal ? occurredAt : event.finishedAt }, + trace: { + traceId, + status, + startedAt: explicitStartedAt, + lastEventAt: occurredAt, + finishedAt: terminal ? occurredAt : null, + elapsedMs: event.durationMs ?? event.elapsedMs, + updatedAt: occurredAt, + lastEvent: { ...event, createdAt: occurredAt, updatedAt: timestampValue(event.updatedAt ?? occurredAt) } + }, + terminal + }); const sourceEventId = workbenchSourceEventId(event, { traceId, sourceSeq, occurredAt }); const allocation = await runtimeStore.allocateWorkbenchProjectedSeq({ traceId, @@ -94,6 +109,11 @@ export async function writeWorkbenchProjectionEvent({ runtimeStore = null, event sourceEventId, terminal, sealed: terminal, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, updatedAt: occurredAt }] : [], traceEvents: [{ @@ -109,6 +129,11 @@ export async function writeWorkbenchProjectionEvent({ runtimeStore = null, event eventType: textValue(event.eventType ?? event.type ?? event.label) || "event", terminal, sealed: terminal, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, occurredAt, updatedAt: timestampValue(event.updatedAt ?? occurredAt) }], @@ -125,6 +150,11 @@ export async function writeWorkbenchProjectionEvent({ runtimeStore = null, event projectionHealth: "healthy", terminal, sealed: terminal, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, diagnostic: { lastEventLabel: textValue(event.label) || null, lastEventStatus: textValue(event.status) || null, @@ -177,6 +207,7 @@ function buildWorkbenchProjectionFacts({ traceId = null, ownerUserId = null, own const normalizedStatus = normalizeWorkbenchStatus(payload?.status ?? session?.sessionStatus ?? status); const terminal = TERMINAL_STATUSES.has(normalizedStatus); const projection = createWorkbenchTurnProjection({ traceId: safeId, result: payload, session: { id: resolvedSessionId, status: normalizedStatus, session }, trace: payload?.runnerTrace ?? null }); + const timing = projection.timing; const diagnostic = projectionDiagnostics({ traceId: safeId, result: payload, trace: payload?.runnerTrace ?? null, projection }); const messages = normalizeMessages(session?.messages, { traceId: safeId, @@ -184,7 +215,8 @@ function buildWorkbenchProjectionFacts({ traceId = null, ownerUserId = null, own conversationId: resolvedConversationId, threadId: resolvedThreadId, terminal, - timestamp + timestamp, + timing }); return { sessions: resolvedSessionId ? [{ @@ -202,6 +234,11 @@ function buildWorkbenchProjectionFacts({ traceId = null, ownerUserId = null, own terminal, sealed: terminal, sessionJson: session, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, createdAt: timestampValue(session?.createdAt ?? payload?.createdAt ?? timestamp), updatedAt: timestamp }] : [], @@ -236,6 +273,11 @@ function buildWorkbenchProjectionFacts({ traceId = null, ownerUserId = null, own sealed: projection.terminal, finalResponse: projection.finalResponse, diagnostic, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, updatedAt: timestamp }] : [], checkpoints: safeId ? [{ @@ -252,6 +294,11 @@ function buildWorkbenchProjectionFacts({ traceId = null, ownerUserId = null, own terminal: projection.terminal, sealed: projection.terminal, diagnostic, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, updatedAt: timestamp }] : [] }; @@ -262,12 +309,13 @@ function normalizeMessages(messages, context) { return messages.map((message, index) => normalizeMessageFact(message, index, context)).filter(Boolean); } -function normalizeMessageFact(message = {}, index, { traceId, sessionId, conversationId, threadId, terminal, timestamp }) { +function normalizeMessageFact(message = {}, index, { traceId, sessionId, conversationId, threadId, terminal, timestamp, timing: contextTiming }) { const messageId = textValue(message.messageId ?? message.id) || stableFactId("msg", { traceId, index, role: message.role }); if (!sessionId || !messageId) return null; const role = textValue(message.role) || "agent"; const status = normalizeWorkbenchStatus(message.status ?? (terminal && role !== "user" ? "completed" : role === "user" ? "sent" : "running")); const messageTerminal = role !== "user" && TERMINAL_STATUSES.has(status); + const timing = role === "user" ? normalizeTimingProjection(message.timing ?? message) : normalizeTimingProjection(message.timing ?? message, contextTiming); return { ...message, messageId, @@ -284,12 +332,23 @@ function normalizeMessageFact(message = {}, index, { traceId, sessionId, convers terminal: messageTerminal, sealed: messageTerminal, text: textValue(message.text ?? message.content ?? message.message), + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, createdAt: timestampValue(message.createdAt ?? timestamp), updatedAt: timestampValue(message.updatedAt ?? timestamp), valuesPrinted: false }; } +function normalizeTimingProjection(...values) { + const direct = values.find((value) => value && typeof value === "object" && (value.startedAt || value.lastEventAt || value.finishedAt || Number.isFinite(Number(value.durationMs)))) ?? null; + const nested = values.map((value) => value?.timing).find((value) => value && typeof value === "object") ?? null; + return createWorkbenchTurnTimingProjection({ result: { ...(nested ?? {}), ...(direct ?? {}), timing: nested ?? null } }); +} + function isEmptyFacts(facts) { return Object.values(facts).every((items) => !Array.isArray(items) || items.length === 0); } @@ -334,6 +393,11 @@ function timestampValue(value) { return Number.isFinite(ms) ? new Date(ms).toISOString() : new Date().toISOString(); } +function optionalTimestampValue(value) { + const ms = Date.parse(String(value ?? "")); + return Number.isFinite(ms) ? new Date(ms).toISOString() : null; +} + function textValue(value) { return String(value ?? "").trim(); } diff --git a/internal/cloud/workbench-turn-projection.ts b/internal/cloud/workbench-turn-projection.ts index c514cb46..383f8bfe 100644 --- a/internal/cloud/workbench-turn-projection.ts +++ b/internal/cloud/workbench-turn-projection.ts @@ -18,6 +18,7 @@ export function createWorkbenchTurnProjection({ turnId = null, traceId = null, r const finalText = terminal ? projectionText(result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, terminalEvidence?.finalResponse) : null; const agentRun = objectValue(result?.agentRun ?? trace?.agentRun); const lastEvent = traceLastEvent(trace); + const timing = createWorkbenchTurnTimingProjection({ result, session, trace, status, terminal }); return { turnId: projectionTurnId, traceId: projectionTraceId, @@ -33,6 +34,82 @@ export function createWorkbenchTurnProjection({ turnId = null, traceId = null, r sourceCommandId: agentRun?.commandId ?? lastEvent?.commandId ?? lastEvent?.payload?.commandId ?? null, eventCount: normalizedEventCount(trace), updatedAt: trace?.updatedAt ?? result?.updatedAt ?? session?.updatedAt ?? null, + timing, + startedAt: timing.startedAt, + lastEventAt: timing.lastEventAt, + finishedAt: timing.finishedAt, + durationMs: timing.durationMs, + valuesRedacted: true + }; +} + +export function createWorkbenchTurnTimingProjection({ result = null, session = null, trace = null, status = null, terminal = null } = {}) { + const events = Array.isArray(trace?.events) ? trace.events : []; + const firstEvent = events[0] ?? null; + const lastEvent = traceLastEvent(trace); + const terminalEvent = terminalTraceEventForTiming(events); + const directTiming = objectValue(result?.timing) ?? objectValue(trace?.timing) ?? objectValue(session?.timing) ?? null; + const agentRun = objectValue(result?.agentRun ?? trace?.agentRun); + const traceSummary = objectValue(result?.traceSummary ?? trace?.traceSummary); + const sessionSnapshot = objectValue(session?.session); + const normalizedStatus = normalizeWorkbenchStatus(status ?? result?.status ?? trace?.status ?? session?.status); + const isTerminal = typeof terminal === "boolean" ? terminal : TERMINAL_STATUSES.has(normalizedStatus) && !RUNNING_STATUSES.has(normalizedStatus); + const startedAt = firstTimestamp( + result?.startedAt, + directTiming?.startedAt, + result?.traceStartedAt, + trace?.startedAt, + traceSummary?.startedAt, + agentRun?.startedAt, + firstEvent?.startedAt, + firstEvent?.createdAt, + firstEvent?.occurredAt, + session?.startedAt, + session?.createdAt, + sessionSnapshot?.startedAt, + sessionSnapshot?.createdAt + ); + const lastEventAt = firstTimestamp( + result?.lastEventAt, + directTiming?.lastEventAt, + trace?.lastEventAt, + traceSummary?.lastEventAt, + lastEvent?.lastEventAt, + lastEvent?.updatedAt, + lastEvent?.createdAt, + lastEvent?.occurredAt, + trace?.updatedAt, + result?.updatedAt + ); + const finishedAt = isTerminal ? firstTimestamp( + result?.finishedAt, + directTiming?.finishedAt, + trace?.finishedAt, + traceSummary?.finishedAt, + agentRun?.finishedAt, + trace?.terminalEvidence?.updatedAt, + terminalEvent?.updatedAt, + terminalEvent?.createdAt, + terminalEvent?.occurredAt, + result?.updatedAt, + trace?.updatedAt, + lastEventAt + ) : null; + const durationMs = durationValue( + result?.durationMs, + directTiming?.durationMs, + trace?.durationMs, + traceSummary?.durationMs, + agentRun?.durationMs, + trace?.elapsedMs, + result?.elapsedMs, + elapsedBetween(startedAt, finishedAt ?? lastEventAt) + ); + return { + startedAt, + lastEventAt, + finishedAt, + durationMs, valuesRedacted: true }; } @@ -237,6 +314,38 @@ function traceLastEvent(trace) { return trace?.lastEvent ?? events.at(-1) ?? null; } +function terminalTraceEventForTiming(events = []) { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (!event || typeof event !== "object") continue; + if (event.terminal === true || event.final === true || event.replyAuthority === true || terminalStatusFromValue(event.status ?? event.terminalStatus)) return event; + } + return null; +} + +function firstTimestamp(...values) { + for (const value of values) { + const ms = Date.parse(String(value ?? "")); + if (Number.isFinite(ms)) return new Date(ms).toISOString(); + } + return null; +} + +function durationValue(...values) { + for (const value of values) { + const number = Number(value); + if (Number.isFinite(number) && number >= 0) return Math.trunc(number); + } + return null; +} + +function elapsedBetween(startedAt, endedAt) { + const start = Date.parse(String(startedAt ?? "")); + const end = Date.parse(String(endedAt ?? "")); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null; + return end - start; +} + function projectionDiagnosticSource(trace) { const direct = objectValue(trace?.projection); if (direct) return direct; diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 90e6dd49..c1ffd6d4 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -1702,9 +1702,9 @@ function traceAfterProjectedSeq(url: URL): number { function workbenchTracePayload(traceId: string, url: URL): JsonRecord { const payload = tracePayload(traceId, url); if (state.scenarioId === "tool-completed-projection-running" && traceId === "trc_running") { - return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: "completed", events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextProjectedSeq: payload.nextProjectedSeq, nextCursor: payload.nextCursor, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, projectionStatus: "projecting", terminalEvidence: null, finalResponse: null, traceSummary: payload.traceSummary, retention: payload.retention }; + return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: "completed", timing: payload.timing, startedAt: payload.startedAt, lastEventAt: payload.lastEventAt, finishedAt: payload.finishedAt, durationMs: payload.durationMs, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextProjectedSeq: payload.nextProjectedSeq, nextCursor: payload.nextCursor, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, projectionStatus: "projecting", terminalEvidence: null, finalResponse: null, traceSummary: payload.traceSummary, retention: payload.retention }; } - return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: payload.status, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextProjectedSeq: payload.nextProjectedSeq, nextCursor: payload.nextCursor, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, terminalEvidence: payload.terminalEvidence, finalResponse: payload.finalResponse, traceSummary: payload.traceSummary, retention: payload.retention, ...projectionEnvelope(payload) }; + return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: payload.status, timing: payload.timing, startedAt: payload.startedAt, lastEventAt: payload.lastEventAt, finishedAt: payload.finishedAt, durationMs: payload.durationMs, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextProjectedSeq: payload.nextProjectedSeq, nextCursor: payload.nextCursor, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, terminalEvidence: payload.terminalEvidence, finalResponse: payload.finalResponse, traceSummary: payload.traceSummary, retention: payload.retention, ...projectionEnvelope(payload) }; } function projectionEnvelope(payload: JsonRecord): JsonRecord { diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index cda2963a..019458e3 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -2,7 +2,7 @@ // Responsibility: Workbench read-model HTTP client for session, message, turn and trace projection APIs. import { fetchJson, type ApiRequestOptions } from "./client"; -import type { AgentChatResultResponse, ApiResult, ChatMessage, ProjectionDiagnostic, WorkbenchSessionRecord } from "@/types"; +import type { AgentChatResultResponse, ApiResult, ChatMessage, ProjectionDiagnostic, WorkbenchSessionRecord, WorkbenchTurnTimingProjection } from "@/types"; export interface WorkbenchSessionListResponse { sessions?: WorkbenchSessionRecord[]; @@ -93,11 +93,17 @@ function normalizeWorkbenchTurnResult(result: ApiResult> const trace = recordValue(turn.trace); const status = normalizeStatus(textValue(turn.status)) ?? "unknown"; const projection = normalizeProjectionDiagnostic(result.data.projection, turn.projection, result.data); + const timing = normalizeTimingProjection(turn.timing, result.data.timing, turn, result.data); return { ...result, data: { ...turn, projection, + timing, + startedAt: timing?.startedAt ?? textValue(turn.startedAt, result.data.startedAt) ?? undefined, + lastEventAt: timing?.lastEventAt ?? textValue(turn.lastEventAt, result.data.lastEventAt) ?? undefined, + finishedAt: timing?.finishedAt ?? textValue(turn.finishedAt, result.data.finishedAt) ?? undefined, + durationMs: timing?.durationMs ?? numberValue(turn.durationMs ?? result.data.durationMs) ?? undefined, projectionStatus: projection?.projectionStatus ?? textValue(result.data.projectionStatus) ?? undefined, projectionHealth: projection?.projectionHealth ?? textValue(result.data.projectionHealth) ?? undefined, lastProjectedSeq: Number.isFinite(Number(result.data.lastProjectedSeq)) ? Number(result.data.lastProjectedSeq) : undefined, @@ -127,6 +133,7 @@ function normalizeWorkbenchTraceResult(result: ApiResult const hasMore = result.data.hasMore === true; const fullTraceLoaded = typeof result.data.fullTraceLoaded === "boolean" ? result.data.fullTraceLoaded : undefined; const projection = normalizeProjectionDiagnostic(result.data.projection, result.data); + const timing = normalizeTimingProjection(result.data.timing, result.data); return { ...result, data: { @@ -134,6 +141,11 @@ function normalizeWorkbenchTraceResult(result: ApiResult traceId: textValue(result.data.traceId) ?? traceId, status: undefined, traceStatus, + timing, + startedAt: timing?.startedAt ?? textValue(result.data.startedAt) ?? undefined, + lastEventAt: timing?.lastEventAt ?? textValue(result.data.lastEventAt) ?? undefined, + finishedAt: timing?.finishedAt ?? textValue(result.data.finishedAt) ?? undefined, + durationMs: timing?.durationMs ?? numberValue(result.data.durationMs) ?? undefined, events, traceEvents: events, eventCount, @@ -149,6 +161,11 @@ function normalizeWorkbenchTraceResult(result: ApiResult runnerTrace: { traceId: textValue(result.data.traceId) ?? traceId, status: undefined, + timing, + startedAt: timing?.startedAt ?? textValue(result.data.startedAt) ?? undefined, + lastEventAt: timing?.lastEventAt ?? textValue(result.data.lastEventAt) ?? undefined, + finishedAt: timing?.finishedAt ?? textValue(result.data.finishedAt) ?? undefined, + durationMs: timing?.durationMs ?? numberValue(result.data.durationMs) ?? undefined, events, eventCount, hasMore, @@ -166,6 +183,28 @@ function normalizeWorkbenchTraceResult(result: ApiResult }; } +function normalizeTimingProjection(...values: unknown[]): WorkbenchTurnTimingProjection | undefined { + for (const value of values) { + const record = recordValue(value); + if (!record) continue; + const source = recordValue(record.timing) ?? record; + const startedAt = textValue(source.startedAt, record.startedAt); + const lastEventAt = textValue(source.lastEventAt, record.lastEventAt); + const finishedAt = textValue(source.finishedAt, record.finishedAt); + const durationMs = numberValue(source.durationMs ?? record.durationMs); + if (!startedAt && !lastEventAt && !finishedAt && durationMs == null) continue; + return { + ...source, + startedAt: startedAt ?? null, + lastEventAt: lastEventAt ?? null, + finishedAt: finishedAt ?? null, + durationMs, + valuesRedacted: source.valuesRedacted !== false + } as WorkbenchTurnTimingProjection; + } + return undefined; +} + function normalizeStatus(value: unknown): string | null { const text = textValue(value); if (!text) return null; diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 18e06880..6558af51 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -2,7 +2,7 @@ // SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0; PJ2026-0104010803 唯一投影 draft-2026-06-19-p2-terminal-sealed-final-response. // Confirms Workbench message visibility after Vue render and keeps sealed final responses separate from diagnostics. -import { computed, nextTick, onMounted, ref, watch } from "vue"; +import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; import type { ChatMessage } from "@/types"; import LoadingState from "@/components/common/LoadingState.vue"; import StatusBadge from "@/components/common/StatusBadge.vue"; @@ -24,6 +24,7 @@ const clipboard = useClipboard(); const panelRef = ref(null); const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useBottomFollowScroll(panelRef, { threshold: 56 }); const detailMessageId = ref(null); +const timingNowMs = ref(Date.now()); const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages); const detailMessage = computed(() => workbench.activeMessages.find((message) => message.id === detailMessageId.value) ?? null); const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy()); @@ -35,6 +36,7 @@ const scrollSignature = computed(() => workbench.activeMessages.map((message) => message.runnerTrace?.eventCount ?? message.runnerTrace?.events?.length ?? 0, message.runnerTrace?.status ?? "" ].join(":"))); +let timingClock: number | null = null; watch(scrollSignature, async () => { await keepBottomAfterUpdate(); @@ -45,6 +47,12 @@ onMounted(async () => { await nextTick(); scrollToBottom(); acknowledgeVisibleMessages(); + if (typeof window !== "undefined") timingClock = window.setInterval(() => { timingNowMs.value = Date.now(); }, 1000); +}); + +onUnmounted(() => { + if (typeof window !== "undefined" && timingClock !== null) window.clearInterval(timingClock); + timingClock = null; }); watch(() => workbench.sessionDetailLoading, async () => { @@ -123,6 +131,40 @@ function traceForDisplay(message: ChatMessage): ChatMessage["runnerTrace"] { if (!status || status === message.runnerTrace.status) return message.runnerTrace; return { ...message.runnerTrace, status }; } + +function messageTimingMeta(message: ChatMessage): { recent: string | null; duration: string | null; label: string } | null { + if (message.role !== "agent") return null; + const timing = message.timing ?? null; + const lastEventAt = firstNonEmptyString(timing?.lastEventAt, message.lastEventAt); + const durationMs = finiteDurationMs(timing?.durationMs ?? message.durationMs); + const recent = isRunningMessage(message) && lastEventAt ? `最近 ${formatRelativeSince(lastEventAt, timingNowMs.value)}` : null; + const duration = durationMs !== null ? `耗时 ${formatDuration(durationMs)}` : null; + if (!recent && !duration) return null; + const label = [recent ? `最近事件:${recent.replace(/^最近\s*/u, "")}` : null, duration ? `总耗时:${duration.replace(/^耗时\s*/u, "")}` : null].filter(Boolean).join(";"); + return { recent, duration, label }; +} + +function finiteDurationMs(value: unknown): number | null { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null; +} + +function formatRelativeSince(iso: string, nowMsValue: number): string { + const observedMs = Date.parse(iso); + if (!Number.isFinite(observedMs)) return "未知"; + return formatDuration(Math.max(0, nowMsValue - observedMs)) + "前"; +} + +function formatDuration(ms: number): string { + const seconds = Math.max(0, Math.floor(ms / 1000)); + if (seconds < 60) return `${seconds} 秒`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} 小时 ${minutes % 60} 分`; + const days = Math.floor(hours / 24); + return `${days} 天 ${hours % 24} 小时`; +}