diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 75945f5e..52c72576 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -87,7 +87,7 @@ export function hwlabNodeWebProbeHelp(): Record { "observe start registers a local UniDesk-side observer id under .state/web-observe/index.json; after start, prefer observe status|command|stop|collect|analyze instead of repeating --node/--lane/--state-dir.", "observe command actions are explicit user/control actions and are appended to control.jsonl; use --type newSession/selectProvider/sendPrompt/goto/screenshot/mark/stop and keep prompt text out of issue comments by citing textHash/textBytes.", "observe analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json without accessing Workbench APIs or driving the browser.", - "observe analyze scans every sampled DOM point, extracts Workbench timing text such as 总耗时/total and 最近 N 秒/分前, writes sampleMetrics.timeline, and flags backwards/frozen/stale/diagnostic timing anomalies instead of relying on status tail output.", + "observe analyze scans every sampled DOM point, extracts Workbench timing text such as 总耗时/total and 最近 N 秒/分前, and writes a sample point vs turn timing report: each Markdown table row starts with the timestamp, followed by each turn's 总耗时(s) and 最近更新(s). Timing series are reported for post-processing/manual analysis instead of auto-judged from status tail output.", "Use recordStep(name, data) or fetchApiMatrix(paths) to keep structured partial evidence when a later step fails.", "Use reloadStable(), gotoCurrentStable(), or safeReload() for bounded retries around page reload/current-URL navigation jitter such as ERR_NETWORK_CHANGED.", "Playwright page.evaluate accepts one serializable argument; use page.evaluate(({ a, b }) => ..., { a, b }) or safeEvaluate(fn, { a, b }).", diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index ccdb05ec..5b268f3d 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -473,6 +473,25 @@ async function samplePage(reason) { const traceSelector = '[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]'; const messages = summarize(messageSelector, 20); const traceRows = summarize(traceSelector, 30); + const turns = Array.from(document.querySelectorAll('article.message-card[data-role="agent"], .message-card[data-role="agent"], article[data-role="agent"]')).filter(visible).map((element, index) => { + const rect = element.getBoundingClientRect(); + const text = textHashInput(element); + const traceElement = element.querySelector("[data-trace-id]"); + const traceMatch = text.match(/\btrc_[A-Za-z0-9_-]+\b/u); + const durationText = trim(element.querySelector(".message-duration-meta")?.textContent || "", 120); + const activityText = trim(element.querySelector(".message-activity-meta")?.textContent || "", 120); + return { + index, + role: element.getAttribute("data-role") || "agent", + status: element.getAttribute("data-status") || null, + messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, + traceId: traceElement?.getAttribute("data-trace-id") || traceMatch?.[0] || null, + durationText, + activityText, + text, + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + }; + }).slice(-20); const active = document.activeElement; return { url, @@ -485,6 +504,7 @@ async function samplePage(reason) { scroll: { x: Math.round(window.scrollX), y: Math.round(window.scrollY), height: Math.round(document.documentElement.scrollHeight), width: Math.round(document.documentElement.scrollWidth) }, messages, traceRows, + turns, performance: performance.getEntriesByType("resource").slice(-30).map((entry) => ({ name: entry.name.split(/[?#]/u)[0].slice(0, 240), initiatorType: entry.initiatorType, startTime: Math.round(entry.startTime), duration: Math.round(entry.duration) })), }; }).catch((error) => ({ error: errorSummary(error), url: currentPageUrl() })); @@ -508,7 +528,8 @@ function digestDom(dom) { if (dom && dom.error) return dom; const messages = Array.isArray(dom.messages) ? dom.messages.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : []; const traceRows = Array.isArray(dom.traceRows) ? dom.traceRows.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : []; - return { ...dom, messages, traceRows }; + const turns = Array.isArray(dom.turns) ? dom.turns.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 200), textBytes: Buffer.byteLength(item.text || "") })) : []; + return { ...dom, messages, traceRows, turns }; } async function captureScreenshot(reason, imageType = "png") { @@ -730,14 +751,18 @@ function buildFindings(samples, control, network, errors, sampleMetrics) { if (traceSeen && !traceTerminal) findings.push({ id: "trace-without-terminal", severity: "amber", summary: "trace rows were visible but no terminal status was sampled", firstTraceSample: ref(samples.find((item) => Array.isArray(item.traceRows) && item.traceRows.length > 0)) }); const naturalApi = network.filter((item) => item.observerInitiated === false && item.type === "response" && /\/v1\/|\/auth\//u.test(String(item.url || ""))); findings.push({ id: "natural-api-dom-lag-baseline", severity: "info", summary: "natural API responses and DOM samples are available for manual lag correlation", naturalApiResponses: naturalApi.length, sampleCount: samples.length }); - for (const finding of sampleMetricFindings(sampleMetrics)) findings.push(finding); if (errors.length > 0) findings.push({ id: "browser-console-or-page-errors", severity: "amber", summary: "pageerror/runner errors were captured", count: errors.length, first: errors.slice(0, 5) }); if (samples.length === 0) findings.push({ id: "no-samples", severity: "red", summary: "observer produced no samples" }); return findings; } function buildSampleMetrics(samples, control) { - const promptTimes = control.filter((item) => item.type === "sendPrompt" && item.phase === "completed").map((item) => Date.parse(item.ts)).filter(Number.isFinite).sort((a, b) => a - b); + const promptCommands = control + .filter((item) => item.type === "sendPrompt" && item.phase === "completed") + .map((item) => ({ ts: item.ts, tsMs: Date.parse(item.ts), commandId: item.commandId ?? null, textHash: item.input?.textHash ?? null, textBytes: item.input?.textBytes ?? null })) + .filter((item) => Number.isFinite(item.tsMs)) + .sort((a, b) => a.tsMs - b.tsMs); + const promptTimes = promptCommands.map((item) => item.tsMs); const timeline = samples.map((sample) => { const texts = sampleTexts(sample); const tsMs = Date.parse(sample.ts); @@ -746,6 +771,7 @@ function buildSampleMetrics(samples, control) { const recentUpdateValues = texts.flatMap(parseRecentUpdateSeconds).filter(Number.isFinite); const diagnosticTexts = texts.filter((text) => /Failed to fetch|realtime-gap|durable projection store|turn 超过|无法连接|projection-resume|503|timeout|error/iu.test(text)).slice(0, 5); const terminalTexts = texts.filter((text) => /轮次完成|轮次失败|轮次取消|已记录|已完成第\d+轮|final response|turn completed|turn failed|turn canceled|terminal result/iu.test(text)).slice(0, 5); + const finalResultTexts = texts.filter((text) => /已完成第\d+轮|final response|sealed final response|最终结果|已完成[::]/iu.test(text)).slice(0, 5); return { seq: sample.seq ?? null, ts: sample.ts ?? null, @@ -757,77 +783,207 @@ function buildSampleMetrics(samples, control) { totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null, recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null, terminalSeen: terminalTexts.length > 0, + finalResultTextSeen: finalResultTexts.length > 0, diagnosticSeen: diagnosticTexts.length > 0, diagnosticTextHashes: diagnosticTexts.map(sha256).slice(0, 5), textDigest: digestSample(sample) }; }); + const turnTiming = buildTurnTimingTable(samples, timeline); + const turnCells = turnTiming.rows.flatMap((row) => Object.values(row.cells || {})); const withTotal = timeline.filter((item) => item.totalElapsedSeconds !== null).length; const withRecent = timeline.filter((item) => item.recentUpdateSeconds !== null).length; const diagnostics = timeline.filter((item) => item.diagnosticSeen).length; + const rounds = buildRoundMetricSummaries(timeline, promptCommands); return { summary: { sampleCount: timeline.length, withTotalElapsed: withTotal, withRecentUpdate: withRecent, diagnostics, - promptSegments: Math.max(0, promptTimes.length) + promptSegments: Math.max(0, promptTimes.length), + rounds: rounds.length, + turnColumns: turnTiming.columns.length, + turnTimingRows: turnTiming.rows.length, + turnCellsWithTotalElapsed: turnCells.filter((item) => item.totalElapsedSeconds !== null).length, + turnCellsWithRecentUpdate: turnCells.filter((item) => item.recentUpdateSeconds !== null).length }, + rounds, + turnColumns: turnTiming.columns, + turnTimingTable: turnTiming.rows, timeline }; } -function sampleMetricFindings(sampleMetrics) { - const findings = []; - const timeline = Array.isArray(sampleMetrics?.timeline) ? sampleMetrics.timeline : []; - const elapsedBackwards = []; - const recentBackwardsWithoutDigestChange = []; - const staleRunning = []; - const diagnosticSamples = []; - const terminalDiagnostic = []; - const freezeWindows = []; - let freezeStart = null; - let previous = null; - for (const item of timeline) { - if (item.diagnosticSeen) diagnosticSamples.push(metricRef(item)); - if (item.terminalSeen && item.diagnosticSeen) terminalDiagnostic.push(metricRef(item)); - if ( - previous - && item.promptIndex === previous.promptIndex - && item.totalElapsedSeconds !== null - && previous.totalElapsedSeconds !== null - && item.totalElapsedSeconds + 3 < previous.totalElapsedSeconds - ) { - elapsedBackwards.push({ from: metricRef(previous), to: metricRef(item), deltaSeconds: item.totalElapsedSeconds - previous.totalElapsedSeconds }); +function buildTurnTimingTable(samples, timeline) { + const columns = []; + const registry = new Map(); + const rows = []; + for (let index = 0; index < samples.length; index += 1) { + const sample = samples[index]; + const timelineItem = timeline[index] || {}; + const cells = {}; + for (const metric of turnMetricItems(sample, timelineItem)) { + let column = registry.get(metric.key); + if (!column) { + column = { + id: "T" + String(columns.length + 1), + label: "T" + String(columns.length + 1), + keyHash: sha256(metric.key), + source: metric.source, + firstSeq: sample.seq ?? null, + firstTs: sample.ts ?? null, + lastSeq: sample.seq ?? null, + lastTs: sample.ts ?? null, + promptIndex: metric.promptIndex ?? null, + traceId: metric.traceId ?? null, + messageId: metric.messageId ?? null, + domIndex: metric.domIndex ?? null + }; + registry.set(metric.key, column); + columns.push(column); + } else { + column.lastSeq = sample.seq ?? null; + column.lastTs = sample.ts ?? null; + if (!column.traceId && metric.traceId) column.traceId = metric.traceId; + if (!column.messageId && metric.messageId) column.messageId = metric.messageId; + } + cells[column.id] = { + totalElapsedSeconds: metric.totalElapsedSeconds, + recentUpdateSeconds: metric.recentUpdateSeconds, + status: metric.status ?? null, + promptIndex: metric.promptIndex ?? null, + source: metric.source, + traceId: metric.traceId ?? null, + messageId: metric.messageId ?? null, + textHash: metric.textHash ?? null + }; } - if ( - previous - && item.promptIndex === previous.promptIndex - && item.recentUpdateSeconds !== null - && previous.recentUpdateSeconds !== null - && item.recentUpdateSeconds + 5 < previous.recentUpdateSeconds - && item.textDigest === previous.textDigest - ) { - recentBackwardsWithoutDigestChange.push({ from: metricRef(previous), to: metricRef(item), deltaSeconds: item.recentUpdateSeconds - previous.recentUpdateSeconds }); - } - if (!item.terminalSeen && item.recentUpdateSeconds !== null && item.recentUpdateSeconds >= 45) staleRunning.push(metricRef(item)); - const elapsedFrozen = previous && item.promptIndex === previous.promptIndex && !item.terminalSeen && !previous.terminalSeen && item.totalElapsedSeconds !== null && previous.totalElapsedSeconds !== null && Math.abs(item.totalElapsedSeconds - previous.totalElapsedSeconds) <= 1 && item.textDigest === previous.textDigest; - if (elapsedFrozen) { - if (!freezeStart) freezeStart = previous; - } else if (freezeStart && previous) { - if ((previous.seq ?? 0) - (freezeStart.seq ?? 0) >= 3) freezeWindows.push({ from: metricRef(freezeStart), to: metricRef(previous) }); - freezeStart = null; - } - previous = item; + rows.push({ + ts: sample.ts ?? null, + seq: sample.seq ?? null, + promptIndex: timelineItem.promptIndex ?? 0, + routeSessionId: sample.routeSessionId ?? null, + activeSessionId: sample.activeSessionId ?? null, + cells + }); } - if (freezeStart && previous && (previous.seq ?? 0) - (freezeStart.seq ?? 0) >= 3) freezeWindows.push({ from: metricRef(freezeStart), to: metricRef(previous) }); - if (elapsedBackwards.length > 0) findings.push({ id: "sample-total-elapsed-backward", severity: "red", summary: "sampled total elapsed time moved backwards within the same prompt segment", count: elapsedBackwards.length, samples: elapsedBackwards.slice(0, 20) }); - if (freezeWindows.length > 0) findings.push({ id: "sample-total-elapsed-frozen-while-running", severity: "amber", summary: "sampled total elapsed time froze across repeated non-terminal samples", count: freezeWindows.length, windows: freezeWindows.slice(0, 20) }); - if (recentBackwardsWithoutDigestChange.length > 0) findings.push({ id: "sample-recent-update-backward-without-visible-change", severity: "amber", summary: "sampled recent-update age moved backwards while visible digest stayed the same", count: recentBackwardsWithoutDigestChange.length, samples: recentBackwardsWithoutDigestChange.slice(0, 20) }); - if (staleRunning.length > 0) findings.push({ id: "sample-recent-update-stale-while-nonterminal", severity: "amber", summary: "recent-update age exceeded threshold while no terminal state was sampled", count: staleRunning.length, samples: staleRunning.slice(0, 20) }); - if (diagnosticSamples.length > 0) findings.push({ id: "sample-diagnostic-banner-visible", severity: "amber", summary: "diagnostic/error banners were visible in sampled message or trace text", count: diagnosticSamples.length, samples: diagnosticSamples.slice(0, 20) }); - if (terminalDiagnostic.length > 0) findings.push({ id: "sample-terminal-with-diagnostic-visible", severity: "red", summary: "terminal/completed text and diagnostic/error text were visible in the same sampled state", count: terminalDiagnostic.length, samples: terminalDiagnostic.slice(0, 20) }); - return findings; + return { columns, rows }; +} + +function turnMetricItems(sample, timelineItem) { + const promptIndex = timelineItem.promptIndex ?? 0; + const items = []; + if (Array.isArray(sample?.turns) && sample.turns.length > 0) { + for (const turn of sample.turns) { + const texts = turnTexts(turn); + const totalElapsedValues = texts.flatMap(parseTotalElapsedSeconds).filter(Number.isFinite); + const recentUpdateValues = texts.flatMap(parseRecentUpdateSeconds).filter(Number.isFinite); + const traceId = turn.traceId || firstTraceId(texts); + const messageId = turn.messageId || null; + const domIndex = Number.isFinite(Number(turn.index)) ? Number(turn.index) : items.length; + const key = "turn:" + (traceId || messageId || ("dom-index-" + String(domIndex))); + items.push({ + key, + source: "turn", + promptIndex, + traceId, + messageId, + domIndex, + status: turn.status ?? null, + totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null, + recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null, + textHash: turn.textHash || sha256(texts.join("\n")) + }); + } + return items; + } + if (Array.isArray(sample?.messages) && sample.messages.length > 0) { + for (const message of sample.messages) { + const text = String(message?.textPreview || ""); + const totalElapsedValues = parseTotalElapsedSeconds(text).filter(Number.isFinite); + const recentUpdateValues = parseRecentUpdateSeconds(text).filter(Number.isFinite); + if (totalElapsedValues.length === 0 && recentUpdateValues.length === 0) continue; + const domIndex = Number.isFinite(Number(message.index)) ? Number(message.index) : items.length; + items.push({ + key: "message:" + String(domIndex), + source: "message", + promptIndex, + traceId: firstTraceId([text]), + messageId: null, + domIndex, + status: message.status ?? null, + totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null, + recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null, + textHash: message.textHash || sha256(text) + }); + } + if (items.length > 0) return items; + } + if (timelineItem.totalElapsedSeconds !== null || timelineItem.recentUpdateSeconds !== null) { + return [{ + key: "aggregate-prompt:" + String(promptIndex), + source: "aggregate", + promptIndex, + traceId: null, + messageId: null, + domIndex: null, + status: null, + totalElapsedSeconds: timelineItem.totalElapsedSeconds ?? null, + recentUpdateSeconds: timelineItem.recentUpdateSeconds ?? null, + textHash: timelineItem.textDigest ?? null + }]; + } + return []; +} + +function turnTexts(turn) { + return [ + turn?.durationText, + turn?.activityText, + turn?.textPreview, + turn?.text + ].map((value) => String(value || "")).filter((value) => value.trim().length > 0); +} + +function firstTraceId(texts) { + for (const text of texts) { + const match = String(text || "").match(/\btrc_[A-Za-z0-9_-]+\b/u); + if (match) return match[0]; + } + return null; +} + +function buildRoundMetricSummaries(timeline, promptCommands) { + const rounds = []; + for (let index = 0; index < promptCommands.length; index += 1) { + const promptIndex = index + 1; + const items = timeline.filter((item) => item.promptIndex === promptIndex); + const totalElapsed = items.map((item) => item.totalElapsedSeconds).filter((value) => value !== null); + const recentUpdate = items.map((item) => item.recentUpdateSeconds).filter((value) => value !== null); + rounds.push({ + promptIndex, + promptCommandId: promptCommands[index].commandId, + promptTextHash: promptCommands[index].textHash, + promptTextBytes: promptCommands[index].textBytes, + promptCompletedAt: promptCommands[index].ts, + sampleCount: items.length, + firstSeq: items[0]?.seq ?? null, + lastSeq: items[items.length - 1]?.seq ?? null, + firstSampleAt: items[0]?.ts ?? null, + lastSampleAt: items[items.length - 1]?.ts ?? null, + withTotalElapsed: totalElapsed.length, + withRecentUpdate: recentUpdate.length, + maxTotalElapsedSeconds: totalElapsed.length > 0 ? Math.max(...totalElapsed) : null, + lastTotalElapsedSeconds: lastNonNull(items.map((item) => item.totalElapsedSeconds)), + maxRecentUpdateSeconds: recentUpdate.length > 0 ? Math.max(...recentUpdate) : null, + lastRecentUpdateSeconds: lastNonNull(items.map((item) => item.recentUpdateSeconds)), + diagnosticSamples: items.filter((item) => item.diagnosticSeen).length, + terminalSamples: items.filter((item) => item.terminalSeen).length, + finalTextSamples: items.filter((item) => item.finalResultTextSeen).length + }); + } + return rounds; } function sampleTexts(sample) { @@ -844,15 +1000,27 @@ function sampleTexts(sample) { function parseTotalElapsedSeconds(text) { const values = []; - for (const match of String(text || "").matchAll(/总耗时\s*[=::]?\s*(\d{1,2}):(\d{2}):(\d{2})/giu)) { + for (const match of String(text || "").matchAll(/(?:总耗时|耗时)\s*[=::]?\s*(\d{1,2}):(\d{2}):(\d{2})/giu)) { values.push(Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3])); } - for (const match of String(text || "").matchAll(/总耗时\s*[=::]?\s*(\d{1,2}):(\d{2})(?!:)/giu)) { + for (const match of String(text || "").matchAll(/(?:总耗时|耗时)\s*[=::]?\s*(\d{1,2}):(\d{2})(?!:)/giu)) { values.push(Number(match[1]) * 60 + Number(match[2])); } + for (const match of String(text || "").matchAll(/(?:总耗时|耗时)\s*[=::]?\s*(?:(\d+)\s*天)?\s*(?:(\d+)\s*小时)?\s*(?:(\d+)\s*分)?\s*(?:(\d+)\s*秒)?/giu)) { + const days = Number(match[1] || 0); + const hours = Number(match[2] || 0); + const minutes = Number(match[3] || 0); + const seconds = Number(match[4] || 0); + if (days || hours || minutes || seconds) values.push(days * 86400 + hours * 3600 + minutes * 60 + seconds); + } return values; } +function lastNonNull(values) { + for (let index = values.length - 1; index >= 0; index -= 1) if (values[index] !== null && values[index] !== undefined) return values[index]; + return null; +} + function parseRecentUpdateSeconds(text) { const values = []; for (const match of String(text || "").matchAll(/最近\s*(?:(\d+)\s*天)?\s*(?:(\d+)\s*小时)?\s*(?:(\d+)\s*分)?\s*(?:(\d+)\s*秒)?\s*前/giu)) { @@ -874,20 +1042,6 @@ function latestPromptIndex(promptTimes, tsMs) { return index; } -function metricRef(item) { - return { - seq: item.seq ?? null, - ts: item.ts ?? null, - promptIndex: item.promptIndex ?? null, - routeSessionId: item.routeSessionId ?? null, - activeSessionId: item.activeSessionId ?? null, - totalElapsedSeconds: item.totalElapsedSeconds ?? null, - recentUpdateSeconds: item.recentUpdateSeconds ?? null, - terminalSeen: Boolean(item.terminalSeen), - diagnosticSeen: Boolean(item.diagnosticSeen) - }; -} - function buildTransitions(samples) { const rows = []; let last = null; @@ -958,14 +1112,52 @@ function compactHeartbeat(value) { return { jobId: value.jobId, pid: value.pid, status: value.status, sampleSeq: value.sampleSeq, commandSeq: value.commandSeq, currentUrl: value.currentUrl, updatedAt: value.updatedAt, uptimeMs: value.uptimeMs }; } +function renderTurnTimingTable(sampleMetrics) { + const columns = Array.isArray(sampleMetrics?.turnColumns) ? sampleMetrics.turnColumns : []; + const rows = Array.isArray(sampleMetrics?.turnTimingTable) ? sampleMetrics.turnTimingTable : []; + if (columns.length === 0 || rows.length === 0) return "- 无 turn 时间表。"; + const header = ["时间戳"]; + for (const column of columns) { + header.push(column.label + " 总耗时(s)"); + header.push(column.label + " 最近更新(s)"); + } + const lines = []; + lines.push("| " + header.map(escapeMarkdownCell).join(" | ") + " |"); + lines.push("| " + header.map(() => "---").join(" | ") + " |"); + for (const row of rows) { + const cells = [row.ts || "-"]; + for (const column of columns) { + const cell = row.cells?.[column.id] || {}; + cells.push(formatMetricCell(cell.totalElapsedSeconds)); + cells.push(formatMetricCell(cell.recentUpdateSeconds)); + } + lines.push("| " + cells.map(escapeMarkdownCell).join(" | ") + " |"); + } + const columnLines = columns.map((column) => "- " + column.label + ": source=" + (column.source || "-") + " prompt=" + (column.promptIndex ?? "-") + " firstSeq=" + (column.firstSeq ?? "-") + " lastSeq=" + (column.lastSeq ?? "-") + " traceId=" + (column.traceId || "-") + " messageId=" + (column.messageId || "-")).join("\n"); + return lines.join("\n") + "\n\n列说明:\n" + columnLines; +} + +function formatMetricCell(value) { + if (value === null || value === undefined || !Number.isFinite(Number(value))) return "-"; + return String(Number(value)); +} + +function escapeMarkdownCell(value) { + return String(value ?? "-").replace(/\|/gu, "\\|"); +} + function renderMarkdown(report) { const findingLines = report.findings.length === 0 ? "- 无红灯项。" : report.findings.map((item) => "- " + item.severity + ": " + item.id + " - " + item.summary).join("\n"); const commandLines = report.commandTimeline.length === 0 ? "- 无控制命令。" : report.commandTimeline.map((item) => "- " + item.ts + " " + item.phase + " " + item.type + " " + item.commandId + " " + (item.afterUrl || "")).join("\n"); const transitionLines = report.transitions.length === 0 ? "- 无状态变化。" : report.transitions.slice(0, 80).map((item) => "- #" + item.seq + " " + item.ts + " messages=" + item.messageCount + " traceRows=" + item.traceRowCount + " route=" + (item.routeSessionId || "-") + " active=" + (item.activeSessionId || "-")).join("\n"); const metricSummary = report.sampleMetrics?.summary || {}; + const roundLines = Array.isArray(report.sampleMetrics?.rounds) && report.sampleMetrics.rounds.length > 0 + ? report.sampleMetrics.rounds.map((item) => "- round " + item.promptIndex + " promptHash=" + (item.promptTextHash || "-") + " samples=" + item.sampleCount + " totalMax=" + (item.maxTotalElapsedSeconds ?? "-") + " totalLast=" + (item.lastTotalElapsedSeconds ?? "-") + " recentMax=" + (item.maxRecentUpdateSeconds ?? "-") + " recentLast=" + (item.lastRecentUpdateSeconds ?? "-") + " diagnostics=" + item.diagnosticSamples + " terminal=" + item.terminalSamples + " finalText=" + item.finalTextSamples).join("\n") + : "- 无轮次指标。"; const metricLines = Array.isArray(report.sampleMetrics?.timeline) && report.sampleMetrics.timeline.length > 0 - ? report.sampleMetrics.timeline.slice(0, 120).map((item) => "- #" + item.seq + " " + item.ts + " prompt=" + item.promptIndex + " totalElapsedSeconds=" + (item.totalElapsedSeconds ?? "-") + " recentUpdateSeconds=" + (item.recentUpdateSeconds ?? "-") + " terminal=" + item.terminalSeen + " diagnostic=" + item.diagnosticSeen).join("\n") + ? report.sampleMetrics.timeline.slice(0, 120).map((item) => "- #" + item.seq + " " + item.ts + " prompt=" + item.promptIndex + " totalElapsedSeconds=" + (item.totalElapsedSeconds ?? "-") + " recentUpdateSeconds=" + (item.recentUpdateSeconds ?? "-") + " terminal=" + item.terminalSeen + " finalText=" + item.finalResultTextSeen + " diagnostic=" + item.diagnosticSeen).join("\n") : "- 无采样指标。"; + const turnTimingTable = renderTurnTimingTable(report.sampleMetrics); return "# web-probe observe analysis\n\n" + "- stateDir: " + report.stateDir + "\n" + "- generatedAt: " + report.generatedAt + "\n" @@ -980,6 +1172,10 @@ function renderMarkdown(report) { + "- withRecentUpdate: " + (metricSummary.withRecentUpdate ?? 0) + "\n" + "- diagnostics: " + (metricSummary.diagnostics ?? 0) + "\n" + "- promptSegments: " + (metricSummary.promptSegments ?? 0) + "\n\n" + + "### Rounds\n\n" + roundLines + "\n\n" + + "### Turn timing table\n\n" + + turnTimingTable + "\n\n" + + "### Aggregate timeline\n\n" + metricLines + "\n\n" + "## Command timeline\n\n" + commandLines + "\n\n" + "## State transitions\n\n" + transitionLines + "\n";