diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 7d0374d4..a0bdb5ae 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -97,7 +97,7 @@ bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx - `web-probe observe start` 默认是被动观测:记录 DOM 摘要、自然页面 request/response/requestfailed、截图和 performance 样本,不主动 fetch Workbench API、不 reload、不切换 session、不拦截路由、不调用 repair helper。任何 `newSession`、`selectProvider`、`sendPrompt`、`goto`、`screenshot`、`mark`、`stop` 都必须通过 `observe command` 显式下发,并进入 `control.jsonl`。 - `web-probe observe` 的 issue evidence 优先记录 observer id、stateDir、report JSON/Markdown SHA、samples/control/network/artifact 计数、routeSessionId、activeSessionId、prompt hash/textBytes、traceId、AgentRun runId/commandId、最终 status 和必要摘要;不要把 prompt 原文、assistant 大段正文、完整 stdout/stderr 或 provider payload 粘贴到 issue。 - 多轮 Workbench 采样必须证明同一个 `sessionId` 连续承载所有轮次;每轮至少记录 prompt hash、traceId、终态、最终回答摘要和性能/产物表。若 Web UI 投影卡住但 Code Agent/AgentRun result 已 terminal,应同时登记“执行终态”和“Workbench 投影未收敛”,不得用 `goto`、reload、切 session 或 result polling 把 UI 失败伪装成通过。 -- `observe analyze` 是离线分析,只读取 artifact JSONL 并写 `analysis/report.md` 与 `analysis/report.json`,不访问 Workbench API、不驱动浏览器。报告必须输出采样点 vs 每个 turn 的总耗时/最近更新时间表、DOM diagnostic/HTTP/console/requestfailed/runtime execution error 分组、page asset provenance segment、同源 API Resource Timing 分位表和超过 5s 的慢路径 finding;页面/API 加载超过 5s 视为不可用级性能红线,必须登记到上游问题,不能靠下游 retry/reload/fallback 掩盖。报告里的 `final-response-flicker`、`uncommanded-visible-state-change`、session changed、network 503 等 finding 是排障线索;用于 closeout 时必须结合原始 session/trace/DOM 证据解释,避免把采样噪声直接当作业务结论。 +- `observe analyze` 是离线分析,只读取 artifact JSONL 并写 `analysis/report.md` 与 `analysis/report.json`,不访问 Workbench API、不驱动浏览器。报告必须输出采样点 vs 每个 turn 的总耗时/最近更新时间表、可见“加载中”的数量/归属/并发 owner/连续出现区间、DOM diagnostic/HTTP/console/requestfailed/runtime execution error 分组、page asset provenance segment、同源 API Resource Timing 分位表和超过 5s 的慢路径 finding;页面/API 加载超过 5s 视为不可用级性能红线,可见“加载中”持续超过 5s 也必须作为真实慢加载证据登记到上游问题。修复必须降低真实请求、投影、渲染或后端路径耗时,禁止为了减少“加载中”出现时间而提前展示未加载完的内容,也不能靠下游 retry/reload/fallback 掩盖。报告里的 `final-response-flicker`、`uncommanded-visible-state-change`、session changed、network 503 等 finding 是排障线索;用于 closeout 时必须结合原始 session/trace/DOM 证据解释,避免把采样噪声直接当作业务结论。 - 自定义 `web-probe script` 仍运行在 UniDesk `trans` 60s 最外层短连接约束内;能在一轮内完成的 P4 验收优先把 `--command-timeout-seconds` 控制在 55 秒以内,并减少无界 selector/network 等待。确需等待更久时,改用 `web-probe run` 的异步 job/status 语义,或把动作拆成“提交/采样/截图/状态读取”多次短 probe。若输出出现 `UNIDESK_SSH_RUNTIME_TIMEOUT` 但同时恢复了 `reportPath`、`reportSha256`、screenshots 或 DOM steps,先按远端报告判断脚本/页面实际状态;最终关闭证据仍优先用一次未触发短连接超时的 bounded rerun。 - issue closeout 优先引用 `web-probe script` 输出的顶层 `issueEvidence` 或 `summary.issueEvidence`;只有需要展开调查时才粘贴 `probe.script.result`、`probe.steps` 或完整 `reportPath`,避免 stdout、summary 和 report 多层重复同一证据。 - stdin heredoc 与 `--script-file` 都按 ES module 加载,脚本必须导出 `export default async ({ page, gotoStable, recordStep, ... }) => { ... }`;不要在模块顶层直接写 `return`。失败为 `Illegal return statement`、`does not provide an export named default` 或 finalUrl 仍是 `about:blank` 且 stepCount=0 时,先按 probe 脚本入口误用处理,不要归因成 Cloud Web 行为失败。 diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 5196bf12..d2564fd0 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -96,6 +96,7 @@ export function hwlabNodeWebProbeHelp(): Record { "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 秒/分前, 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.", + "observe analyze also reports visible “加载中” count, owner attribution, concurrent loading owners, and continuous visible segments; fixes must reduce real loading latency, not reveal incomplete content early to make this metric disappear.", "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-impl.ts b/scripts/src/hwlab-node-impl.ts index ed3e10bb..f8594bec 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -7816,6 +7816,10 @@ function renderWebObserveAnalyzeTable(value: Record): string { const runtimeAlerts = record(analysis?.runtimeAlerts); const pagePerformance = record(analysis?.pagePerformance); const promptNetwork = record(analysis?.promptNetwork); + const loading = record(sampleMetrics?.loading); + const loadingSummary = record(loading?.summary); + const loadingSegments = Array.isArray(loading?.longestSegments) ? loading.longestSegments.map(record).filter((item): item is Record => item !== null).slice(0, 8) : []; + const loadingOwners = Array.isArray(loading?.owners) ? loading.owners.map(record).filter((item): item is Record => item !== null).slice(0, 8) : []; const rounds = webObserveArray(sampleMetrics?.roundItems ?? sampleMetrics?.rounds).map(record).filter((item): item is Record => item !== null).slice(-8); const turnColumns = webObserveArray(sampleMetrics?.turnColumns).map(record).filter((item): item is Record => item !== null).slice(-12); const roundCount = Array.isArray(sampleMetrics?.rounds) ? sampleMetrics.rounds.length : sampleMetrics?.rounds; @@ -7995,15 +7999,49 @@ function renderWebObserveAnalyzeTable(value: Record): string { ]]), "", "Rounds:", - webObserveTable(["ROUND", "SAMPLES", "TOTAL_LAST", "RECENT_LAST", "DIAG", "TERMINAL", "PROMPT_HASH"], rounds.length > 0 ? rounds.map((item) => [ + webObserveTable(["ROUND", "SAMPLES", "LOADING", "MAX_LOAD", "TOTAL_LAST", "RECENT_LAST", "DIAG", "TERMINAL", "PROMPT_HASH"], rounds.length > 0 ? rounds.map((item) => [ webObserveText(item.promptIndex), webObserveText(item.sampleCount), + webObserveText(item.loadingSamples), + webObserveText(item.maxLoadingCount), webObserveText(item.lastTotalElapsedSeconds), webObserveText(item.lastRecentUpdateSeconds), webObserveText(item.diagnosticSamples), webObserveText(item.terminalSamples), webObserveShort(webObserveText(item.promptTextHash), 24), - ]) : [["-", "-", "-", "-", "-", "-", "-"]]), + ]) : [["-", "-", "-", "-", "-", "-", "-", "-", "-"]]), + "", + "Loading visibility:", + webObserveTable(["SAMPLES", "LOADING_SAMPLES", "MAX_COUNT", "MAX_OWNERS", "OWNERS", "LONGEST_S", "CURRENT_S", "OVER5S"], [[ + webObserveText(loadingSummary?.sampleCount ?? sampleMetrics?.sampleCount), + webObserveText(loadingSummary?.loadingSampleCount ?? sampleMetrics?.loadingSampleCount), + webObserveText(loadingSummary?.maxSimultaneousCount ?? sampleMetrics?.loadingMaxCount), + webObserveText(loadingSummary?.maxSimultaneousOwnerCount ?? sampleMetrics?.loadingMaxOwnerCount), + webObserveText(loadingSummary?.ownerCount ?? sampleMetrics?.loadingOwnerCount), + webObserveText(loadingSummary?.longestContinuousSeconds ?? sampleMetrics?.loadingLongestContinuousSeconds), + webObserveText(loadingSummary?.currentContinuousSeconds ?? sampleMetrics?.loadingCurrentContinuousSeconds), + webObserveText(loadingSummary?.overFiveSecondSegmentCount ?? sampleMetrics?.loadingOverFiveSecondSegmentCount), + ]]), + "", + "Loading owners:", + webObserveTable(["SAMPLES", "OCCUR", "MAX", "LONGEST_S", "KIND", "OWNER"], loadingOwners.length > 0 ? loadingOwners.map((item) => [ + webObserveText(item.sampleCount), + webObserveText(item.occurrenceCount), + webObserveText(item.maxSimultaneousCount), + webObserveText(item.longestContinuousSeconds), + webObserveShort(webObserveText(item.ownerKind), 16), + webObserveShort(webObserveText(item.ownerLabel ?? item.ownerKey), 64), + ]) : [["-", "-", "-", "-", "-", "-"]]), + "", + "Loading segments:", + webObserveTable(["DURATION_S", "MAX", "OWNERS", "SEQ", "LAST", "STATUS"], loadingSegments.length > 0 ? loadingSegments.map((item) => [ + webObserveText(item.durationSeconds), + webObserveText(item.maxCount), + webObserveText(item.ownerCount), + `${webObserveText(item.firstSeq)}..${webObserveText(item.lastSeq)}`, + webObserveShort(webObserveText(item.lastAt), 24), + item.ongoing === true ? "ongoing" : webObserveShort(webObserveText(item.endedAt), 24), + ]) : [["-", "-", "-", "-", "-", "-"]]), "", "Turn columns:", webObserveTable(["TURN", "PROMPT", "TRACE", "FIRST_SEQ", "LAST_SEQ", "SOURCE"], turnColumns.length > 0 ? turnColumns.map((item) => [ @@ -8726,7 +8764,7 @@ const compactHeartbeat=(item)=>item?{ok:item.ok,jobId:item.jobId,pid:item.pid,st const retryLabel=(detail)=>detail&&detail.auth?detail.auth.lastRetryLabel||'':detail&&detail.result?detail.result.lastRetryLabel||'':detail&&detail.error&&detail.error.auth?detail.error.auth.lastRetryLabel||'':''; const detailText=(detail)=>detail&&detail.error?short((detail.error.message||'')+(detail.error.auth&&detail.error.auth.lastError?' '+detail.error.auth.lastError:'')):detail&&detail.result?short([detail.result.statusText,detail.result.retryExhausted?'retry-exhausted':''].filter(Boolean).join(' ')):''; const compactControl=(item)=>({ts:item.ts,seq:item.seq,phase:item.phase,type:item.type,commandId:item.commandId,durationMs:item.detail&&item.detail.durationMs||null,retry:retryLabel(item.detail),detail:detailText(item.detail)}); -const compactSample=(item)=>({seq:item.seq,ts:item.ts,path:item.path,routeSessionId:item.routeSessionId||null,activeSessionId:item.activeSessionId||null,messageCount:Array.isArray(item.messages)?item.messages.length:0,traceRowCount:Array.isArray(item.traceRows)?item.traceRows.length:0}); +const compactSample=(item)=>({seq:item.seq,ts:item.ts,path:item.path,routeSessionId:item.routeSessionId||null,activeSessionId:item.activeSessionId||null,messageCount:Array.isArray(item.messages)?item.messages.length:0,traceRowCount:Array.isArray(item.traceRows)?item.traceRows.length:0,loadingCount:Array.isArray(item.loadings)?item.loadings.length:0,loadingOwners:Array.isArray(item.loadings)?Array.from(new Set(item.loadings.map((loading)=>loading.ownerLabel||loading.ownerKind||loading.ownerKey||'loading'))).slice(0,4):[]}); const compactNetwork=(item)=>({ts:item.ts,type:item.type,method:item.method,url:short(item.url),status:item.status||null,failure:item.failure?short(item.failure):null}); const pidText=fs.existsSync(path.join(dir,'pid'))?fs.readFileSync(path.join(dir,'pid'),'utf8').trim():null; let alive=false; if(pidText){try{process.kill(Number(pidText),0); alive=true}catch{}} diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index db7fa334..761eb652 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -893,6 +893,91 @@ async function samplePage(reason) { return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; }; const textHashInput = (element) => trim(element.textContent || "", 800); + const loadingTextPattern = /加载中|Loading/iu; + const elementTextForLoading = (element) => [ + element.textContent || "", + element.getAttribute("aria-label") || "", + element.getAttribute("title") || "", + element.getAttribute("data-testid") || "" + ].map((value) => trim(value, 240)).filter(Boolean).join(" "); + const hasLoadingText = (element) => loadingTextPattern.test(elementTextForLoading(element)); + const elementDescriptor = (element) => { + if (!element) return null; + const className = String(element.className || "").replace(/\s+/g, " ").trim().split(" ").slice(0, 6).join(" "); + return { + tag: element.tagName.toLowerCase(), + testId: element.getAttribute("data-testid") || null, + role: element.getAttribute("role") || null, + id: element.getAttribute("id") || null, + className: className || null, + status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, + sessionId: element.getAttribute("data-session-id") || null, + messageId: element.getAttribute("data-message-id") || null, + traceId: element.getAttribute("data-trace-id") || null, + ariaLabel: element.getAttribute("aria-label") || null + }; + }; + const ownerKindFor = (element) => { + const value = [ + element.getAttribute("data-testid") || "", + element.getAttribute("class") || "", + element.getAttribute("role") || "", + element.tagName || "" + ].join(" ").toLowerCase(); + if (/message|turn|agent|assistant/.test(value)) return "turn"; + if (/session|rail|sidebar|nav/.test(value)) return "session-nav"; + if (/composer|prompt|input|textarea/.test(value)) return "composer"; + if (/diagnostic|alert|error|warning/.test(value)) return "diagnostic"; + if (/trace|event|terminal|log/.test(value)) return "trace"; + if (/performance|metric|chart|table/.test(value)) return "performance"; + if (/main|workspace|workbench|root/.test(value)) return "workbench"; + return "unknown"; + }; + const ownerLabelFor = (element) => { + const heading = element.querySelector("h1,h2,h3,h4,[data-testid*='title' i],[class*='title' i],[class*='header' i]"); + return trim( + element.getAttribute("aria-label") + || element.getAttribute("data-testid") + || (heading ? heading.textContent || "" : "") + || element.getAttribute("class") + || element.tagName, + 160 + ); + }; + const ownerKeyFor = (element) => { + const descriptor = elementDescriptor(element) || {}; + return [ + ownerKindFor(element), + descriptor.testId || descriptor.id || descriptor.role || descriptor.className || descriptor.tag || "unknown", + descriptor.sessionId || descriptor.messageId || descriptor.traceId || "" + ].filter(Boolean).join(":").slice(0, 240); + }; + const collectLoadingNodes = () => { + const candidates = Array.from(document.querySelectorAll("body *")) + .filter(visible) + .filter(hasLoadingText) + .filter((element) => !Array.from(element.children).some((child) => visible(child) && hasLoadingText(child))) + .slice(-80); + return candidates.map((element, index) => { + const rect = element.getBoundingClientRect(); + const owner = element.closest('[data-message-id], [data-trace-id], article.message-card, .message-card, [data-testid*="message" i], [data-testid*="turn" i], [data-testid*="composer" i], [data-testid*="session" i], [class*="composer" i], [class*="session" i], [class*="trace" i], [class*="diagnostic" i], article, section, aside, main, form, [role="status"], [role="alert"], [role="article"], [role="navigation"]') || element; + const ownerDescriptor = elementDescriptor(owner); + return { + index, + tag: element.tagName.toLowerCase(), + className: String(element.className || "").slice(0, 180), + testId: element.getAttribute("data-testid"), + role: element.getAttribute("role"), + text: trim(elementTextForLoading(element), 240), + ownerKind: ownerKindFor(owner), + ownerKey: ownerKeyFor(owner), + ownerLabel: ownerLabelFor(owner), + owner: ownerDescriptor, + ownerText: trim(owner.textContent || "", 300), + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + }; + }); + }; const diagnosticSummaryText = (element) => { const summarySelectors = [ ".api-error-diagnostic-summary-text p", @@ -950,6 +1035,7 @@ async function samplePage(reason) { const diagnosticSelector = '.api-error-diagnostic, [class*="api-error-diagnostic" i], [class*="message-diagnostic" i], [class*="projection-diagnostic" i], [data-testid="api-error-diagnostic" i], [data-testid="error-diagnostic" i], [data-testid*="diagnostic" i], [role="alert"], [aria-live="assertive"]'; const messages = summarize(messageSelector, 20); const traceRows = summarize(traceSelector, 30); + const loadings = collectLoadingNodes(); const diagnostics = Array.from(document.querySelectorAll(diagnosticSelector)).filter(visible).slice(-40).map((element, index) => { const rect = element.getBoundingClientRect(); const text = diagnosticSummaryText(element); @@ -1013,6 +1099,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, + loadings, diagnostics, turns, pageProvenance: { @@ -1069,11 +1156,12 @@ 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 || "") })) : []; + const loadings = Array.isArray(dom.loadings) ? dom.loadings.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), ownerTextHash: sha256Text(item.ownerText || ""), ownerTextPreview: truncate(item.ownerText || "", 160) })) : []; const diagnostics = Array.isArray(dom.diagnostics) ? dom.diagnostics.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 260), textBytes: Buffer.byteLength(item.text || "") })) : []; 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 || "") })) : []; const pageProvenance = normalizePageProvenance(dom.pageProvenance, { reason: "sample", pageLoadSeq: currentPageProvenance?.pageLoadSeq ?? pageLoadSeq }); currentPageProvenance = pageProvenance; - return { ...dom, messages, traceRows, diagnostics, turns, pageProvenance: compactPageProvenance(pageProvenance) }; + return { ...dom, messages, traceRows, loadings, diagnostics, turns, pageProvenance: compactPageProvenance(pageProvenance) }; } async function captureScreenshot(reason, imageType = "png") { @@ -1308,8 +1396,9 @@ console.log(JSON.stringify({ readIssues: jsonlReadIssues.slice(0, 3).map((item) => ({ file: item.file, line: item.line ?? null, error: String(item.error ?? "").slice(0, 160) })), sampleMetrics: { ...recentWindow.sampleMetrics.summary, - rounds: recentWindow.sampleMetrics.rounds.slice(-8).map((item) => ({ promptIndex: item.promptIndex, promptTextHash: item.promptTextHash, sampleCount: item.sampleCount, firstSeq: item.firstSeq, lastSeq: item.lastSeq, lastTotalElapsedSeconds: item.lastTotalElapsedSeconds, lastRecentUpdateSeconds: item.lastRecentUpdateSeconds, diagnosticSamples: item.diagnosticSamples, terminalSamples: item.terminalSamples, finalTextSamples: item.finalTextSamples, turnTimingTotalElapsedZeroResetCount: item.turnTimingTotalElapsedZeroResetCount, turnTimingTotalElapsedForwardJumpCount: item.turnTimingTotalElapsedForwardJumpCount, turnTimingTotalElapsedForwardJumpMaxSeconds: item.turnTimingTotalElapsedForwardJumpMaxSeconds, turnTimingRecentUpdateJumpCount: item.turnTimingRecentUpdateJumpCount, turnTimingRecentUpdateMaxIncreaseSeconds: item.turnTimingRecentUpdateMaxIncreaseSeconds })), + rounds: recentWindow.sampleMetrics.rounds.slice(-8).map((item) => ({ promptIndex: item.promptIndex, promptTextHash: item.promptTextHash, sampleCount: item.sampleCount, firstSeq: item.firstSeq, lastSeq: item.lastSeq, lastTotalElapsedSeconds: item.lastTotalElapsedSeconds, lastRecentUpdateSeconds: item.lastRecentUpdateSeconds, loadingSamples: item.loadingSamples, maxLoadingCount: item.maxLoadingCount, loadingOwnerCount: item.loadingOwnerCount, diagnosticSamples: item.diagnosticSamples, terminalSamples: item.terminalSamples, finalTextSamples: item.finalTextSamples, turnTimingTotalElapsedZeroResetCount: item.turnTimingTotalElapsedZeroResetCount, turnTimingTotalElapsedForwardJumpCount: item.turnTimingTotalElapsedForwardJumpCount, turnTimingTotalElapsedForwardJumpMaxSeconds: item.turnTimingTotalElapsedForwardJumpMaxSeconds, turnTimingRecentUpdateJumpCount: item.turnTimingRecentUpdateJumpCount, turnTimingRecentUpdateMaxIncreaseSeconds: item.turnTimingRecentUpdateMaxIncreaseSeconds })), turnColumns: recentWindow.sampleMetrics.turnColumns.slice(-12).map((item) => ({ label: item.label, source: item.source, promptIndex: item.promptIndex, lastPromptIndex: item.lastPromptIndex, firstSeq: item.firstSeq, lastSeq: item.lastSeq, traceId: item.traceId, messageId: item.messageId })), + loading: compactLoadingMetricsForOutput(recentWindow.sampleMetrics.loading), }, pageProvenance: recentWindow.pageProvenance.summary, pagePerformance: recentWindow.pagePerformance.summary, @@ -1472,6 +1561,7 @@ function compactSampleForAnalysis(sample) { activeSessionId: sample.activeSessionId ?? null, messages: compactDomItems(sample.messages), traceRows: compactDomItems(sample.traceRows), + loadings: compactLoadingItems(sample.loadings), turns: compactDomItems(sample.turns), diagnostics: compactDomItems(sample.diagnostics), pageProvenance: compactSamplePageProvenance(sample.pageProvenance), @@ -1479,6 +1569,41 @@ function compactSampleForAnalysis(sample) { }; } +function compactLoadingItems(items) { + if (!Array.isArray(items)) return []; + return items.map((item) => { + if (!item || typeof item !== "object") return item; + const rawText = String(item.text ?? item.textPreview ?? ""); + return { + index: item.index ?? null, + tag: item.tag ?? null, + testId: item.testId ?? null, + role: item.role ?? null, + ownerKind: item.ownerKind ?? null, + ownerKey: item.ownerKey ?? null, + ownerLabel: item.ownerLabel ?? null, + owner: item.owner && typeof item.owner === "object" ? { + tag: item.owner.tag ?? null, + testId: item.owner.testId ?? null, + role: item.owner.role ?? null, + id: item.owner.id ?? null, + className: item.owner.className ?? null, + status: item.owner.status ?? null, + sessionId: item.owner.sessionId ?? null, + messageId: item.owner.messageId ?? null, + traceId: item.owner.traceId ?? null, + ariaLabel: item.owner.ariaLabel ?? null, + } : null, + text: limitText(rawText, 400), + textPreview: limitText(String(item.textPreview ?? rawText), 240), + textHash: item.textHash ?? sha256(rawText), + textBytes: item.textBytes ?? Buffer.byteLength(rawText), + ownerTextHash: item.ownerTextHash ?? null, + ownerTextPreview: item.ownerTextPreview ? limitText(item.ownerTextPreview, 240) : null, + }; + }); +} + function compactDomItems(items) { if (!Array.isArray(items)) return []; return items.map(compactDomItem); @@ -1537,6 +1662,17 @@ function compactPerformanceItems(items) { })); } +function compactLoadingMetricsForOutput(value) { + if (!value || typeof value !== "object") return null; + return { + summary: value.summary ?? null, + longestSegments: Array.isArray(value.segments) ? value.segments.slice(0, 8) : [], + owners: Array.isArray(value.owners) ? value.owners.slice(0, 8) : [], + timeline: Array.isArray(value.timeline) ? value.timeline.slice(-12) : [], + valuesRedacted: true + }; +} + function compactSamplePageProvenance(value) { if (!value || typeof value !== "object") return null; return { @@ -1599,6 +1735,9 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN ? sampleMetrics.turnTimingNonMonotonic.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump") : []; if (recentUpdateSawtoothJumps.length > 0) findings.push({ id: "turn-timing-recent-update-sawtooth-jump", severity: "amber", summary: "最近更新 value jumped faster than sample interval; expected sawtooth increase-or-reset", count: recentUpdateSawtoothJumps.length, samples: recentUpdateSawtoothJumps.slice(0, 20) }); + const loadingSummary = sampleMetrics?.loading?.summary || {}; + if (Number(loadingSummary.longestContinuousSeconds ?? 0) > 5) findings.push({ id: "page-loading-visible-over-5s", severity: "red", summary: "visible 加载中 stayed on screen longer than 5s; fix real loading latency instead of revealing incomplete content early", count: loadingSummary.overFiveSecondSegmentCount ?? 1, longestContinuousSeconds: loadingSummary.longestContinuousSeconds, segments: sampleMetrics.loading.segments.slice(0, 20), owners: sampleMetrics.loading.owners.slice(0, 20) }); + if (Number(loadingSummary.maxSimultaneousCount ?? 0) > 1) findings.push({ id: "page-loading-concurrent", severity: "info", summary: "multiple 加载中 indicators were visible in the same sampled DOM point", count: loadingSummary.concurrentLoadingSampleCount ?? 0, maxSimultaneousCount: loadingSummary.maxSimultaneousCount, owners: sampleMetrics.loading.owners.slice(0, 20) }); if ((runtimeAlerts?.summary?.httpErrorCount ?? 0) > 0) findings.push({ id: "runtime-http-errors", severity: "amber", summary: "natural page requests returned HTTP error status during observation", count: runtimeAlerts.summary.httpErrorCount, groups: runtimeAlerts.networkHttpErrorsByPath.slice(0, 12) }); if ((runtimeAlerts?.summary?.requestFailedCount ?? 0) > 0) findings.push({ id: "runtime-requestfailed", severity: "amber", summary: "browser requestfailed events were captured during observation", count: runtimeAlerts.summary.requestFailedCount, groups: runtimeAlerts.networkRequestFailedByPath.slice(0, 12) }); if ((runtimeAlerts?.summary?.domDiagnosticSampleCount ?? 0) > 0) findings.push({ id: "runtime-dom-diagnostics", severity: "amber", summary: "diagnostic/error/warning-like text was visible in sampled DOM", count: runtimeAlerts.summary.domDiagnosticSampleCount, groupCount: runtimeAlerts.summary.domDiagnosticGroupCount ?? 0, groups: runtimeAlerts.domDiagnosticsByText.slice(0, 12), samples: runtimeAlerts.domDiagnostics.slice(0, 12) }); @@ -2562,6 +2701,8 @@ function buildSampleMetrics(samples, control) { const diagnosticTexts = texts.filter(isDiagnosticText).slice(0, 5); const terminalTexts = texts.filter(isTerminalTraceText).slice(0, 5); const finalResultTexts = texts.filter(isFinalResultText).slice(0, 5); + const loadings = Array.isArray(sample.loadings) ? sample.loadings : []; + const loadingOwners = uniqueLoadingOwners(loadings); return { seq: sample.seq ?? null, ts: sample.ts ?? null, @@ -2570,6 +2711,9 @@ function buildSampleMetrics(samples, control) { promptIndex, messageCount: Array.isArray(sample.messages) ? sample.messages.length : 0, traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : 0, + loadingCount: loadings.length, + loadingOwnerCount: loadingOwners.length, + loadingOwners: loadingOwners.map((item) => ({ ownerKey: item.ownerKey, ownerKind: item.ownerKind, ownerLabel: item.ownerLabel, count: item.count })).slice(0, 12), totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null, recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null, terminalSeen: terminalTexts.length > 0, @@ -2605,6 +2749,7 @@ function buildSampleMetrics(samples, control) { 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 loading = buildLoadingMetrics(samples, timeline); const rounds = buildRoundMetricSummaries(timeline, promptCommands, { nonMonotonic: turnTimingNonMonotonic, elapsedZeroResets: turnTimingElapsedZeroResets, @@ -2620,6 +2765,14 @@ function buildSampleMetrics(samples, control) { withTotalElapsed: withTotal, withRecentUpdate: withRecent, diagnostics, + loadingSampleCount: loading.summary.loadingSampleCount, + loadingMaxCount: loading.summary.maxSimultaneousCount, + loadingMaxOwnerCount: loading.summary.maxSimultaneousOwnerCount, + loadingOwnerCount: loading.summary.ownerCount, + loadingConcurrentSampleCount: loading.summary.concurrentLoadingSampleCount, + loadingLongestContinuousSeconds: loading.summary.longestContinuousSeconds, + loadingCurrentContinuousSeconds: loading.summary.currentContinuousSeconds, + loadingOverFiveSecondSegmentCount: loading.summary.overFiveSecondSegmentCount, promptSegments: Math.max(0, promptTimes.length), rounds: rounds.length, turnColumns: turnTiming.columns.length, @@ -2645,6 +2798,7 @@ function buildSampleMetrics(samples, control) { roundsWithTerminalElapsedGrowth: rounds.filter((item) => item.turnTimingTerminalElapsedGrowthCount > 0).length, roundsWithRecentUpdateJumps: rounds.filter((item) => item.turnTimingRecentUpdateJumpCount > 0).length }, + loading, rounds, turnColumns: turnTiming.columns, turnTimingTable: turnTiming.rows, @@ -2660,6 +2814,231 @@ function buildSampleMetrics(samples, control) { }; } +function uniqueLoadingOwners(loadings) { + const groups = new Map(); + for (let index = 0; index < (Array.isArray(loadings) ? loadings : []).length; index += 1) { + const item = loadings[index]; + const ownerKey = loadingOwnerKey(item, index); + const existing = groups.get(ownerKey) || { + ownerKey, + ownerKind: item?.ownerKind ?? "unknown", + ownerLabel: loadingOwnerLabel(item, ownerKey), + count: 0, + textHashes: [] + }; + existing.count += 1; + if (item?.textHash && !existing.textHashes.includes(item.textHash)) existing.textHashes.push(item.textHash); + groups.set(ownerKey, existing); + } + return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.ownerLabel).localeCompare(String(b.ownerLabel))); +} + +function loadingOwnerKey(item, index = 0) { + const key = String(item?.ownerKey || "").trim(); + if (key) return key.slice(0, 240); + const owner = item?.owner && typeof item.owner === "object" ? item.owner : {}; + return [ + item?.ownerKind || "unknown", + owner.testId || item?.testId || owner.id || owner.role || owner.className || item?.role || item?.tag || "node", + owner.sessionId || owner.messageId || owner.traceId || item?.textHash || String(index) + ].filter(Boolean).join(":").slice(0, 240); +} + +function loadingOwnerLabel(item, fallback) { + return limitText(String(item?.ownerLabel || item?.owner?.ariaLabel || item?.owner?.testId || item?.owner?.className || fallback || "unknown"), 160); +} + +function buildLoadingMetrics(samples, timeline) { + const events = samples.map((sample, index) => { + const tsMs = Date.parse(sample?.ts); + const loadings = Array.isArray(sample?.loadings) ? sample.loadings : []; + const owners = uniqueLoadingOwners(loadings); + return { + seq: sample?.seq ?? null, + ts: sample?.ts ?? null, + tsMs, + promptIndex: timeline[index]?.promptIndex ?? 0, + routeSessionId: sample?.routeSessionId ?? null, + activeSessionId: sample?.activeSessionId ?? null, + loadingCount: loadings.length, + ownerCount: owners.length, + owners, + ownerKeys: owners.map((item) => item.ownerKey), + ownerLabels: owners.map((item) => item.ownerLabel).slice(0, 8) + }; + }).filter((item) => Number.isFinite(item.tsMs)); + const continuityThresholdMs = loadingContinuityThresholdMs(events); + const segments = buildLoadingSegments(events, continuityThresholdMs, (event) => event.loadingCount, (event) => event.owners) + .sort((a, b) => Number(b.durationSeconds ?? 0) - Number(a.durationSeconds ?? 0) || Number(b.maxCount ?? 0) - Number(a.maxCount ?? 0)); + const ownerMap = new Map(); + for (const event of events) { + for (const owner of event.owners) { + const existing = ownerMap.get(owner.ownerKey) || { + ownerKey: owner.ownerKey, + ownerKind: owner.ownerKind, + ownerLabel: owner.ownerLabel, + sampleCount: 0, + occurrenceCount: 0, + maxSimultaneousCount: 0, + firstAt: event.ts, + lastAt: event.ts, + firstSeq: event.seq, + lastSeq: event.seq, + promptIndexes: new Set(), + events: [] + }; + existing.sampleCount += 1; + existing.occurrenceCount += owner.count; + existing.maxSimultaneousCount = Math.max(existing.maxSimultaneousCount, owner.count); + existing.lastAt = event.ts; + existing.lastSeq = event.seq; + if (Number.isFinite(Number(event.promptIndex))) existing.promptIndexes.add(Number(event.promptIndex)); + existing.events.push({ ...event, loadingCount: owner.count, owners: [owner] }); + ownerMap.set(owner.ownerKey, existing); + } + } + const owners = Array.from(ownerMap.values()).map((owner) => { + const ownerSegments = buildLoadingSegments(owner.events, continuityThresholdMs, (event) => event.loadingCount, (event) => event.owners); + const longest = ownerSegments.reduce((max, item) => Math.max(max, Number(item.durationSeconds ?? 0)), 0); + return { + ownerKey: owner.ownerKey, + ownerKind: owner.ownerKind, + ownerLabel: owner.ownerLabel, + sampleCount: owner.sampleCount, + occurrenceCount: owner.occurrenceCount, + maxSimultaneousCount: owner.maxSimultaneousCount, + longestContinuousSeconds: longest, + firstAt: owner.firstAt, + lastAt: owner.lastAt, + firstSeq: owner.firstSeq, + lastSeq: owner.lastSeq, + promptIndexes: Array.from(owner.promptIndexes).sort((a, b) => a - b), + segments: ownerSegments.sort((a, b) => Number(b.durationSeconds ?? 0) - Number(a.durationSeconds ?? 0)).slice(0, 8), + valuesRedacted: true + }; + }).sort((a, b) => Number(b.longestContinuousSeconds ?? 0) - Number(a.longestContinuousSeconds ?? 0) || Number(b.occurrenceCount ?? 0) - Number(a.occurrenceCount ?? 0)); + const latest = events[events.length - 1] || null; + const currentSegment = latest && latest.loadingCount > 0 + ? segments.find((segment) => segment.ongoing === true && segment.lastSeq === latest.seq) || null + : null; + const timelineRows = events + .filter((event, index) => event.loadingCount > 0 || (index > 0 && events[index - 1]?.loadingCount > 0)) + .slice(0, 500) + .map((event) => ({ + seq: event.seq, + ts: event.ts, + promptIndex: event.promptIndex, + loadingCount: event.loadingCount, + ownerCount: event.ownerCount, + owners: event.owners.map((owner) => ({ ownerKind: owner.ownerKind, ownerLabel: owner.ownerLabel, count: owner.count })).slice(0, 8) + })); + return { + summary: { + sampleCount: events.length, + loadingSampleCount: events.filter((event) => event.loadingCount > 0).length, + maxSimultaneousCount: events.reduce((max, event) => Math.max(max, event.loadingCount), 0), + maxSimultaneousOwnerCount: events.reduce((max, event) => Math.max(max, event.ownerCount), 0), + concurrentLoadingSampleCount: events.filter((event) => event.loadingCount > 1).length, + ownerCount: owners.length, + segmentCount: segments.length, + overFiveSecondSegmentCount: segments.filter((segment) => Number(segment.durationSeconds ?? 0) > 5).length, + longestContinuousSeconds: segments.length > 0 ? Number(segments[0].durationSeconds ?? 0) : 0, + currentContinuousSeconds: currentSegment ? Number(currentSegment.durationSeconds ?? 0) : 0, + continuityThresholdMs, + latestLoadingCount: latest?.loadingCount ?? 0, + latestOwnerCount: latest?.ownerCount ?? 0, + valuesRedacted: true + }, + segments: segments.slice(0, 80), + owners: owners.slice(0, 80), + timeline: timelineRows, + valuesRedacted: true + }; +} + +function loadingContinuityThresholdMs(events) { + const deltas = []; + for (let index = 1; index < events.length; index += 1) { + const delta = events[index].tsMs - events[index - 1].tsMs; + if (Number.isFinite(delta) && delta > 0) deltas.push(delta); + } + if (deltas.length === 0) return 15000; + const sorted = deltas.slice().sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + return Math.max(15000, Math.round(median * 2.5)); +} + +function buildLoadingSegments(events, continuityThresholdMs, countForEvent, ownersForEvent) { + const segments = []; + let segment = null; + let previousTsMs = null; + for (const event of events) { + const count = Number(countForEvent(event) ?? 0); + const gapOk = previousTsMs === null || !Number.isFinite(event.tsMs) || event.tsMs - previousTsMs <= continuityThresholdMs; + if (count > 0) { + if (!segment || !gapOk) { + if (segment) segments.push(finalizeLoadingSegment(segment, null)); + segment = { + firstAt: event.ts, + lastAt: event.ts, + firstSeq: event.seq, + lastSeq: event.seq, + promptIndexes: new Set(), + ownerKeys: new Set(), + ownerLabels: new Map(), + sampleCount: 0, + maxCount: 0, + ongoing: true + }; + } + segment.lastAt = event.ts; + segment.lastSeq = event.seq; + segment.sampleCount += 1; + segment.maxCount = Math.max(segment.maxCount, count); + if (Number.isFinite(Number(event.promptIndex))) segment.promptIndexes.add(Number(event.promptIndex)); + for (const owner of ownersForEvent(event) || []) { + if (!owner?.ownerKey) continue; + segment.ownerKeys.add(owner.ownerKey); + if (!segment.ownerLabels.has(owner.ownerKey)) segment.ownerLabels.set(owner.ownerKey, { ownerKey: owner.ownerKey, ownerKind: owner.ownerKind, ownerLabel: owner.ownerLabel, count: 0 }); + const label = segment.ownerLabels.get(owner.ownerKey); + label.count += owner.count ?? 1; + } + } else if (segment) { + segment.ongoing = false; + segment.endedAt = event.ts; + segment.endSeq = event.seq; + segments.push(finalizeLoadingSegment(segment, event)); + segment = null; + } + previousTsMs = event.tsMs; + } + if (segment) segments.push(finalizeLoadingSegment(segment, null)); + return segments; +} + +function finalizeLoadingSegment(segment, absentEvent) { + const startMs = Date.parse(segment.firstAt || ""); + const endAnchor = absentEvent?.ts || segment.lastAt; + const endMs = Date.parse(endAnchor || ""); + const durationSeconds = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs ? Number(((endMs - startMs) / 1000).toFixed(3)) : 0; + return { + firstAt: segment.firstAt, + lastAt: segment.lastAt, + endedAt: absentEvent?.ts ?? null, + firstSeq: segment.firstSeq, + lastSeq: segment.lastSeq, + endSeq: absentEvent?.seq ?? null, + durationSeconds, + sampleCount: segment.sampleCount, + maxCount: segment.maxCount, + ownerCount: segment.ownerKeys.size, + owners: Array.from(segment.ownerLabels.values()).sort((a, b) => b.count - a.count || String(a.ownerLabel).localeCompare(String(b.ownerLabel))).slice(0, 12), + promptIndexes: Array.from(segment.promptIndexes).sort((a, b) => a - b), + ongoing: absentEvent ? false : segment.ongoing === true, + valuesRedacted: true + }; +} + function buildTurnTimingTable(samples, timeline) { const columns = []; const registry = new Map(); @@ -3034,6 +3413,13 @@ function buildRoundMetricSummaries(timeline, promptCommands, timing = {}) { 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); + const loadingCounts = items.map((item) => Number(item.loadingCount ?? 0)).filter(Number.isFinite); + const loadingOwners = new Set(); + for (const item of items) { + for (const owner of Array.isArray(item.loadingOwners) ? item.loadingOwners : []) { + if (owner?.ownerKey) loadingOwners.add(owner.ownerKey); + } + } const timingAnomalies = nonMonotonic.filter((item) => item.promptIndex === promptIndex); const timingForwardJumps = totalElapsedForwardJumps.filter((item) => item.promptIndex === promptIndex); const timingZeroResets = elapsedZeroResets.filter((item) => item.promptIndex === promptIndex); @@ -3056,6 +3442,9 @@ function buildRoundMetricSummaries(timeline, promptCommands, timing = {}) { lastSampleAt: items[items.length - 1]?.ts ?? null, withTotalElapsed: totalElapsed.length, withRecentUpdate: recentUpdate.length, + loadingSamples: loadingCounts.filter((value) => value > 0).length, + maxLoadingCount: loadingCounts.length > 0 ? Math.max(...loadingCounts) : 0, + loadingOwnerCount: loadingOwners.size, maxTotalElapsedSeconds: totalElapsed.length > 0 ? Math.max(...totalElapsed) : null, lastTotalElapsedSeconds: lastNonNull(items.map((item) => item.totalElapsedSeconds)), maxRecentUpdateSeconds: recentUpdate.length > 0 ? Math.max(...recentUpdate) : null, @@ -3287,6 +3676,8 @@ function renderMarkdown(report) { 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 loading = report.sampleMetrics?.loading || {}; + const loadingSummary = loading.summary || {}; const alertSummary = report.runtimeAlerts?.summary || {}; const httpAlertLines = Array.isArray(report.runtimeAlerts?.networkHttpErrorsByPath) && report.runtimeAlerts.networkHttpErrorsByPath.length > 0 ? report.runtimeAlerts.networkHttpErrorsByPath.slice(0, 40).map((item) => "- HTTP " + (item.status ?? "-") + " " + item.method + " " + item.urlPath + " count=" + item.count + " prompts=" + (item.promptIndexes?.join(",") || "-") + " first=" + (item.firstAt || "-") + " last=" + (item.lastAt || "-")).join("\n") @@ -3307,8 +3698,17 @@ function renderMarkdown(report) { ? report.promptNetwork.rounds.map((item) => "- round " + item.promptIndex + " promptHash=" + (item.promptTextHash || "-") + " chatPostOk=" + String(item.chatPostOk) + " failure=" + (item.failureKind || "-") + " statuses=" + (Array.isArray(item.responseStatuses) && item.responseStatuses.length > 0 ? item.responseStatuses.join(",") : "-") + " firstChat=" + (item.firstChatEventAt || "-") + " lastChat=" + (item.lastChatEventAt || "-")).join("\n") : "- 无 prompt 网络记录。"; 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 ?? "-") + " totalDecrease=" + (item.turnTimingTotalElapsedDecreaseCount ?? 0) + " totalForwardJump=" + (item.turnTimingTotalElapsedForwardJumpCount ?? 0) + " totalForwardJumpMax=" + (item.turnTimingTotalElapsedForwardJumpMaxSeconds ?? 0) + " terminalGrowth=" + (item.turnTimingTerminalElapsedGrowthCount ?? 0) + " terminalGrowthMax=" + (item.turnTimingTerminalElapsedGrowthMaxSeconds ?? 0) + " recentJump=" + (item.turnTimingRecentUpdateJumpCount ?? 0) + " recentSawtoothJump=" + (item.turnTimingRecentUpdateSawtoothJumpCount ?? item.turnTimingRecentUpdateJumpCount ?? 0) + " recentStep=" + (item.turnTimingRecentUpdateStepCount ?? 0) + " recentMaxIncrease=" + (item.turnTimingRecentUpdateMaxIncreaseSeconds ?? "-") + " recentMaxExcess=" + (item.turnTimingRecentUpdateMaxExcessSeconds ?? 0) + " recentReset=" + (item.turnTimingRecentUpdateResetCount ?? 0) + " diagnostics=" + item.diagnosticSamples + " terminal=" + item.terminalSamples + " finalText=" + item.finalTextSamples).join("\n") + ? report.sampleMetrics.rounds.map((item) => "- round " + item.promptIndex + " promptHash=" + (item.promptTextHash || "-") + " samples=" + item.sampleCount + " loadingSamples=" + (item.loadingSamples ?? 0) + " maxLoading=" + (item.maxLoadingCount ?? 0) + " loadingOwners=" + (item.loadingOwnerCount ?? 0) + " totalMax=" + (item.maxTotalElapsedSeconds ?? "-") + " totalLast=" + (item.lastTotalElapsedSeconds ?? "-") + " recentMax=" + (item.maxRecentUpdateSeconds ?? "-") + " recentLast=" + (item.lastRecentUpdateSeconds ?? "-") + " totalDecrease=" + (item.turnTimingTotalElapsedDecreaseCount ?? 0) + " totalForwardJump=" + (item.turnTimingTotalElapsedForwardJumpCount ?? 0) + " totalForwardJumpMax=" + (item.turnTimingTotalElapsedForwardJumpMaxSeconds ?? 0) + " terminalGrowth=" + (item.turnTimingTerminalElapsedGrowthCount ?? 0) + " terminalGrowthMax=" + (item.turnTimingTerminalElapsedGrowthMaxSeconds ?? 0) + " recentJump=" + (item.turnTimingRecentUpdateJumpCount ?? 0) + " recentSawtoothJump=" + (item.turnTimingRecentUpdateSawtoothJumpCount ?? item.turnTimingRecentUpdateJumpCount ?? 0) + " recentStep=" + (item.turnTimingRecentUpdateStepCount ?? 0) + " recentMaxIncrease=" + (item.turnTimingRecentUpdateMaxIncreaseSeconds ?? "-") + " recentMaxExcess=" + (item.turnTimingRecentUpdateMaxExcessSeconds ?? 0) + " recentReset=" + (item.turnTimingRecentUpdateResetCount ?? 0) + " diagnostics=" + item.diagnosticSamples + " terminal=" + item.terminalSamples + " finalText=" + item.finalTextSamples).join("\n") : "- 无轮次指标。"; + const loadingSegmentLines = Array.isArray(loading.segments) && loading.segments.length > 0 + ? loading.segments.slice(0, 80).map((item) => "- duration=" + (item.durationSeconds ?? 0) + "s countMax=" + (item.maxCount ?? 0) + " owners=" + (item.ownerCount ?? 0) + " seq=" + (item.firstSeq ?? "-") + ".." + (item.lastSeq ?? "-") + " ts=" + (item.firstAt || "-") + ".." + (item.lastAt || "-") + " endedAt=" + (item.endedAt || (item.ongoing ? "ongoing" : "-")) + " ownerLabels=" + ((Array.isArray(item.owners) ? item.owners : []).slice(0, 6).map((owner) => (owner.ownerKind || "-") + ":" + (owner.ownerLabel || "-") + "x" + (owner.count ?? 0)).join(",") || "-")).join("\n") + : "- 未观察到“加载中”可见区间。"; + const loadingOwnerLines = Array.isArray(loading.owners) && loading.owners.length > 0 + ? loading.owners.slice(0, 80).map((item) => "- " + (item.ownerKind || "-") + " " + escapeMarkdownCell(item.ownerLabel || item.ownerKey || "-") + " samples=" + (item.sampleCount ?? 0) + " occurrences=" + (item.occurrenceCount ?? 0) + " maxCount=" + (item.maxSimultaneousCount ?? 0) + " longest=" + (item.longestContinuousSeconds ?? 0) + "s seq=" + (item.firstSeq ?? "-") + ".." + (item.lastSeq ?? "-") + " prompts=" + (Array.isArray(item.promptIndexes) ? item.promptIndexes.join(",") : "-")).join("\n") + : "- 未观察到“加载中”归属。"; + const loadingTimelineLines = Array.isArray(loading.timeline) && loading.timeline.length > 0 + ? loading.timeline.slice(0, 160).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " prompt=" + (item.promptIndex ?? "-") + " loadingCount=" + (item.loadingCount ?? 0) + " ownerCount=" + (item.ownerCount ?? 0) + " owners=" + ((Array.isArray(item.owners) ? item.owners : []).slice(0, 6).map((owner) => (owner.ownerKind || "-") + ":" + (owner.ownerLabel || "-") + "x" + (owner.count ?? 0)).join(",") || "-")).join("\n") + : "- 未观察到“加载中”采样点。"; const provenanceLines = Array.isArray(report.pageProvenance?.segments) && report.pageProvenance.segments.length > 0 ? report.pageProvenance.segments.slice(0, 40).map((item) => "- fingerprint=" + (item.assetFingerprint || "-") + " samples=" + item.sampleCount + " seq=" + (item.firstSeq ?? "-") + ".." + (item.lastSeq ?? "-") + " ts=" + (item.firstAt || "-") + ".." + (item.lastAt || "-") + " scripts=" + (item.scriptCount ?? "-") + " styles=" + (item.stylesheetCount ?? "-") + " urlPaths=" + (Array.isArray(item.urlPaths) ? item.urlPaths.slice(0, 4).join(",") : "-")).join("\n") : "- 无页面 provenance segment。"; @@ -3316,7 +3716,7 @@ function renderMarkdown(report) { ? report.pagePerformance.sameOriginApiByPath.slice(0, 80).map((item) => "- " + item.path + " kind=" + (item.routeKind || "same-origin-api") + " budgetMetric=" + (item.budgetMetric || "durationMs") + " samples=" + item.sampleCount + " p50=" + (item.p50Ms ?? "-") + "ms p75=" + (item.p75Ms ?? "-") + "ms p95=" + (item.p95Ms ?? "-") + "ms max=" + (item.maxMs ?? "-") + "ms >5s=" + (item.overFiveSecondCount ?? 0) + " streamOpenP95=" + (item.streamOpenP95Ms ?? "-") + "ms streamLifetime>5s=" + (item.streamLifetimeOverFiveSecondCount ?? 0) + " window=" + (item.firstAt || "-") + ".." + (item.lastAt || "-")).join("\n") : "- 无同源 API Resource Timing 样本。"; 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 + " finalText=" + item.finalResultTextSeen + " diagnostic=" + item.diagnosticSeen).join("\n") + ? report.sampleMetrics.timeline.slice(0, 120).map((item) => "- #" + item.seq + " " + item.ts + " prompt=" + item.promptIndex + " loadingCount=" + (item.loadingCount ?? 0) + " loadingOwners=" + (item.loadingOwnerCount ?? 0) + " 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" @@ -3333,6 +3733,13 @@ function renderMarkdown(report) { + "- withTotalElapsed: " + (metricSummary.withTotalElapsed ?? 0) + "\n" + "- withRecentUpdate: " + (metricSummary.withRecentUpdate ?? 0) + "\n" + "- diagnostics: " + (metricSummary.diagnostics ?? 0) + "\n" + + "- loadingSamples: " + (metricSummary.loadingSampleCount ?? 0) + "\n" + + "- loadingMaxCount: " + (metricSummary.loadingMaxCount ?? 0) + "\n" + + "- loadingMaxOwnerCount: " + (metricSummary.loadingMaxOwnerCount ?? 0) + "\n" + + "- loadingOwnerCount: " + (metricSummary.loadingOwnerCount ?? 0) + "\n" + + "- loadingLongestContinuousSeconds: " + (metricSummary.loadingLongestContinuousSeconds ?? 0) + "\n" + + "- loadingCurrentContinuousSeconds: " + (metricSummary.loadingCurrentContinuousSeconds ?? 0) + "\n" + + "- loadingOverFiveSecondSegmentCount: " + (metricSummary.loadingOverFiveSecondSegmentCount ?? 0) + "\n" + "- promptSegments: " + (metricSummary.promptSegments ?? 0) + "\n\n" + "- turnColumns: " + (metricSummary.turnColumns ?? 0) + "\n" + "- turnTimingRows: " + (metricSummary.turnTimingRows ?? 0) + "\n" @@ -3349,6 +3756,21 @@ function renderMarkdown(report) { + "- turnTimingRecentUpdateMaxExcessSeconds: " + (metricSummary.turnTimingRecentUpdateMaxExcessSeconds ?? 0) + "\n" + "- turnTimingRecentUpdateResetCount: " + (metricSummary.turnTimingRecentUpdateResetCount ?? 0) + "\n\n" + "### Rounds\n\n" + roundLines + "\n\n" + + "### Loading visibility: visible 加载中\n\n" + + "- sampleCount: " + (loadingSummary.sampleCount ?? 0) + "\n" + + "- loadingSampleCount: " + (loadingSummary.loadingSampleCount ?? 0) + "\n" + + "- maxSimultaneousCount: " + (loadingSummary.maxSimultaneousCount ?? 0) + "\n" + + "- maxSimultaneousOwnerCount: " + (loadingSummary.maxSimultaneousOwnerCount ?? 0) + "\n" + + "- concurrentLoadingSampleCount: " + (loadingSummary.concurrentLoadingSampleCount ?? 0) + "\n" + + "- ownerCount: " + (loadingSummary.ownerCount ?? 0) + "\n" + + "- segmentCount: " + (loadingSummary.segmentCount ?? 0) + "\n" + + "- overFiveSecondSegmentCount: " + (loadingSummary.overFiveSecondSegmentCount ?? 0) + "\n" + + "- longestContinuousSeconds: " + (loadingSummary.longestContinuousSeconds ?? 0) + "\n" + + "- currentContinuousSeconds: " + (loadingSummary.currentContinuousSeconds ?? 0) + "\n" + + "- policy: 该指标只能证明用户真实看到“加载中”的持续时间;修复必须降低真实请求/投影/渲染耗时,禁止提前展示未加载完内容来压低该指标。\n\n" + + "#### Loading segments\n\n" + loadingSegmentLines + "\n\n" + + "#### Loading owners\n\n" + loadingOwnerLines + "\n\n" + + "#### Loading sample timeline\n\n" + loadingTimelineLines + "\n\n" + "### Page provenance\n\n" + "- segmentCount: " + (report.pageProvenance?.summary?.segmentCount ?? 0) + "\n" + "- controlSegmentCount: " + (report.pageProvenance?.summary?.controlSegmentCount ?? 0) + "\n\n"