@xiaobuyu/nodesign 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -106
- package/bin/nodesign.js +0 -0
- package/package.json +1 -1
- package/server/api/README.md +45 -45
- package/server/api/_guard.js +75 -75
- package/server/api/admin.js +187 -187
- package/server/api/assets/docx-page.js +103 -103
- package/server/api/assets/helpers.js +22 -22
- package/server/api/assets/notes.js +144 -144
- package/server/api/assets.js +896 -896
- package/server/api/board.js +57 -57
- package/server/api/browse.js +138 -138
- package/server/api/canvas.js +444 -444
- package/server/api/chatai.js +324 -324
- package/server/api/exports/build-standalone.js +980 -980
- package/server/api/exports/cards.js +118 -118
- package/server/api/exports/docx-pdf.js +46 -46
- package/server/api/exports/export-page.js +142 -142
- package/server/api/exports/handoff.js +147 -147
- package/server/api/exports.js +690 -690
- package/server/api/instruction.js +75 -75
- package/server/api/me.js +116 -116
- package/server/api/memory.js +185 -185
- package/server/api/pending-changes.js +366 -366
- package/server/api/plugins.js +164 -164
- package/server/api/projects.js +220 -220
- package/server/api/publish.js +64 -64
- package/server/api/recent.js +73 -73
- package/server/api/sessions.js +589 -589
- package/server/api/skills.js +77 -77
- package/server/api/standalone-fit.js +168 -168
- package/server/api/turn-compose.js +180 -180
- package/server/api/turn-inflight.js +44 -44
- package/server/auth/README.md +17 -17
- package/server/auth/middleware.js +138 -138
- package/server/auth/origin-guard.js +71 -71
- package/server/auth/session.js +108 -108
- package/server/auth/tier.js +113 -113
- package/server/auth/users-store.js +348 -348
- package/server/edit/README.md +29 -29
- package/server/engine/README.md +113 -113
- package/server/engine/agent/agent-shared.js +550 -550
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
- package/server/engine/agent/auto-mode-rules.js +78 -78
- package/server/engine/agent/context.js +418 -418
- package/server/engine/agent/events.js +464 -464
- package/server/engine/agent/hooks/canvas-validate.js +199 -199
- package/server/engine/agent/hooks/failure.js +120 -120
- package/server/engine/agent/hooks/file-events.js +75 -75
- package/server/engine/agent/hooks/lifecycle.js +233 -233
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
- package/server/engine/agent/hooks/post-guidance.js +206 -206
- package/server/engine/agent/hooks/post-subagent-report.js +104 -104
- package/server/engine/agent/hooks/post-trim.js +58 -58
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
- package/server/engine/agent/hooks/pre-defaults.js +84 -84
- package/server/engine/agent/hooks/pre-injectors.js +277 -277
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
- package/server/engine/agent/hooks/pre-starter-files.js +61 -61
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
- package/server/engine/agent/hooks/site-validate.js +94 -94
- package/server/engine/agent/hooks/tool-prompts.js +31 -31
- package/server/engine/agent/hooks/turn-state-memory.js +56 -56
- package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
- package/server/engine/agent/hooks.js +354 -354
- package/server/engine/agent/init-contract.js +69 -69
- package/server/engine/agent/isolation.js +159 -159
- package/server/engine/agent/model-table.js +292 -292
- package/server/engine/agent/plugin-loader.js +266 -266
- package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
- package/server/engine/agent/prompts/tools/site-reference.md +106 -106
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
- package/server/engine/agent/session-loop.js +1024 -1024
- package/server/engine/agent/session-model.js +164 -164
- package/server/engine/agent/skill.js +204 -204
- package/server/engine/agent/task-events.js +78 -78
- package/server/engine/agents/ds-extractor.md +132 -132
- package/server/engine/agents/explorer.md +190 -190
- package/server/engine/agents/index.js +230 -230
- package/server/engine/agents/schemas/design-system.json +156 -156
- package/server/engine/agents/schemas/tweak-schema.json +113 -113
- package/server/engine/agents/tweak-proposer.md +127 -127
- package/server/engine/agents/vision-checker.md +254 -254
- package/server/engine/browse/capture.js +422 -422
- package/server/engine/browse/card.js +137 -137
- package/server/engine/browse/handover.js +59 -59
- package/server/engine/browse/page-digest.js +119 -119
- package/server/engine/browse/refs.js +196 -196
- package/server/engine/browse/registry.js +307 -307
- package/server/engine/browse/screencast.js +170 -170
- package/server/engine/browse/state.js +121 -121
- package/server/engine/chatai/chat-log.js +79 -79
- package/server/engine/chatai/index.js +193 -193
- package/server/engine/chatai/openai-compat.js +152 -152
- package/server/engine/chatai/orchestrate.js +309 -309
- package/server/engine/chatai/perform.js +40 -40
- package/server/engine/chatai/summarize.js +79 -79
- package/server/engine/mcp/capability-gate.js +52 -52
- package/server/engine/mcp/index.js +339 -339
- package/server/engine/mcp/param-sanitizer.js +142 -142
- package/server/engine/mcp/tools/arrange-on-board.js +124 -124
- package/server/engine/mcp/tools/artifact-session.js +278 -278
- package/server/engine/mcp/tools/browse-computer.js +421 -421
- package/server/engine/mcp/tools/browse-find-batch.js +183 -183
- package/server/engine/mcp/tools/browse-screenshot.js +157 -157
- package/server/engine/mcp/tools/browse.js +480 -480
- package/server/engine/mcp/tools/build-docx.js +101 -101
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
- package/server/engine/mcp/tools/create-on-board.js +155 -155
- package/server/engine/mcp/tools/crystallize-skill.js +168 -168
- package/server/engine/mcp/tools/explain-style.js +237 -237
- package/server/engine/mcp/tools/export-handoff.js +133 -133
- package/server/engine/mcp/tools/expose-tweaks.js +149 -149
- package/server/engine/mcp/tools/generate-image.js +842 -842
- package/server/engine/mcp/tools/get-computed-styles.js +164 -164
- package/server/engine/mcp/tools/get-pending-changes.js +205 -205
- package/server/engine/mcp/tools/h3box-ssh.js +84 -84
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
- package/server/engine/mcp/tools/highlight.js +68 -68
- package/server/engine/mcp/tools/list-pages.js +222 -222
- package/server/engine/mcp/tools/lookup-tags.js +59 -59
- package/server/engine/mcp/tools/navigate-to-page.js +60 -60
- package/server/engine/mcp/tools/organize-board.js +64 -64
- package/server/engine/mcp/tools/paint-still.js +434 -434
- package/server/engine/mcp/tools/pin-to-board.js +130 -130
- package/server/engine/mcp/tools/preview-deck.js +130 -130
- package/server/engine/mcp/tools/profile-scroll.js +305 -305
- package/server/engine/mcp/tools/publish-site.js +102 -102
- package/server/engine/mcp/tools/query-elements.js +195 -195
- package/server/engine/mcp/tools/read-board.js +103 -103
- package/server/engine/mcp/tools/read-document.js +93 -93
- package/server/engine/mcp/tools/read-page.js +264 -264
- package/server/engine/mcp/tools/read-tavern-json.js +143 -143
- package/server/engine/mcp/tools/record-decision.js +140 -140
- package/server/engine/mcp/tools/relate-on-board.js +110 -110
- package/server/engine/mcp/tools/report-issue.js +115 -115
- package/server/engine/mcp/tools/roll-film.js +266 -266
- package/server/engine/mcp/tools/screenshot-docx.js +138 -138
- package/server/engine/mcp/tools/screenshot-url.js +195 -195
- package/server/engine/mcp/tools/screenshot.js +583 -583
- package/server/engine/mcp/tools/tier-gate.js +96 -96
- package/server/engine/mcp/tools/trace-motion.js +215 -215
- package/server/engine/mcp/tools/web-search.js +548 -548
- package/server/engine/motion/inventory.js +349 -349
- package/server/engine/perception/session.js +235 -235
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
- package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
- package/server/engine/runs/active-runs.js +698 -698
- package/server/engine/runs/live-turn.js +252 -252
- package/server/engine/runs/store.js +364 -364
- package/server/engine/runs/turn-relay.js +217 -217
- package/server/engine/runtime/workspace.js +164 -164
- package/server/lib/artifact-file-path.js +80 -80
- package/server/lib/artifact-target.js +284 -284
- package/server/lib/asset-refs.js +239 -239
- package/server/lib/async-queue.js +104 -104
- package/server/lib/auto-relations.js +130 -130
- package/server/lib/binding-types.js +60 -60
- package/server/lib/board-kind-sizes.js +62 -62
- package/server/lib/board-relations.js +133 -133
- package/server/lib/browse-proxy.js +180 -180
- package/server/lib/canvas-id.js +36 -36
- package/server/lib/cover.js +227 -227
- package/server/lib/danbooru-tags.js +291 -291
- package/server/lib/doc-extract.js +156 -156
- package/server/lib/docx/build-from-source.js +260 -260
- package/server/lib/docx/build.js +490 -490
- package/server/lib/docx/dump-styles.js +287 -287
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
- package/server/lib/docx/merge-runs.js +129 -129
- package/server/lib/docx/numbering.js +218 -218
- package/server/lib/docx/order.js +152 -152
- package/server/lib/docx/rawzip.js +172 -172
- package/server/lib/docx/render.js +92 -92
- package/server/lib/docx/text-lint.js +225 -225
- package/server/lib/docx/tokens.js +335 -335
- package/server/lib/docx/units.js +29 -29
- package/server/lib/docx/xml.js +291 -291
- package/server/lib/docx-pages.js +187 -187
- package/server/lib/export-collect.js +289 -289
- package/server/lib/export-package.js +210 -210
- package/server/lib/html-srcset.js +145 -145
- package/server/lib/image-variant.js +460 -460
- package/server/lib/ingress/forward-openai-chat.js +305 -305
- package/server/lib/ingress/openai-chat.js +491 -491
- package/server/lib/ingress/session-notice.js +66 -66
- package/server/lib/ingress/session-routes.js +68 -68
- package/server/lib/ingress/slot-probe.js +130 -130
- package/server/lib/ingress/upstream-billing.js +52 -52
- package/server/lib/ingress/upstream-fail-streak.js +72 -72
- package/server/lib/ingress/upstream-truncation.js +48 -48
- package/server/lib/issues-store.js +182 -182
- package/server/lib/kinds/deck.js +72 -72
- package/server/lib/kinds/docx.js +283 -283
- package/server/lib/kinds/file-kinds.js +113 -113
- package/server/lib/kinds/index.js +202 -202
- package/server/lib/kinds/site.js +161 -161
- package/server/lib/model-ingress.js +543 -543
- package/server/lib/moderation.js +255 -255
- package/server/lib/notice-store.js +103 -103
- package/server/lib/plugin-install.js +104 -104
- package/server/lib/plugin-validator.js +721 -721
- package/server/lib/publish-store.js +115 -115
- package/server/lib/quick-summary.js +52 -52
- package/server/lib/quota.js +270 -270
- package/server/lib/rate-window.js +29 -29
- package/server/lib/reference-assets.js +92 -92
- package/server/lib/region-shot.js +135 -135
- package/server/lib/safe-path.js +73 -73
- package/server/lib/sdk-session.js +33 -33
- package/server/lib/showcase-store.js +92 -92
- package/server/lib/site-publish.js +465 -465
- package/server/lib/ssrf-guard.js +432 -432
- package/server/lib/task-scan.js +158 -158
- package/server/lib/tavern-json.js +154 -154
- package/server/lib/video-variant.js +237 -237
- package/server/lib/workspace-path.js +33 -33
- package/server/ops/fix-sdk-musl.mjs +40 -40
- package/server/ops/install-macos-fonts.sh +114 -114
- package/server/ops/macos-fonts.conf +336 -336
- package/server/ops/sandbox-shim/bwrap +57 -57
- package/server/projects/assets-summary.js +119 -119
- package/server/projects/auto-name.js +53 -53
- package/server/projects/board-store.js +640 -640
- package/server/projects/move-entry.js +103 -103
- package/server/projects/store.js +270 -270
- package/server/projects/ui-config.js +54 -54
- package/server/projects/workspace-templates.js +53 -53
- package/server/projects/workspace.js +1025 -1025
- package/server/runtime/local-config.js +177 -177
- package/server/runtime/profile.js +72 -72
- package/server/services/rembg-service.py +334 -334
- package/server/shared/README.md +25 -25
- package/server/shared/deck.js +54 -54
- package/server/shared/time.js +49 -49
- package/server/style-pipeline/README.md +53 -53
- package/server/ws/broker.js +36 -36
- package/server/ws/browse-channel.js +177 -177
- package/server/ws/index.js +386 -386
- package/web/README.md +89 -89
- package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
- package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
- package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
- package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
- package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
- package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
- package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
- package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
- package/web/dist/index.html +17 -17
- package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
|
@@ -1,305 +1,305 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* mcp/tools/profile-scroll.js — profile_scroll(2026-08-18)
|
|
3
|
-
*
|
|
4
|
-
* ## 为什么有这个工具
|
|
5
|
-
*
|
|
6
|
-
* 用户说"往下滑动一顿一顿的",这在设计工具里是最常见的反馈之一。而在这之前
|
|
7
|
-
* 平台给 agent 的全部观测能力是「截一张静态图」—— 帧时、掉帧、图片体积、
|
|
8
|
-
* 长任务一个都看不到。一个 agent 的实际绕路是:起 `python3 -m http.server`
|
|
9
|
-
* → 从仓库 node_modules 直接 import playwright → 手写 rAF 采样器 → 逐项关掉
|
|
10
|
-
* 嫌疑元素做 A/B。它量出来的结论一个都猜不出来:基线 63% 掉帧,单独关视差
|
|
11
|
-
* 64%(没用),单独关全屏胶片颗粒层 40%,两个都关 5% —— 是**叠加效应**,
|
|
12
|
-
* 真凶是「33.7MB 的 PNG + 一个带 mix-blend-mode 的全屏 fixed 层」这对组合。
|
|
13
|
-
* 没有测量,它大概率只会把用户新抱怨的那个功能拆掉交差,剩下 40% 的掉帧
|
|
14
|
-
* 原封不动留在站里。
|
|
15
|
-
*
|
|
16
|
-
* ## 数字怎么读
|
|
17
|
-
*
|
|
18
|
-
* ⚠️ 这台机器 1 vCPU,而且截图 worker 跟 server 抢同一颗核。**绝对值不代表
|
|
19
|
-
* 用户设备上的体验**,它的用处是 A/B:改一处、再量一次、看方向。返回文本里
|
|
20
|
-
* 会重复这句话,别让 agent 拿绝对数字去跟用户汇报。
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
24
|
-
import { z } from 'zod';
|
|
25
|
-
import { resolveCanvasTarget, CANVAS_PATH_DESC, requireBrowsable } from '../../../lib/artifact-target.js';
|
|
26
|
-
import { openArtifactPage, launchPerceptionBrowser, degradedNote } from './helpers/perception-page.js';
|
|
27
|
-
|
|
28
|
-
const DEVICE_W = { desktop: 1440, tablet: 834, mobile: 390 };
|
|
29
|
-
const DEVICE_H = { desktop: 900, tablet: 1112, mobile: 844 };
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* 页面里跑的采样器:**只测量,不自己滚**。
|
|
33
|
-
*
|
|
34
|
-
* ⛔ 前两版都是页面自己 `window.scrollBy` 推进的,两次都测错,原因不同:
|
|
35
|
-
* v1「大跨步 + 每步 sleep」——合成器在步与步之间有空闲,坏页面照样报 3% 掉帧。
|
|
36
|
-
* v2「rAF 里逐帧小步推」——压力对了,但**滚动方式还是假的**:程序化滚动
|
|
37
|
-
* 不派发 wheel 事件,于是一整类最常见的"一顿一顿"完全隐形:
|
|
38
|
-
* wheel handler 里做视差、滚动劫持(scrolljacking)、平滑滚库
|
|
39
|
-
* (Locomotive/Lenis 那种自己接管滚动的)。用户的手指走的是 wheel 那条路。
|
|
40
|
-
* 而且 `scroll-behavior: smooth` 会把程序化滚动变成动画,实测让量测范围
|
|
41
|
-
* 悄悄缩到 3.5% —— 看着"滚完了",其实几乎没动。
|
|
42
|
-
* 现在:滚动由外面用 CDP 派发**真 wheel 事件**(≈60Hz),这里只逐帧记 rAF 间隔。
|
|
43
|
-
*
|
|
44
|
-
* ⭐ 顺带白拿一个诊断:派发了多少像素 vs 页面实际走了多少像素。差得远
|
|
45
|
-
* = 滚动被劫持了,这本身就是答案。
|
|
46
|
-
*/
|
|
47
|
-
/* eslint-disable no-undef */
|
|
48
|
-
function installSampler() {
|
|
49
|
-
const st = {
|
|
50
|
-
frames: [], longTasks: [], po: null, raf: 0, running: true,
|
|
51
|
-
startedAt: performance.now(), last: performance.now(),
|
|
52
|
-
container: null, startTop: 0,
|
|
53
|
-
};
|
|
54
|
-
window.__ndProf = st;
|
|
55
|
-
try {
|
|
56
|
-
st.po = new PerformanceObserver((l) => { for (const e of l.getEntries()) st.longTasks.push(e.duration); });
|
|
57
|
-
st.po.observe({ entryTypes: ['longtask'] });
|
|
58
|
-
} catch { /* 不支持就没有长任务数据 */ }
|
|
59
|
-
|
|
60
|
-
// 滚的可能不是 window:应用式版面常常是某个内层容器在滚。wheel 事件落在
|
|
61
|
-
// 指针下面那个元素上,所以内层容器**会**滚 —— 但 window.scrollY 不动,
|
|
62
|
-
// 按它判断就会误报"这页滚不动"。先认出真正在滚的那个。
|
|
63
|
-
const pickContainer = () => {
|
|
64
|
-
const cx = Math.floor(window.innerWidth / 2);
|
|
65
|
-
const cy = Math.floor(window.innerHeight / 2);
|
|
66
|
-
let el = document.elementFromPoint(cx, cy);
|
|
67
|
-
while (el && el !== document.body && el !== document.documentElement) {
|
|
68
|
-
const cs = getComputedStyle(el);
|
|
69
|
-
const scrollable = /(auto|scroll|overlay)/.test(`${cs.overflowY}`) && el.scrollHeight > el.clientHeight + 4;
|
|
70
|
-
if (scrollable) return el;
|
|
71
|
-
el = el.parentElement;
|
|
72
|
-
}
|
|
73
|
-
return document.scrollingElement || document.documentElement;
|
|
74
|
-
};
|
|
75
|
-
st.container = pickContainer();
|
|
76
|
-
st.startTop = st.container.scrollTop;
|
|
77
|
-
|
|
78
|
-
const tick = () => {
|
|
79
|
-
if (!st.running) return;
|
|
80
|
-
const now = performance.now();
|
|
81
|
-
st.frames.push(now - st.last);
|
|
82
|
-
st.last = now;
|
|
83
|
-
st.raf = requestAnimationFrame(tick);
|
|
84
|
-
};
|
|
85
|
-
st.raf = requestAnimationFrame(tick);
|
|
86
|
-
return {
|
|
87
|
-
scrollHeight: st.container.scrollHeight,
|
|
88
|
-
clientHeight: st.container.clientHeight,
|
|
89
|
-
isWindow: st.container === (document.scrollingElement || document.documentElement),
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function collectSampler() {
|
|
94
|
-
const st = window.__ndProf;
|
|
95
|
-
if (!st) return null;
|
|
96
|
-
st.running = false;
|
|
97
|
-
cancelAnimationFrame(st.raf);
|
|
98
|
-
try { st.po?.disconnect(); } catch { /* */ }
|
|
99
|
-
|
|
100
|
-
const imgs = performance.getEntriesByType('resource')
|
|
101
|
-
.filter(e => e.initiatorType === 'img' || /\.(png|jpe?g|webp|avif|gif|svg)(\?|$)/i.test(e.name))
|
|
102
|
-
.map(e => ({
|
|
103
|
-
name: decodeURIComponent(e.name.split('?')[0].split('/').pop() || '').slice(0, 60),
|
|
104
|
-
// encodedBodySize 是**这次传输**的字节;transferSize 命中缓存时是 0。
|
|
105
|
-
// 感知通道现在带 X-ND-Raw,拿的是磁盘原图,所以这个数字就是交付站点的数字。
|
|
106
|
-
bytes: e.encodedBodySize || e.transferSize || 0,
|
|
107
|
-
}));
|
|
108
|
-
imgs.sort((a, b) => b.bytes - a.bytes);
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
frames: st.frames.slice(2), // 头两帧含启动抖动
|
|
112
|
-
longTasks: st.longTasks,
|
|
113
|
-
elapsedMs: performance.now() - st.startedAt,
|
|
114
|
-
scrollHeight: st.container.scrollHeight,
|
|
115
|
-
clientHeight: st.container.clientHeight,
|
|
116
|
-
movedPx: st.container.scrollTop - st.startTop,
|
|
117
|
-
imgCount: imgs.length,
|
|
118
|
-
imgBytes: imgs.reduce((a, b) => a + b.bytes, 0),
|
|
119
|
-
topImgs: imgs.slice(0, 5),
|
|
120
|
-
layers: (() => {
|
|
121
|
-
// 「合成层嫌疑」:全屏定位 + 带混合模式/滤镜/半透明的那些,
|
|
122
|
-
// 就是那个真实案例里的另一半凶手
|
|
123
|
-
const out = [];
|
|
124
|
-
for (const el of document.querySelectorAll('*')) {
|
|
125
|
-
const cs = getComputedStyle(el);
|
|
126
|
-
if (cs.position !== 'fixed' && cs.position !== 'sticky') continue;
|
|
127
|
-
const r = el.getBoundingClientRect();
|
|
128
|
-
const big = r.width * r.height > window.innerWidth * window.innerHeight * 0.5;
|
|
129
|
-
const heavy = cs.mixBlendMode !== 'normal' || cs.filter !== 'none'
|
|
130
|
-
|| cs.backdropFilter !== 'none' || (cs.opacity !== '1' && cs.opacity !== '');
|
|
131
|
-
if (big && heavy) {
|
|
132
|
-
const id = el.id ? `#${el.id}` : (typeof el.className === 'string' && el.className.trim()
|
|
133
|
-
? `.${el.className.trim().split(/\s+/)[0]}` : el.tagName.toLowerCase());
|
|
134
|
-
out.push(`${id} [${cs.position}, blend=${cs.mixBlendMode}, filter=${cs.filter === 'none' ? '-' : 'yes'}, backdrop=${cs.backdropFilter === 'none' ? '-' : 'yes'}, opacity=${cs.opacity}]`);
|
|
135
|
-
}
|
|
136
|
-
if (out.length >= 5) break;
|
|
137
|
-
}
|
|
138
|
-
return out;
|
|
139
|
-
})(),
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
/* eslint-enable no-undef */
|
|
143
|
-
|
|
144
|
-
const pct = (arr, p) => {
|
|
145
|
-
if (!arr.length) return 0;
|
|
146
|
-
const s = [...arr].sort((a, b) => a - b);
|
|
147
|
-
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
|
148
|
-
};
|
|
149
|
-
const kb = (b) => (b >= 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`);
|
|
150
|
-
|
|
151
|
-
export function makeProfileScrollTool({ workspaceRoot, projectId, sessionId }) {
|
|
152
|
-
return tool(
|
|
153
|
-
'profile_scroll',
|
|
154
|
-
`Measure how a page actually performs while scrolling. Loads it over http
|
|
155
|
-
(same origin as the user preview), scrolls programmatically, and returns frame
|
|
156
|
-
timing, dropped-frame ratio, long tasks, total image bytes and the heaviest
|
|
157
|
-
images, plus any full-screen fixed/sticky layers with blend modes or filters.
|
|
158
|
-
|
|
159
|
-
Scrolling is driven by REAL wheel events, so this also catches the other common
|
|
160
|
-
cause of "one frame at a time": the page eating the wheel and scrolling itself
|
|
161
|
-
(scroll hijacking, Lenis/Locomotive-style smooth scroll). The report says how
|
|
162
|
-
many pixels were dispatched vs how many the page actually moved — if those
|
|
163
|
-
disagree, that is your answer and frame times are beside the point.
|
|
164
|
-
|
|
165
|
-
Use this the moment a user says scrolling feels heavy, janky, laggy or "one
|
|
166
|
-
frame at a time" — do NOT start deleting features to guess which one it is.
|
|
167
|
-
The one real case on record: baseline 63% dropped frames; killing the parallax
|
|
168
|
-
alone changed nothing (64%); killing the full-screen grain layer alone got it to
|
|
169
|
-
40%; killing both got 5%; hiding images got 3%. It was an additive effect
|
|
170
|
-
between 33.7MB of PNGs and one blended full-screen layer. Guessing would have
|
|
171
|
-
removed the wrong thing and left most of the jank in place.
|
|
172
|
-
|
|
173
|
-
Also worth running before you hand over any image-heavy page: total image bytes
|
|
174
|
-
is the single most common cause, and generate_image now writes a .webp sibling
|
|
175
|
-
next to every PNG for exactly this reason.
|
|
176
|
-
|
|
177
|
-
⚠️ This machine has 1 vCPU and the browser competes with the server for it. The
|
|
178
|
-
absolute numbers are NOT what the user's device will see. Use them for A/B:
|
|
179
|
-
measure, change one thing, measure again, compare direction. Never report the
|
|
180
|
-
raw percentage to the user as if it were their experience.`,
|
|
181
|
-
{
|
|
182
|
-
path: z.string().optional().describe(CANVAS_PATH_DESC),
|
|
183
|
-
device: z.enum(['desktop', 'tablet', 'mobile']).optional()
|
|
184
|
-
.describe('Viewport preset (default desktop 1440×900). Mobile is where jank shows up worst.'),
|
|
185
|
-
frames: z.number().int().min(30).max(300).optional()
|
|
186
|
-
.describe('How many wheel steps to scroll across (default 120 ≈ a 2-second scroll at 60Hz). Real wheel events are dispatched at ~60Hz, the same rate a trackpad fires — so wheel handlers, scroll hijacking and smooth-scroll libraries are all in the measurement.'),
|
|
187
|
-
},
|
|
188
|
-
async ({ path: relPath, device, frames: framesArg }) => {
|
|
189
|
-
const asText = (text) => ({ content: [{ type: 'text', text }] });
|
|
190
|
-
const target = await resolveCanvasTarget(workspaceRoot, relPath, sessionId);
|
|
191
|
-
if (!target) return { content: [{ type: 'text', text: 'No page found to profile.' }], isError: true };
|
|
192
|
-
const guard = requireBrowsable(target);
|
|
193
|
-
if (guard) return { content: [{ type: 'text', text: guard }], isError: true };
|
|
194
|
-
|
|
195
|
-
const dev = device || 'desktop';
|
|
196
|
-
const viewport = { width: DEVICE_W[dev], height: DEVICE_H[dev] };
|
|
197
|
-
const nFrames = framesArg ?? 120;
|
|
198
|
-
|
|
199
|
-
let browser;
|
|
200
|
-
try {
|
|
201
|
-
browser = await launchPerceptionBrowser();
|
|
202
|
-
const opened = await openArtifactPage(browser, {
|
|
203
|
-
projectId, workspaceRoot, absPath: target.absPath, viewport,
|
|
204
|
-
});
|
|
205
|
-
await opened.goto();
|
|
206
|
-
const page = opened.page;
|
|
207
|
-
|
|
208
|
-
// 采样器先装上(它顺手认出真正在滚的那个容器),再从外面派发真 wheel
|
|
209
|
-
const geom = await page.evaluate(installSampler);
|
|
210
|
-
const scrollable = Math.max(0, geom.scrollHeight - geom.clientHeight);
|
|
211
|
-
// 每帧推进量:把可滚范围在 nFrames 帧里走完。下限 6px —— 太小的话短页面
|
|
212
|
-
// 一直停在原地,样本全是静止帧(那种"满分"没有意义)。
|
|
213
|
-
const perFrame = Math.max(6, Math.round(scrollable / nFrames));
|
|
214
|
-
|
|
215
|
-
// ⭐ 真 wheel,配速 ≈60Hz。配速是必要的:不配速的话 CDP 一秒能塞几百个
|
|
216
|
-
// wheel,任何页面都会显得卡(假警报),而真人的滚轮/触摸板就是 60-120Hz。
|
|
217
|
-
await page.mouse.move(Math.floor(viewport.width / 2), Math.floor(viewport.height / 2));
|
|
218
|
-
const t0 = Date.now();
|
|
219
|
-
for (let i = 0; i < nFrames; i += 1) {
|
|
220
|
-
await page.mouse.wheel(0, perFrame);
|
|
221
|
-
const wait = (t0 + (i + 1) * 16) - Date.now();
|
|
222
|
-
if (wait > 1) await page.waitForTimeout(wait);
|
|
223
|
-
}
|
|
224
|
-
await page.waitForTimeout(120); // 让最后几帧和长任务落账
|
|
225
|
-
const r = await page.evaluate(collectSampler);
|
|
226
|
-
|
|
227
|
-
// ⛔ **不再过滤长帧**。原来是 `frames.filter(f => f > 0 && f < 2000)`,
|
|
228
|
-
// 于是一个 3017ms 的冻结**被当成脏数据剔掉**,剩下的帧算出 `dropped 0%`
|
|
229
|
-
// 且不作任何提示 —— 越卡越可能被删。A/B 甚至会反过来(把页面改得更卡
|
|
230
|
-
// 反而报得更好)。长帧正是要找的东西。
|
|
231
|
-
const frames = r.frames.filter(f => f > 0);
|
|
232
|
-
const dropped = frames.filter(f => f > 16.7 * 1.5).length;
|
|
233
|
-
const dropRatio = frames.length ? (dropped / frames.length) * 100 : 0;
|
|
234
|
-
const worst = frames.length ? Math.max(...frames) : 0;
|
|
235
|
-
const freezes = frames.filter(f => f > 250).length;
|
|
236
|
-
// 派发了多少 vs 真走了多少。差很多 = 滚动被劫持/被平滑滚库接管,
|
|
237
|
-
// 这本身就是"一顿一顿"的答案,不必再往下猜。
|
|
238
|
-
const askedPx = perFrame * nFrames;
|
|
239
|
-
const movedPx = Math.round(r.movedPx);
|
|
240
|
-
const followRatio = askedPx ? (movedPx / askedPx) * 100 : 100;
|
|
241
|
-
// 样本不足就说不足。原来这种情况报的是 `p50 0.0ms / dropped 0%` ——
|
|
242
|
-
// 一句"满分",而真相是**什么都没量到**(溢出面板、短页、滚不动的页)。
|
|
243
|
-
const tooFew = frames.length < 20;
|
|
244
|
-
// 判据是**跟随率**不是绝对位移:实测那个劫持页走了 960px(不算"没动"),
|
|
245
|
-
// 但只跟上了派发量的 13% —— 用户手上的感觉就是"滚了半天没动"。
|
|
246
|
-
// 60% 是留给平滑滚库正常惯性的余量。
|
|
247
|
-
const hijacked = askedPx > 400 && followRatio < 60;
|
|
248
|
-
|
|
249
|
-
// 契约:note 非空必须写进返回文本。这个工具尤其不能漏 —— 它的描述铁口
|
|
250
|
-
// 直断"走 http 同源",退回 file:// 后它按 Resource Timing 报的
|
|
251
|
-
// 「images 0 files 0KB」正好是它主诊断的反面。
|
|
252
|
-
const degraded = degradedNote(opened);
|
|
253
|
-
const lines = [
|
|
254
|
-
...(degraded ? [degraded, ''] : []),
|
|
255
|
-
`profile_scroll — ${target.relPath} @ ${dev} ${viewport.width}×${viewport.height}`,
|
|
256
|
-
`scroll container ${geom.isWindow ? 'window' : 'an inner element (overflow:auto)'}`
|
|
257
|
-
+ `, scrollable ${scrollable}px, real wheel events ${perFrame}px × ${nFrames} @60Hz`,
|
|
258
|
-
`moved ${movedPx}px of ${askedPx}px dispatched (${followRatio.toFixed(0)}% followed)`,
|
|
259
|
-
'',
|
|
260
|
-
...(tooFew ? [
|
|
261
|
-
`⚠ ONLY ${frames.length} frames sampled — that is not enough to say anything.`,
|
|
262
|
-
' Nothing below is a verdict. Usual causes: the page is barely taller than the',
|
|
263
|
-
' viewport, or the thing that scrolls is not under the centre of the screen.',
|
|
264
|
-
'',
|
|
265
|
-
] : []),
|
|
266
|
-
...(hijacked ? [
|
|
267
|
-
`⚠ ${nFrames} real wheel events asked for ${askedPx}px; the page moved ${movedPx}px (${followRatio.toFixed(0)}%).`,
|
|
268
|
-
' Something is EATING the scroll — a wheel handler doing custom scrolling,',
|
|
269
|
-
' scroll hijacking, or a smooth-scroll library (Lenis/Locomotive). That IS the',
|
|
270
|
-
' jank the user feels: their wheel and the page no longer agree. Look at wheel',
|
|
271
|
-
' listeners before you look at images or layers.',
|
|
272
|
-
'',
|
|
273
|
-
] : []),
|
|
274
|
-
`frame time p50 ${pct(frames, 50).toFixed(1)}ms p90 ${pct(frames, 90).toFixed(1)}ms p99 ${pct(frames, 99).toFixed(1)}ms (16.7ms = 60fps)`,
|
|
275
|
-
`dropped ${dropRatio.toFixed(0)}% of ${frames.length} frames took >25ms`,
|
|
276
|
-
`worst frame ${worst.toFixed(0)}ms${freezes ? ` · ${freezes} frame(s) over 250ms = a visible freeze` : ''}`,
|
|
277
|
-
`long tasks ${r.longTasks.length}${r.longTasks.length ? ` (worst ${Math.max(...r.longTasks).toFixed(0)}ms)` : ''}`,
|
|
278
|
-
'',
|
|
279
|
-
`images ${r.imgCount} files, ${kb(r.imgBytes)} total (as delivered: originals, not preview variants)`,
|
|
280
|
-
...(r.topImgs.length
|
|
281
|
-
? [`heaviest ${r.topImgs.map(i => `${i.name} ${kb(i.bytes)}`).join(' · ')}`]
|
|
282
|
-
: []),
|
|
283
|
-
...(r.layers.length
|
|
284
|
-
? ['', 'full-screen composited layers (a classic other half of the problem):',
|
|
285
|
-
...r.layers.map(l => ` ${l}`)]
|
|
286
|
-
: []),
|
|
287
|
-
'',
|
|
288
|
-
r.imgBytes > 5 * 1024 * 1024
|
|
289
|
-
? `⚠ ${kb(r.imgBytes)} of images is the first thing to fix — reference the .webp siblings instead of the PNG masters.`
|
|
290
|
-
: null,
|
|
291
|
-
dropRatio > 25 && r.layers.length
|
|
292
|
-
? '⚠ Both heavy layers and dropped frames present. These are usually ADDITIVE — test removing them one at a time AND together before concluding.'
|
|
293
|
-
: null,
|
|
294
|
-
'',
|
|
295
|
-
'Numbers are for A/B comparison on this 1-vCPU box, not the user\'s device.',
|
|
296
|
-
].filter(l => l !== null);
|
|
297
|
-
return asText(lines.join('\n'));
|
|
298
|
-
} catch (err) {
|
|
299
|
-
return { content: [{ type: 'text', text: `profile_scroll failed: ${err?.message || String(err)}` }], isError: true };
|
|
300
|
-
} finally {
|
|
301
|
-
await browser?.close().catch(() => {});
|
|
302
|
-
}
|
|
303
|
-
},
|
|
304
|
-
);
|
|
305
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* mcp/tools/profile-scroll.js — profile_scroll(2026-08-18)
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么有这个工具
|
|
5
|
+
*
|
|
6
|
+
* 用户说"往下滑动一顿一顿的",这在设计工具里是最常见的反馈之一。而在这之前
|
|
7
|
+
* 平台给 agent 的全部观测能力是「截一张静态图」—— 帧时、掉帧、图片体积、
|
|
8
|
+
* 长任务一个都看不到。一个 agent 的实际绕路是:起 `python3 -m http.server`
|
|
9
|
+
* → 从仓库 node_modules 直接 import playwright → 手写 rAF 采样器 → 逐项关掉
|
|
10
|
+
* 嫌疑元素做 A/B。它量出来的结论一个都猜不出来:基线 63% 掉帧,单独关视差
|
|
11
|
+
* 64%(没用),单独关全屏胶片颗粒层 40%,两个都关 5% —— 是**叠加效应**,
|
|
12
|
+
* 真凶是「33.7MB 的 PNG + 一个带 mix-blend-mode 的全屏 fixed 层」这对组合。
|
|
13
|
+
* 没有测量,它大概率只会把用户新抱怨的那个功能拆掉交差,剩下 40% 的掉帧
|
|
14
|
+
* 原封不动留在站里。
|
|
15
|
+
*
|
|
16
|
+
* ## 数字怎么读
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ 这台机器 1 vCPU,而且截图 worker 跟 server 抢同一颗核。**绝对值不代表
|
|
19
|
+
* 用户设备上的体验**,它的用处是 A/B:改一处、再量一次、看方向。返回文本里
|
|
20
|
+
* 会重复这句话,别让 agent 拿绝对数字去跟用户汇报。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
import { resolveCanvasTarget, CANVAS_PATH_DESC, requireBrowsable } from '../../../lib/artifact-target.js';
|
|
26
|
+
import { openArtifactPage, launchPerceptionBrowser, degradedNote } from './helpers/perception-page.js';
|
|
27
|
+
|
|
28
|
+
const DEVICE_W = { desktop: 1440, tablet: 834, mobile: 390 };
|
|
29
|
+
const DEVICE_H = { desktop: 900, tablet: 1112, mobile: 844 };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 页面里跑的采样器:**只测量,不自己滚**。
|
|
33
|
+
*
|
|
34
|
+
* ⛔ 前两版都是页面自己 `window.scrollBy` 推进的,两次都测错,原因不同:
|
|
35
|
+
* v1「大跨步 + 每步 sleep」——合成器在步与步之间有空闲,坏页面照样报 3% 掉帧。
|
|
36
|
+
* v2「rAF 里逐帧小步推」——压力对了,但**滚动方式还是假的**:程序化滚动
|
|
37
|
+
* 不派发 wheel 事件,于是一整类最常见的"一顿一顿"完全隐形:
|
|
38
|
+
* wheel handler 里做视差、滚动劫持(scrolljacking)、平滑滚库
|
|
39
|
+
* (Locomotive/Lenis 那种自己接管滚动的)。用户的手指走的是 wheel 那条路。
|
|
40
|
+
* 而且 `scroll-behavior: smooth` 会把程序化滚动变成动画,实测让量测范围
|
|
41
|
+
* 悄悄缩到 3.5% —— 看着"滚完了",其实几乎没动。
|
|
42
|
+
* 现在:滚动由外面用 CDP 派发**真 wheel 事件**(≈60Hz),这里只逐帧记 rAF 间隔。
|
|
43
|
+
*
|
|
44
|
+
* ⭐ 顺带白拿一个诊断:派发了多少像素 vs 页面实际走了多少像素。差得远
|
|
45
|
+
* = 滚动被劫持了,这本身就是答案。
|
|
46
|
+
*/
|
|
47
|
+
/* eslint-disable no-undef */
|
|
48
|
+
function installSampler() {
|
|
49
|
+
const st = {
|
|
50
|
+
frames: [], longTasks: [], po: null, raf: 0, running: true,
|
|
51
|
+
startedAt: performance.now(), last: performance.now(),
|
|
52
|
+
container: null, startTop: 0,
|
|
53
|
+
};
|
|
54
|
+
window.__ndProf = st;
|
|
55
|
+
try {
|
|
56
|
+
st.po = new PerformanceObserver((l) => { for (const e of l.getEntries()) st.longTasks.push(e.duration); });
|
|
57
|
+
st.po.observe({ entryTypes: ['longtask'] });
|
|
58
|
+
} catch { /* 不支持就没有长任务数据 */ }
|
|
59
|
+
|
|
60
|
+
// 滚的可能不是 window:应用式版面常常是某个内层容器在滚。wheel 事件落在
|
|
61
|
+
// 指针下面那个元素上,所以内层容器**会**滚 —— 但 window.scrollY 不动,
|
|
62
|
+
// 按它判断就会误报"这页滚不动"。先认出真正在滚的那个。
|
|
63
|
+
const pickContainer = () => {
|
|
64
|
+
const cx = Math.floor(window.innerWidth / 2);
|
|
65
|
+
const cy = Math.floor(window.innerHeight / 2);
|
|
66
|
+
let el = document.elementFromPoint(cx, cy);
|
|
67
|
+
while (el && el !== document.body && el !== document.documentElement) {
|
|
68
|
+
const cs = getComputedStyle(el);
|
|
69
|
+
const scrollable = /(auto|scroll|overlay)/.test(`${cs.overflowY}`) && el.scrollHeight > el.clientHeight + 4;
|
|
70
|
+
if (scrollable) return el;
|
|
71
|
+
el = el.parentElement;
|
|
72
|
+
}
|
|
73
|
+
return document.scrollingElement || document.documentElement;
|
|
74
|
+
};
|
|
75
|
+
st.container = pickContainer();
|
|
76
|
+
st.startTop = st.container.scrollTop;
|
|
77
|
+
|
|
78
|
+
const tick = () => {
|
|
79
|
+
if (!st.running) return;
|
|
80
|
+
const now = performance.now();
|
|
81
|
+
st.frames.push(now - st.last);
|
|
82
|
+
st.last = now;
|
|
83
|
+
st.raf = requestAnimationFrame(tick);
|
|
84
|
+
};
|
|
85
|
+
st.raf = requestAnimationFrame(tick);
|
|
86
|
+
return {
|
|
87
|
+
scrollHeight: st.container.scrollHeight,
|
|
88
|
+
clientHeight: st.container.clientHeight,
|
|
89
|
+
isWindow: st.container === (document.scrollingElement || document.documentElement),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function collectSampler() {
|
|
94
|
+
const st = window.__ndProf;
|
|
95
|
+
if (!st) return null;
|
|
96
|
+
st.running = false;
|
|
97
|
+
cancelAnimationFrame(st.raf);
|
|
98
|
+
try { st.po?.disconnect(); } catch { /* */ }
|
|
99
|
+
|
|
100
|
+
const imgs = performance.getEntriesByType('resource')
|
|
101
|
+
.filter(e => e.initiatorType === 'img' || /\.(png|jpe?g|webp|avif|gif|svg)(\?|$)/i.test(e.name))
|
|
102
|
+
.map(e => ({
|
|
103
|
+
name: decodeURIComponent(e.name.split('?')[0].split('/').pop() || '').slice(0, 60),
|
|
104
|
+
// encodedBodySize 是**这次传输**的字节;transferSize 命中缓存时是 0。
|
|
105
|
+
// 感知通道现在带 X-ND-Raw,拿的是磁盘原图,所以这个数字就是交付站点的数字。
|
|
106
|
+
bytes: e.encodedBodySize || e.transferSize || 0,
|
|
107
|
+
}));
|
|
108
|
+
imgs.sort((a, b) => b.bytes - a.bytes);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
frames: st.frames.slice(2), // 头两帧含启动抖动
|
|
112
|
+
longTasks: st.longTasks,
|
|
113
|
+
elapsedMs: performance.now() - st.startedAt,
|
|
114
|
+
scrollHeight: st.container.scrollHeight,
|
|
115
|
+
clientHeight: st.container.clientHeight,
|
|
116
|
+
movedPx: st.container.scrollTop - st.startTop,
|
|
117
|
+
imgCount: imgs.length,
|
|
118
|
+
imgBytes: imgs.reduce((a, b) => a + b.bytes, 0),
|
|
119
|
+
topImgs: imgs.slice(0, 5),
|
|
120
|
+
layers: (() => {
|
|
121
|
+
// 「合成层嫌疑」:全屏定位 + 带混合模式/滤镜/半透明的那些,
|
|
122
|
+
// 就是那个真实案例里的另一半凶手
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const el of document.querySelectorAll('*')) {
|
|
125
|
+
const cs = getComputedStyle(el);
|
|
126
|
+
if (cs.position !== 'fixed' && cs.position !== 'sticky') continue;
|
|
127
|
+
const r = el.getBoundingClientRect();
|
|
128
|
+
const big = r.width * r.height > window.innerWidth * window.innerHeight * 0.5;
|
|
129
|
+
const heavy = cs.mixBlendMode !== 'normal' || cs.filter !== 'none'
|
|
130
|
+
|| cs.backdropFilter !== 'none' || (cs.opacity !== '1' && cs.opacity !== '');
|
|
131
|
+
if (big && heavy) {
|
|
132
|
+
const id = el.id ? `#${el.id}` : (typeof el.className === 'string' && el.className.trim()
|
|
133
|
+
? `.${el.className.trim().split(/\s+/)[0]}` : el.tagName.toLowerCase());
|
|
134
|
+
out.push(`${id} [${cs.position}, blend=${cs.mixBlendMode}, filter=${cs.filter === 'none' ? '-' : 'yes'}, backdrop=${cs.backdropFilter === 'none' ? '-' : 'yes'}, opacity=${cs.opacity}]`);
|
|
135
|
+
}
|
|
136
|
+
if (out.length >= 5) break;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
})(),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/* eslint-enable no-undef */
|
|
143
|
+
|
|
144
|
+
const pct = (arr, p) => {
|
|
145
|
+
if (!arr.length) return 0;
|
|
146
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
147
|
+
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
|
148
|
+
};
|
|
149
|
+
const kb = (b) => (b >= 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`);
|
|
150
|
+
|
|
151
|
+
export function makeProfileScrollTool({ workspaceRoot, projectId, sessionId }) {
|
|
152
|
+
return tool(
|
|
153
|
+
'profile_scroll',
|
|
154
|
+
`Measure how a page actually performs while scrolling. Loads it over http
|
|
155
|
+
(same origin as the user preview), scrolls programmatically, and returns frame
|
|
156
|
+
timing, dropped-frame ratio, long tasks, total image bytes and the heaviest
|
|
157
|
+
images, plus any full-screen fixed/sticky layers with blend modes or filters.
|
|
158
|
+
|
|
159
|
+
Scrolling is driven by REAL wheel events, so this also catches the other common
|
|
160
|
+
cause of "one frame at a time": the page eating the wheel and scrolling itself
|
|
161
|
+
(scroll hijacking, Lenis/Locomotive-style smooth scroll). The report says how
|
|
162
|
+
many pixels were dispatched vs how many the page actually moved — if those
|
|
163
|
+
disagree, that is your answer and frame times are beside the point.
|
|
164
|
+
|
|
165
|
+
Use this the moment a user says scrolling feels heavy, janky, laggy or "one
|
|
166
|
+
frame at a time" — do NOT start deleting features to guess which one it is.
|
|
167
|
+
The one real case on record: baseline 63% dropped frames; killing the parallax
|
|
168
|
+
alone changed nothing (64%); killing the full-screen grain layer alone got it to
|
|
169
|
+
40%; killing both got 5%; hiding images got 3%. It was an additive effect
|
|
170
|
+
between 33.7MB of PNGs and one blended full-screen layer. Guessing would have
|
|
171
|
+
removed the wrong thing and left most of the jank in place.
|
|
172
|
+
|
|
173
|
+
Also worth running before you hand over any image-heavy page: total image bytes
|
|
174
|
+
is the single most common cause, and generate_image now writes a .webp sibling
|
|
175
|
+
next to every PNG for exactly this reason.
|
|
176
|
+
|
|
177
|
+
⚠️ This machine has 1 vCPU and the browser competes with the server for it. The
|
|
178
|
+
absolute numbers are NOT what the user's device will see. Use them for A/B:
|
|
179
|
+
measure, change one thing, measure again, compare direction. Never report the
|
|
180
|
+
raw percentage to the user as if it were their experience.`,
|
|
181
|
+
{
|
|
182
|
+
path: z.string().optional().describe(CANVAS_PATH_DESC),
|
|
183
|
+
device: z.enum(['desktop', 'tablet', 'mobile']).optional()
|
|
184
|
+
.describe('Viewport preset (default desktop 1440×900). Mobile is where jank shows up worst.'),
|
|
185
|
+
frames: z.number().int().min(30).max(300).optional()
|
|
186
|
+
.describe('How many wheel steps to scroll across (default 120 ≈ a 2-second scroll at 60Hz). Real wheel events are dispatched at ~60Hz, the same rate a trackpad fires — so wheel handlers, scroll hijacking and smooth-scroll libraries are all in the measurement.'),
|
|
187
|
+
},
|
|
188
|
+
async ({ path: relPath, device, frames: framesArg }) => {
|
|
189
|
+
const asText = (text) => ({ content: [{ type: 'text', text }] });
|
|
190
|
+
const target = await resolveCanvasTarget(workspaceRoot, relPath, sessionId);
|
|
191
|
+
if (!target) return { content: [{ type: 'text', text: 'No page found to profile.' }], isError: true };
|
|
192
|
+
const guard = requireBrowsable(target);
|
|
193
|
+
if (guard) return { content: [{ type: 'text', text: guard }], isError: true };
|
|
194
|
+
|
|
195
|
+
const dev = device || 'desktop';
|
|
196
|
+
const viewport = { width: DEVICE_W[dev], height: DEVICE_H[dev] };
|
|
197
|
+
const nFrames = framesArg ?? 120;
|
|
198
|
+
|
|
199
|
+
let browser;
|
|
200
|
+
try {
|
|
201
|
+
browser = await launchPerceptionBrowser();
|
|
202
|
+
const opened = await openArtifactPage(browser, {
|
|
203
|
+
projectId, workspaceRoot, absPath: target.absPath, viewport,
|
|
204
|
+
});
|
|
205
|
+
await opened.goto();
|
|
206
|
+
const page = opened.page;
|
|
207
|
+
|
|
208
|
+
// 采样器先装上(它顺手认出真正在滚的那个容器),再从外面派发真 wheel
|
|
209
|
+
const geom = await page.evaluate(installSampler);
|
|
210
|
+
const scrollable = Math.max(0, geom.scrollHeight - geom.clientHeight);
|
|
211
|
+
// 每帧推进量:把可滚范围在 nFrames 帧里走完。下限 6px —— 太小的话短页面
|
|
212
|
+
// 一直停在原地,样本全是静止帧(那种"满分"没有意义)。
|
|
213
|
+
const perFrame = Math.max(6, Math.round(scrollable / nFrames));
|
|
214
|
+
|
|
215
|
+
// ⭐ 真 wheel,配速 ≈60Hz。配速是必要的:不配速的话 CDP 一秒能塞几百个
|
|
216
|
+
// wheel,任何页面都会显得卡(假警报),而真人的滚轮/触摸板就是 60-120Hz。
|
|
217
|
+
await page.mouse.move(Math.floor(viewport.width / 2), Math.floor(viewport.height / 2));
|
|
218
|
+
const t0 = Date.now();
|
|
219
|
+
for (let i = 0; i < nFrames; i += 1) {
|
|
220
|
+
await page.mouse.wheel(0, perFrame);
|
|
221
|
+
const wait = (t0 + (i + 1) * 16) - Date.now();
|
|
222
|
+
if (wait > 1) await page.waitForTimeout(wait);
|
|
223
|
+
}
|
|
224
|
+
await page.waitForTimeout(120); // 让最后几帧和长任务落账
|
|
225
|
+
const r = await page.evaluate(collectSampler);
|
|
226
|
+
|
|
227
|
+
// ⛔ **不再过滤长帧**。原来是 `frames.filter(f => f > 0 && f < 2000)`,
|
|
228
|
+
// 于是一个 3017ms 的冻结**被当成脏数据剔掉**,剩下的帧算出 `dropped 0%`
|
|
229
|
+
// 且不作任何提示 —— 越卡越可能被删。A/B 甚至会反过来(把页面改得更卡
|
|
230
|
+
// 反而报得更好)。长帧正是要找的东西。
|
|
231
|
+
const frames = r.frames.filter(f => f > 0);
|
|
232
|
+
const dropped = frames.filter(f => f > 16.7 * 1.5).length;
|
|
233
|
+
const dropRatio = frames.length ? (dropped / frames.length) * 100 : 0;
|
|
234
|
+
const worst = frames.length ? Math.max(...frames) : 0;
|
|
235
|
+
const freezes = frames.filter(f => f > 250).length;
|
|
236
|
+
// 派发了多少 vs 真走了多少。差很多 = 滚动被劫持/被平滑滚库接管,
|
|
237
|
+
// 这本身就是"一顿一顿"的答案,不必再往下猜。
|
|
238
|
+
const askedPx = perFrame * nFrames;
|
|
239
|
+
const movedPx = Math.round(r.movedPx);
|
|
240
|
+
const followRatio = askedPx ? (movedPx / askedPx) * 100 : 100;
|
|
241
|
+
// 样本不足就说不足。原来这种情况报的是 `p50 0.0ms / dropped 0%` ——
|
|
242
|
+
// 一句"满分",而真相是**什么都没量到**(溢出面板、短页、滚不动的页)。
|
|
243
|
+
const tooFew = frames.length < 20;
|
|
244
|
+
// 判据是**跟随率**不是绝对位移:实测那个劫持页走了 960px(不算"没动"),
|
|
245
|
+
// 但只跟上了派发量的 13% —— 用户手上的感觉就是"滚了半天没动"。
|
|
246
|
+
// 60% 是留给平滑滚库正常惯性的余量。
|
|
247
|
+
const hijacked = askedPx > 400 && followRatio < 60;
|
|
248
|
+
|
|
249
|
+
// 契约:note 非空必须写进返回文本。这个工具尤其不能漏 —— 它的描述铁口
|
|
250
|
+
// 直断"走 http 同源",退回 file:// 后它按 Resource Timing 报的
|
|
251
|
+
// 「images 0 files 0KB」正好是它主诊断的反面。
|
|
252
|
+
const degraded = degradedNote(opened);
|
|
253
|
+
const lines = [
|
|
254
|
+
...(degraded ? [degraded, ''] : []),
|
|
255
|
+
`profile_scroll — ${target.relPath} @ ${dev} ${viewport.width}×${viewport.height}`,
|
|
256
|
+
`scroll container ${geom.isWindow ? 'window' : 'an inner element (overflow:auto)'}`
|
|
257
|
+
+ `, scrollable ${scrollable}px, real wheel events ${perFrame}px × ${nFrames} @60Hz`,
|
|
258
|
+
`moved ${movedPx}px of ${askedPx}px dispatched (${followRatio.toFixed(0)}% followed)`,
|
|
259
|
+
'',
|
|
260
|
+
...(tooFew ? [
|
|
261
|
+
`⚠ ONLY ${frames.length} frames sampled — that is not enough to say anything.`,
|
|
262
|
+
' Nothing below is a verdict. Usual causes: the page is barely taller than the',
|
|
263
|
+
' viewport, or the thing that scrolls is not under the centre of the screen.',
|
|
264
|
+
'',
|
|
265
|
+
] : []),
|
|
266
|
+
...(hijacked ? [
|
|
267
|
+
`⚠ ${nFrames} real wheel events asked for ${askedPx}px; the page moved ${movedPx}px (${followRatio.toFixed(0)}%).`,
|
|
268
|
+
' Something is EATING the scroll — a wheel handler doing custom scrolling,',
|
|
269
|
+
' scroll hijacking, or a smooth-scroll library (Lenis/Locomotive). That IS the',
|
|
270
|
+
' jank the user feels: their wheel and the page no longer agree. Look at wheel',
|
|
271
|
+
' listeners before you look at images or layers.',
|
|
272
|
+
'',
|
|
273
|
+
] : []),
|
|
274
|
+
`frame time p50 ${pct(frames, 50).toFixed(1)}ms p90 ${pct(frames, 90).toFixed(1)}ms p99 ${pct(frames, 99).toFixed(1)}ms (16.7ms = 60fps)`,
|
|
275
|
+
`dropped ${dropRatio.toFixed(0)}% of ${frames.length} frames took >25ms`,
|
|
276
|
+
`worst frame ${worst.toFixed(0)}ms${freezes ? ` · ${freezes} frame(s) over 250ms = a visible freeze` : ''}`,
|
|
277
|
+
`long tasks ${r.longTasks.length}${r.longTasks.length ? ` (worst ${Math.max(...r.longTasks).toFixed(0)}ms)` : ''}`,
|
|
278
|
+
'',
|
|
279
|
+
`images ${r.imgCount} files, ${kb(r.imgBytes)} total (as delivered: originals, not preview variants)`,
|
|
280
|
+
...(r.topImgs.length
|
|
281
|
+
? [`heaviest ${r.topImgs.map(i => `${i.name} ${kb(i.bytes)}`).join(' · ')}`]
|
|
282
|
+
: []),
|
|
283
|
+
...(r.layers.length
|
|
284
|
+
? ['', 'full-screen composited layers (a classic other half of the problem):',
|
|
285
|
+
...r.layers.map(l => ` ${l}`)]
|
|
286
|
+
: []),
|
|
287
|
+
'',
|
|
288
|
+
r.imgBytes > 5 * 1024 * 1024
|
|
289
|
+
? `⚠ ${kb(r.imgBytes)} of images is the first thing to fix — reference the .webp siblings instead of the PNG masters.`
|
|
290
|
+
: null,
|
|
291
|
+
dropRatio > 25 && r.layers.length
|
|
292
|
+
? '⚠ Both heavy layers and dropped frames present. These are usually ADDITIVE — test removing them one at a time AND together before concluding.'
|
|
293
|
+
: null,
|
|
294
|
+
'',
|
|
295
|
+
'Numbers are for A/B comparison on this 1-vCPU box, not the user\'s device.',
|
|
296
|
+
].filter(l => l !== null);
|
|
297
|
+
return asText(lines.join('\n'));
|
|
298
|
+
} catch (err) {
|
|
299
|
+
return { content: [{ type: 'text', text: `profile_scroll failed: ${err?.message || String(err)}` }], isError: true };
|
|
300
|
+
} finally {
|
|
301
|
+
await browser?.close().catch(() => {});
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
}
|