@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,170 +1,170 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* server/engine/browse/screencast.js — 把 agent 的浏览器画面推给用户(2026-08-18)
|
|
3
|
-
*
|
|
4
|
-
* 用户要的形态:「**用通用显示器来播放 agent 当前浏览器操作画面,必要时人类可以
|
|
5
|
-
* 协助 agent**」。这个模块是那条像素通路加上行输入。
|
|
6
|
-
*
|
|
7
|
-
* ## 为什么是 CDP screencast(而不是轮询截图)
|
|
8
|
-
*
|
|
9
|
-
* ⭐ **screencast 是 damage-driven**(实测):页面静止时 30 秒只发 1 帧、0.1% 单核;
|
|
10
|
-
* 一次 DOM 改动 = 一帧。所以代价全在「页面在动 + 有人看」,静止时近乎免费。
|
|
11
|
-
* 周期性 `page.screenshot()` 轮询正好丢掉这个免费午餐 —— 页面动不动都要编码一张,
|
|
12
|
-
* 在 1 vCPU 上更差。
|
|
13
|
-
*
|
|
14
|
-
* ## 1 vCPU 上的账(⚠️ 实测纠正过一次归因)
|
|
15
|
-
*
|
|
16
|
-
* ⚠️ **这一节我写错过一次,纠正如下。**
|
|
17
|
-
*
|
|
18
|
-
* 第一版量的是「动画页 + 没人订阅 85.6% / 有人订阅 86.6%」,据此写了「贵的是页面
|
|
19
|
-
* 自己在动,画面流几乎免费」。两处都错:
|
|
20
|
-
* - 基线**本来就吃满核了**(只剩 ~14pp 余量),推流的成本被天花板挡住
|
|
21
|
-
* - 用的是 `ps` 的 `pcpu`,那是进程**生命周期平均值**,量不出增量
|
|
22
|
-
*
|
|
23
|
-
* 改用 `/proc` 的 utime+stime 差分 + 稳态等待,在**有余量**的页面上量两次:
|
|
24
|
-
*
|
|
25
|
-
* 小元素每 100ms 变一次:基线 1.3% → 订阅中 7.6% → 退订 1.8%(漂移 0.5pp)
|
|
26
|
-
* → 净成本 **+6.0pp @ 2.0 fps**
|
|
27
|
-
* 小元素 rAF 每帧变: 基线 4.0% → 订阅中 43.5% → 退订 4.1%(漂移 0.2pp)
|
|
28
|
-
* → 净成本 **+39.4pp @ 12.2 fps**
|
|
29
|
-
*
|
|
30
|
-
* 两点独立,每 fps 约 **3.1pp 单核**。所以:**满帧推流要吃掉约 40% 单核**,
|
|
31
|
-
* `MAX_ACTIVE = 1` 恰恰是唯一能把这个核还回来的刹车 —— 不是"便宜无害的装饰"。
|
|
32
|
-
* (待考虑没做:没人看时用 `Emulation.setVirtualTimePolicy` 把页面本身冻住,
|
|
33
|
-
* 能再省下页面动画那部分,但有些页面 resume 会坏,要真跑一批站才敢上。)
|
|
34
|
-
*
|
|
35
|
-
* 三道刹车:
|
|
36
|
-
* 1. **帧率硬压**:`everyNthFrame=5`(60fps 动画 → ~12fps)、`quality=45`、`maxWidth=1024`
|
|
37
|
-
* 2. ⭐ **ack 门控背压**:CDP 的 screencast 是 ack 驱动的 —— 只在 socket 没堵
|
|
38
|
-
* (`bufferedAmount` 低于阈值)时才 `screencastFrameAck`。socket 堵住就不 ack,
|
|
39
|
-
* chromium 自然停发下一帧。**把"没人接得住"直接变成"不生产"**,不丢帧也不排队膨胀。
|
|
40
|
-
* 3. **没人看就停**:最后一个订阅者断开 → 立刻 `stopScreencast`,不等浏览器空闲回收。
|
|
41
|
-
* 同时活跃画面流全局 ≤1(**这条是 CPU 定的,不是内存**)。
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
|
-
/** 帧参数:1 vCPU 下的取舍,别调高(改这里前先拿 `top` 量一遍) */
|
|
45
|
-
const CAST = { format: 'jpeg', quality: 45, maxWidth: 1024, maxHeight: 700, everyNthFrame: 5 };
|
|
46
|
-
/** socket 里积压超过这个就不再 ack(= 让 chromium 停发) */
|
|
47
|
-
const BACKPRESSURE_BYTES = 256 * 1024;
|
|
48
|
-
/** 全局同时只允许一路活跃画面流。**这是 CPU 的主要保护**:满帧推流约 40% 单核
|
|
49
|
-
* (见文件头的实测),这台机器只有一个核。 */
|
|
50
|
-
const MAX_ACTIVE = 1;
|
|
51
|
-
|
|
52
|
-
/** projectId → { page, cdp, subs:Set<ws>, meta } */
|
|
53
|
-
const casts = new Map();
|
|
54
|
-
|
|
55
|
-
function activeCount() {
|
|
56
|
-
let n = 0;
|
|
57
|
-
for (const c of casts.values()) if (c.subs.size) n += 1;
|
|
58
|
-
return n;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* 让某个 socket 订阅某项目的画面。
|
|
63
|
-
* @returns {Promise<{ok:true}|{ok:false, reason:string}>}
|
|
64
|
-
*/
|
|
65
|
-
let subscribing = false; // 绕过二的锁,见下
|
|
66
|
-
|
|
67
|
-
export async function subscribe(projectId, ws, page) {
|
|
68
|
-
let cast = casts.get(projectId);
|
|
69
|
-
// ⛔ **上限检查要在"这一路要不要开始编码"上判,不是在"有没有这个条目"上判**
|
|
70
|
-
// (审查攻出来的两个绕过):
|
|
71
|
-
// ① 退订只清 subs、**不删 casts 里的条目**,于是"再订一次"会走进 `cast` 已存在
|
|
72
|
-
// 那一支,整个跳过上限检查 —— 两路同时推流。
|
|
73
|
-
// ② 检查之后紧跟着 await(建 CDP 会话),两个并发 subscribe 都能过。
|
|
74
|
-
const willStartCasting = !cast || !cast.casting;
|
|
75
|
-
if (willStartCasting) {
|
|
76
|
-
if (subscribing) {
|
|
77
|
-
return { ok: false, reason: '另一路画面正在建立,稍等一下再打开这扇窗。' };
|
|
78
|
-
}
|
|
79
|
-
if (activeCount() >= MAX_ACTIVE) {
|
|
80
|
-
// ⚠️ 这个上限是**全机器**的(CPU 是全机器的),所以占着它的那一路很可能
|
|
81
|
-
// 属于**别的项目、甚至别的用户**。原来的话术是"先关掉另一扇浏览器窗",
|
|
82
|
-
// 那扇窗他既看不见也关不掉 —— 一句让人白忙的假指令。说实话就行。
|
|
83
|
-
const mine = casts.get(projectId)?.subs?.size ? '(就是这个项目自己的那扇)' : '(在别的会话里,你看不到也关不掉)';
|
|
84
|
-
return { ok: false, reason: `这台机器同时只能推一路浏览器画面,现在有一路在跑${mine}。`
|
|
85
|
-
+ '满帧推流约吃 40% 个核(实测),而这里只有 1 个核。等那边看完再打开,'
|
|
86
|
-
+ '或者让 agent 用截图代替实时画面。' };
|
|
87
|
-
}
|
|
88
|
-
subscribing = true;
|
|
89
|
-
}
|
|
90
|
-
try {
|
|
91
|
-
if (!cast) {
|
|
92
|
-
const cdp = await page.context().newCDPSession(page);
|
|
93
|
-
cast = { projectId, page, cdp, subs: new Set(), meta: null, casting: false };
|
|
94
|
-
casts.set(projectId, cast);
|
|
95
|
-
|
|
96
|
-
cdp.on('Page.screencastFrame', (ev) => {
|
|
97
|
-
cast.meta = ev.metadata; // 里面有 deviceWidth/deviceHeight —— 接手时坐标换算要用
|
|
98
|
-
const buf = Buffer.from(ev.data, 'base64');
|
|
99
|
-
let anyoneKeepingUp = false;
|
|
100
|
-
for (const sock of cast.subs) {
|
|
101
|
-
if (sock.readyState !== 1) continue;
|
|
102
|
-
// ⭐ 堵住的 socket 直接跳过这一帧(画面会掉帧,但不会积压)
|
|
103
|
-
if (sock.bufferedAmount > BACKPRESSURE_BYTES) continue;
|
|
104
|
-
anyoneKeepingUp = true;
|
|
105
|
-
try { sock.send(buf, { binary: true, compress: false }); } catch { /* 下一帧再说 */ }
|
|
106
|
-
}
|
|
107
|
-
// ack 决定 chromium 发不发下一帧:没人接得住就不 ack = 不生产
|
|
108
|
-
if (anyoneKeepingUp) {
|
|
109
|
-
cdp.send('Page.screencastFrameAck', { sessionId: ev.sessionId }).catch(() => {});
|
|
110
|
-
} else {
|
|
111
|
-
// 留一条慢速心跳,免得所有人都堵住之后再也不恢复
|
|
112
|
-
setTimeout(() => {
|
|
113
|
-
cdp.send('Page.screencastFrameAck', { sessionId: ev.sessionId }).catch(() => {});
|
|
114
|
-
}, 500).unref?.();
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
cast.subs.add(ws);
|
|
120
|
-
if (!cast.casting) {
|
|
121
|
-
await cast.cdp.send('Page.startScreencast', CAST);
|
|
122
|
-
cast.casting = true;
|
|
123
|
-
// ⭐ 立刻补一帧。screencast 是 damage-driven —— 静止页面订阅之后可能**一帧都不发**,
|
|
124
|
-
// 用户看到的是一扇全白的窗,而它其实是"连上了但页面没动"。
|
|
125
|
-
// 这条是审查锐化出来的:接手功能在静止页面上由构造决定会失败(窗白着没法点)。
|
|
126
|
-
try {
|
|
127
|
-
const shot = await cast.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: CAST.quality });
|
|
128
|
-
const buf = Buffer.from(shot.data, 'base64');
|
|
129
|
-
for (const sock of cast.subs) {
|
|
130
|
-
if (sock.readyState === 1) sock.send(buf, { binary: true, compress: false });
|
|
131
|
-
}
|
|
132
|
-
} catch { /* 补不上就等页面自己动 */ }
|
|
133
|
-
}
|
|
134
|
-
return { ok: true };
|
|
135
|
-
} finally {
|
|
136
|
-
if (willStartCasting) subscribing = false;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** 退订。最后一个人走了就停止编码。 */
|
|
141
|
-
export async function unsubscribe(projectId, ws) {
|
|
142
|
-
const cast = casts.get(projectId);
|
|
143
|
-
if (!cast) return;
|
|
144
|
-
cast.subs.delete(ws);
|
|
145
|
-
if (cast.subs.size) return;
|
|
146
|
-
if (cast.casting) {
|
|
147
|
-
await cast.cdp.send('Page.stopScreencast').catch(() => {});
|
|
148
|
-
cast.casting = false;
|
|
149
|
-
}
|
|
150
|
-
// 条目留着也没用(下次订阅要重新 startScreencast),而留着正好造出上面那个
|
|
151
|
-
// "跳过上限检查"的绕过。删掉 —— `activeCount()` 数的是"有订阅者的路",
|
|
152
|
-
// 但少一个空壳条目也少一处可以钻的缝。
|
|
153
|
-
casts.delete(projectId);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** 浏览器被关掉时(空闲回收 / LRU 淘汰)把这一路彻底忘掉 */
|
|
157
|
-
export function forget(projectId) {
|
|
158
|
-
const cast = casts.get(projectId);
|
|
159
|
-
if (!cast) return;
|
|
160
|
-
casts.delete(projectId);
|
|
161
|
-
for (const sock of cast.subs) {
|
|
162
|
-
try { sock.send(JSON.stringify({ type: 'closed', reason: 'browser was shut down' })); } catch { /* */ }
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** 当前帧的设备像素尺寸 —— 接手时前端坐标要按它换算 */
|
|
167
|
-
export function frameMeta(projectId) {
|
|
168
|
-
return casts.get(projectId)?.meta ?? null;
|
|
169
|
-
}
|
|
170
|
-
|
|
1
|
+
/**
|
|
2
|
+
* server/engine/browse/screencast.js — 把 agent 的浏览器画面推给用户(2026-08-18)
|
|
3
|
+
*
|
|
4
|
+
* 用户要的形态:「**用通用显示器来播放 agent 当前浏览器操作画面,必要时人类可以
|
|
5
|
+
* 协助 agent**」。这个模块是那条像素通路加上行输入。
|
|
6
|
+
*
|
|
7
|
+
* ## 为什么是 CDP screencast(而不是轮询截图)
|
|
8
|
+
*
|
|
9
|
+
* ⭐ **screencast 是 damage-driven**(实测):页面静止时 30 秒只发 1 帧、0.1% 单核;
|
|
10
|
+
* 一次 DOM 改动 = 一帧。所以代价全在「页面在动 + 有人看」,静止时近乎免费。
|
|
11
|
+
* 周期性 `page.screenshot()` 轮询正好丢掉这个免费午餐 —— 页面动不动都要编码一张,
|
|
12
|
+
* 在 1 vCPU 上更差。
|
|
13
|
+
*
|
|
14
|
+
* ## 1 vCPU 上的账(⚠️ 实测纠正过一次归因)
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ **这一节我写错过一次,纠正如下。**
|
|
17
|
+
*
|
|
18
|
+
* 第一版量的是「动画页 + 没人订阅 85.6% / 有人订阅 86.6%」,据此写了「贵的是页面
|
|
19
|
+
* 自己在动,画面流几乎免费」。两处都错:
|
|
20
|
+
* - 基线**本来就吃满核了**(只剩 ~14pp 余量),推流的成本被天花板挡住
|
|
21
|
+
* - 用的是 `ps` 的 `pcpu`,那是进程**生命周期平均值**,量不出增量
|
|
22
|
+
*
|
|
23
|
+
* 改用 `/proc` 的 utime+stime 差分 + 稳态等待,在**有余量**的页面上量两次:
|
|
24
|
+
*
|
|
25
|
+
* 小元素每 100ms 变一次:基线 1.3% → 订阅中 7.6% → 退订 1.8%(漂移 0.5pp)
|
|
26
|
+
* → 净成本 **+6.0pp @ 2.0 fps**
|
|
27
|
+
* 小元素 rAF 每帧变: 基线 4.0% → 订阅中 43.5% → 退订 4.1%(漂移 0.2pp)
|
|
28
|
+
* → 净成本 **+39.4pp @ 12.2 fps**
|
|
29
|
+
*
|
|
30
|
+
* 两点独立,每 fps 约 **3.1pp 单核**。所以:**满帧推流要吃掉约 40% 单核**,
|
|
31
|
+
* `MAX_ACTIVE = 1` 恰恰是唯一能把这个核还回来的刹车 —— 不是"便宜无害的装饰"。
|
|
32
|
+
* (待考虑没做:没人看时用 `Emulation.setVirtualTimePolicy` 把页面本身冻住,
|
|
33
|
+
* 能再省下页面动画那部分,但有些页面 resume 会坏,要真跑一批站才敢上。)
|
|
34
|
+
*
|
|
35
|
+
* 三道刹车:
|
|
36
|
+
* 1. **帧率硬压**:`everyNthFrame=5`(60fps 动画 → ~12fps)、`quality=45`、`maxWidth=1024`
|
|
37
|
+
* 2. ⭐ **ack 门控背压**:CDP 的 screencast 是 ack 驱动的 —— 只在 socket 没堵
|
|
38
|
+
* (`bufferedAmount` 低于阈值)时才 `screencastFrameAck`。socket 堵住就不 ack,
|
|
39
|
+
* chromium 自然停发下一帧。**把"没人接得住"直接变成"不生产"**,不丢帧也不排队膨胀。
|
|
40
|
+
* 3. **没人看就停**:最后一个订阅者断开 → 立刻 `stopScreencast`,不等浏览器空闲回收。
|
|
41
|
+
* 同时活跃画面流全局 ≤1(**这条是 CPU 定的,不是内存**)。
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** 帧参数:1 vCPU 下的取舍,别调高(改这里前先拿 `top` 量一遍) */
|
|
45
|
+
const CAST = { format: 'jpeg', quality: 45, maxWidth: 1024, maxHeight: 700, everyNthFrame: 5 };
|
|
46
|
+
/** socket 里积压超过这个就不再 ack(= 让 chromium 停发) */
|
|
47
|
+
const BACKPRESSURE_BYTES = 256 * 1024;
|
|
48
|
+
/** 全局同时只允许一路活跃画面流。**这是 CPU 的主要保护**:满帧推流约 40% 单核
|
|
49
|
+
* (见文件头的实测),这台机器只有一个核。 */
|
|
50
|
+
const MAX_ACTIVE = 1;
|
|
51
|
+
|
|
52
|
+
/** projectId → { page, cdp, subs:Set<ws>, meta } */
|
|
53
|
+
const casts = new Map();
|
|
54
|
+
|
|
55
|
+
function activeCount() {
|
|
56
|
+
let n = 0;
|
|
57
|
+
for (const c of casts.values()) if (c.subs.size) n += 1;
|
|
58
|
+
return n;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 让某个 socket 订阅某项目的画面。
|
|
63
|
+
* @returns {Promise<{ok:true}|{ok:false, reason:string}>}
|
|
64
|
+
*/
|
|
65
|
+
let subscribing = false; // 绕过二的锁,见下
|
|
66
|
+
|
|
67
|
+
export async function subscribe(projectId, ws, page) {
|
|
68
|
+
let cast = casts.get(projectId);
|
|
69
|
+
// ⛔ **上限检查要在"这一路要不要开始编码"上判,不是在"有没有这个条目"上判**
|
|
70
|
+
// (审查攻出来的两个绕过):
|
|
71
|
+
// ① 退订只清 subs、**不删 casts 里的条目**,于是"再订一次"会走进 `cast` 已存在
|
|
72
|
+
// 那一支,整个跳过上限检查 —— 两路同时推流。
|
|
73
|
+
// ② 检查之后紧跟着 await(建 CDP 会话),两个并发 subscribe 都能过。
|
|
74
|
+
const willStartCasting = !cast || !cast.casting;
|
|
75
|
+
if (willStartCasting) {
|
|
76
|
+
if (subscribing) {
|
|
77
|
+
return { ok: false, reason: '另一路画面正在建立,稍等一下再打开这扇窗。' };
|
|
78
|
+
}
|
|
79
|
+
if (activeCount() >= MAX_ACTIVE) {
|
|
80
|
+
// ⚠️ 这个上限是**全机器**的(CPU 是全机器的),所以占着它的那一路很可能
|
|
81
|
+
// 属于**别的项目、甚至别的用户**。原来的话术是"先关掉另一扇浏览器窗",
|
|
82
|
+
// 那扇窗他既看不见也关不掉 —— 一句让人白忙的假指令。说实话就行。
|
|
83
|
+
const mine = casts.get(projectId)?.subs?.size ? '(就是这个项目自己的那扇)' : '(在别的会话里,你看不到也关不掉)';
|
|
84
|
+
return { ok: false, reason: `这台机器同时只能推一路浏览器画面,现在有一路在跑${mine}。`
|
|
85
|
+
+ '满帧推流约吃 40% 个核(实测),而这里只有 1 个核。等那边看完再打开,'
|
|
86
|
+
+ '或者让 agent 用截图代替实时画面。' };
|
|
87
|
+
}
|
|
88
|
+
subscribing = true;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
if (!cast) {
|
|
92
|
+
const cdp = await page.context().newCDPSession(page);
|
|
93
|
+
cast = { projectId, page, cdp, subs: new Set(), meta: null, casting: false };
|
|
94
|
+
casts.set(projectId, cast);
|
|
95
|
+
|
|
96
|
+
cdp.on('Page.screencastFrame', (ev) => {
|
|
97
|
+
cast.meta = ev.metadata; // 里面有 deviceWidth/deviceHeight —— 接手时坐标换算要用
|
|
98
|
+
const buf = Buffer.from(ev.data, 'base64');
|
|
99
|
+
let anyoneKeepingUp = false;
|
|
100
|
+
for (const sock of cast.subs) {
|
|
101
|
+
if (sock.readyState !== 1) continue;
|
|
102
|
+
// ⭐ 堵住的 socket 直接跳过这一帧(画面会掉帧,但不会积压)
|
|
103
|
+
if (sock.bufferedAmount > BACKPRESSURE_BYTES) continue;
|
|
104
|
+
anyoneKeepingUp = true;
|
|
105
|
+
try { sock.send(buf, { binary: true, compress: false }); } catch { /* 下一帧再说 */ }
|
|
106
|
+
}
|
|
107
|
+
// ack 决定 chromium 发不发下一帧:没人接得住就不 ack = 不生产
|
|
108
|
+
if (anyoneKeepingUp) {
|
|
109
|
+
cdp.send('Page.screencastFrameAck', { sessionId: ev.sessionId }).catch(() => {});
|
|
110
|
+
} else {
|
|
111
|
+
// 留一条慢速心跳,免得所有人都堵住之后再也不恢复
|
|
112
|
+
setTimeout(() => {
|
|
113
|
+
cdp.send('Page.screencastFrameAck', { sessionId: ev.sessionId }).catch(() => {});
|
|
114
|
+
}, 500).unref?.();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
cast.subs.add(ws);
|
|
120
|
+
if (!cast.casting) {
|
|
121
|
+
await cast.cdp.send('Page.startScreencast', CAST);
|
|
122
|
+
cast.casting = true;
|
|
123
|
+
// ⭐ 立刻补一帧。screencast 是 damage-driven —— 静止页面订阅之后可能**一帧都不发**,
|
|
124
|
+
// 用户看到的是一扇全白的窗,而它其实是"连上了但页面没动"。
|
|
125
|
+
// 这条是审查锐化出来的:接手功能在静止页面上由构造决定会失败(窗白着没法点)。
|
|
126
|
+
try {
|
|
127
|
+
const shot = await cast.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: CAST.quality });
|
|
128
|
+
const buf = Buffer.from(shot.data, 'base64');
|
|
129
|
+
for (const sock of cast.subs) {
|
|
130
|
+
if (sock.readyState === 1) sock.send(buf, { binary: true, compress: false });
|
|
131
|
+
}
|
|
132
|
+
} catch { /* 补不上就等页面自己动 */ }
|
|
133
|
+
}
|
|
134
|
+
return { ok: true };
|
|
135
|
+
} finally {
|
|
136
|
+
if (willStartCasting) subscribing = false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 退订。最后一个人走了就停止编码。 */
|
|
141
|
+
export async function unsubscribe(projectId, ws) {
|
|
142
|
+
const cast = casts.get(projectId);
|
|
143
|
+
if (!cast) return;
|
|
144
|
+
cast.subs.delete(ws);
|
|
145
|
+
if (cast.subs.size) return;
|
|
146
|
+
if (cast.casting) {
|
|
147
|
+
await cast.cdp.send('Page.stopScreencast').catch(() => {});
|
|
148
|
+
cast.casting = false;
|
|
149
|
+
}
|
|
150
|
+
// 条目留着也没用(下次订阅要重新 startScreencast),而留着正好造出上面那个
|
|
151
|
+
// "跳过上限检查"的绕过。删掉 —— `activeCount()` 数的是"有订阅者的路",
|
|
152
|
+
// 但少一个空壳条目也少一处可以钻的缝。
|
|
153
|
+
casts.delete(projectId);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 浏览器被关掉时(空闲回收 / LRU 淘汰)把这一路彻底忘掉 */
|
|
157
|
+
export function forget(projectId) {
|
|
158
|
+
const cast = casts.get(projectId);
|
|
159
|
+
if (!cast) return;
|
|
160
|
+
casts.delete(projectId);
|
|
161
|
+
for (const sock of cast.subs) {
|
|
162
|
+
try { sock.send(JSON.stringify({ type: 'closed', reason: 'browser was shut down' })); } catch { /* */ }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 当前帧的设备像素尺寸 —— 接手时前端坐标要按它换算 */
|
|
167
|
+
export function frameMeta(projectId) {
|
|
168
|
+
return casts.get(projectId)?.meta ?? null;
|
|
169
|
+
}
|
|
170
|
+
|
|
@@ -1,121 +1,121 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* server/engine/browse/state.js — 浏览器在桌面上留下的那点持久痕迹(2026-08-18)
|
|
3
|
-
*
|
|
4
|
-
* ## 为什么需要它
|
|
5
|
-
*
|
|
6
|
-
* 浏览器实例本身是**进程内的、易失的**(`registry.js` 的 Map,空闲 5 分钟就回收,
|
|
7
|
-
* pm2 重启就没了)。这是对的 —— 浏览器真的没了。
|
|
8
|
-
*
|
|
9
|
-
* 但用户要的是「**桌面上有一张浏览器卡片,随时能进去看和用**」(2026-08-18 拍板,
|
|
10
|
-
* 推翻了当初 BrowserWindow 里那句「你不会想在桌面上永久摆着一张某次浏览的卡片」)。
|
|
11
|
-
* 卡片要活得比实例长:agent 昨天逛过的站,今天打开项目还应该看得见那张卡,双击
|
|
12
|
-
* 就回到那一页。所以要有一点**落盘**的状态。
|
|
13
|
-
*
|
|
14
|
-
* ## 落哪儿
|
|
15
|
-
*
|
|
16
|
-
* `<pid>/.browser/`,跟 chromium 的 profile 同级、在 `shared/` **外面**。
|
|
17
|
-
* 理由跟 profile 一样(registry.js 里核过):`shared/` 是 agent 的 cwd 也是
|
|
18
|
-
* artifact-file 的服务根,放进去等于让 agent 能 Read 自己的浏览历史、
|
|
19
|
-
* 还会被当文件服出去、还进 per-project git。删项目时 `<pid>/` 整个删掉,
|
|
20
|
-
* 这里跟着走,不用另写清理。
|
|
21
|
-
*
|
|
22
|
-
* ## 两份东西,两种性质
|
|
23
|
-
*
|
|
24
|
-
* - `state.json` —— 上次在哪一页(`{url, title, at}`)。导航/点击后写,**必须极便宜
|
|
25
|
-
* 且永不抛**:它长在导航路径上,为了一张卡片让 agent 的导航失败是划不来的。
|
|
26
|
-
* - `last.webp` —— 上次看到的样子(卡片上那块预览)。
|
|
27
|
-
* ⭐ 只在**本来就截了图**的时候顺手存(`browser_screenshot` 的视口档),
|
|
28
|
-
* 或者用户正在看画布、preview 端点现截一张。**不为了刷新卡片主动截图** ——
|
|
29
|
-
* 实测视口截图在 stripe/小红书这种站上要 1.7~4.2 秒,而这台机器只有 1 个核。
|
|
30
|
-
*
|
|
31
|
-
* 卡上的预览因此是「**上次看到的样子**」,跟 word 卡是同一路数(那也是一张静态页图)。
|
|
32
|
-
* 活的画面流只在窗打开时给:实测每 fps 约 3.1pp 单核,满帧 40%。
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import path from 'node:path';
|
|
36
|
-
import fs from 'node:fs/promises';
|
|
37
|
-
import { getProjectWorkspace } from '../../projects/workspace.js';
|
|
38
|
-
|
|
39
|
-
const dirOf = (projectId) => path.join(getProjectWorkspace(projectId), '.browser');
|
|
40
|
-
const STATE_FILE = 'state.json';
|
|
41
|
-
const FRAME_FILE = 'last.webp';
|
|
42
|
-
|
|
43
|
-
/** 卡片预览的绝对路径(存在与否由调用方 stat) */
|
|
44
|
-
export function framePath(projectId) {
|
|
45
|
-
return path.join(dirOf(projectId), FRAME_FILE);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* 记一笔「现在在这一页」。**永不抛** —— 它挂在导航路径上。
|
|
50
|
-
* @param {string} projectId
|
|
51
|
-
* @param {import('playwright').Page} page
|
|
52
|
-
*/
|
|
53
|
-
export async function recordVisit(projectId, page) {
|
|
54
|
-
try {
|
|
55
|
-
const url = page.url();
|
|
56
|
-
if (!/^https?:/.test(url)) return; // about:blank / chrome-error 不值得记
|
|
57
|
-
const title = await page.title().catch(() => '');
|
|
58
|
-
const dir = dirOf(projectId);
|
|
59
|
-
await fs.mkdir(dir, { recursive: true });
|
|
60
|
-
await fs.writeFile(path.join(dir, STATE_FILE), `${JSON.stringify({
|
|
61
|
-
url, title: title || null, at: new Date().toISOString(),
|
|
62
|
-
}, null, 2)}\n`, 'utf8');
|
|
63
|
-
} catch { /* 记不下来就算了,不能因此让导航失败 */ }
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* 上次在哪一页。没逛过 → null(桌面上就没有这张卡)。
|
|
68
|
-
* @returns {Promise<{url: string, title: string|null, at: string}|null>}
|
|
69
|
-
*/
|
|
70
|
-
export async function readVisit(projectId) {
|
|
71
|
-
try {
|
|
72
|
-
const raw = await fs.readFile(path.join(dirOf(projectId), STATE_FILE), 'utf8');
|
|
73
|
-
const v = JSON.parse(raw);
|
|
74
|
-
return (v && typeof v.url === 'string' && /^https?:/.test(v.url)) ? v : null;
|
|
75
|
-
} catch { return null; }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* 存一帧当卡片预览。传的是原始截图 buffer,这里缩成卡片尺寸再落盘。
|
|
80
|
-
*
|
|
81
|
-
* 卡片预览区 640×400(`ARTIFACT_PREVIEW_H.browse`),主角档 ×1.5 = 960 宽,
|
|
82
|
-
* 所以存 1024 宽足够,再大是白占盘和白花编码时间。**永不抛。**
|
|
83
|
-
*/
|
|
84
|
-
export async function saveFrame(projectId, buf) {
|
|
85
|
-
try {
|
|
86
|
-
const { default: sharp } = await import('sharp');
|
|
87
|
-
const out = await sharp(buf).resize({ width: 1024, withoutEnlargement: true })
|
|
88
|
-
.webp({ quality: 72 }).toBuffer();
|
|
89
|
-
const dir = dirOf(projectId);
|
|
90
|
-
await fs.mkdir(dir, { recursive: true });
|
|
91
|
-
await fs.writeFile(path.join(dir, FRAME_FILE), out);
|
|
92
|
-
return out.length;
|
|
93
|
-
} catch { return 0; }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* 忘掉这个项目的浏览痕迹 —— 桌面上那张卡随之消失。
|
|
98
|
-
*
|
|
99
|
-
* 为什么需要:卡片的存在判据是 `state.json` 在不在,而它一旦写下就永远在 ——
|
|
100
|
-
* 一个月前逛过一次的项目,桌面上会一直摆着那张卡赶不走。这是"卡活得比实例长"
|
|
101
|
-
* 的代价,得给个出口。
|
|
102
|
-
*
|
|
103
|
-
* ⚠️ **profile 不删**(`.browser/default/`):登录态是资产,用户点的是"这张卡
|
|
104
|
-
* 我不看了",不是"把我在那个站的登录清掉"。
|
|
105
|
-
*/
|
|
106
|
-
export async function forgetVisit(projectId) {
|
|
107
|
-
const dir = dirOf(projectId);
|
|
108
|
-
await Promise.all([
|
|
109
|
-
fs.unlink(path.join(dir, STATE_FILE)).catch(() => {}),
|
|
110
|
-
fs.unlink(path.join(dir, FRAME_FILE)).catch(() => {}),
|
|
111
|
-
]);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** 上次那张预览的字节 + mtime;没有 → null */
|
|
115
|
-
export async function readFrame(projectId) {
|
|
116
|
-
const file = framePath(projectId);
|
|
117
|
-
try {
|
|
118
|
-
const [buf, st] = await Promise.all([fs.readFile(file), fs.stat(file)]);
|
|
119
|
-
return { buf, mtime: st.mtime };
|
|
120
|
-
} catch { return null; }
|
|
121
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* server/engine/browse/state.js — 浏览器在桌面上留下的那点持久痕迹(2026-08-18)
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* 浏览器实例本身是**进程内的、易失的**(`registry.js` 的 Map,空闲 5 分钟就回收,
|
|
7
|
+
* pm2 重启就没了)。这是对的 —— 浏览器真的没了。
|
|
8
|
+
*
|
|
9
|
+
* 但用户要的是「**桌面上有一张浏览器卡片,随时能进去看和用**」(2026-08-18 拍板,
|
|
10
|
+
* 推翻了当初 BrowserWindow 里那句「你不会想在桌面上永久摆着一张某次浏览的卡片」)。
|
|
11
|
+
* 卡片要活得比实例长:agent 昨天逛过的站,今天打开项目还应该看得见那张卡,双击
|
|
12
|
+
* 就回到那一页。所以要有一点**落盘**的状态。
|
|
13
|
+
*
|
|
14
|
+
* ## 落哪儿
|
|
15
|
+
*
|
|
16
|
+
* `<pid>/.browser/`,跟 chromium 的 profile 同级、在 `shared/` **外面**。
|
|
17
|
+
* 理由跟 profile 一样(registry.js 里核过):`shared/` 是 agent 的 cwd 也是
|
|
18
|
+
* artifact-file 的服务根,放进去等于让 agent 能 Read 自己的浏览历史、
|
|
19
|
+
* 还会被当文件服出去、还进 per-project git。删项目时 `<pid>/` 整个删掉,
|
|
20
|
+
* 这里跟着走,不用另写清理。
|
|
21
|
+
*
|
|
22
|
+
* ## 两份东西,两种性质
|
|
23
|
+
*
|
|
24
|
+
* - `state.json` —— 上次在哪一页(`{url, title, at}`)。导航/点击后写,**必须极便宜
|
|
25
|
+
* 且永不抛**:它长在导航路径上,为了一张卡片让 agent 的导航失败是划不来的。
|
|
26
|
+
* - `last.webp` —— 上次看到的样子(卡片上那块预览)。
|
|
27
|
+
* ⭐ 只在**本来就截了图**的时候顺手存(`browser_screenshot` 的视口档),
|
|
28
|
+
* 或者用户正在看画布、preview 端点现截一张。**不为了刷新卡片主动截图** ——
|
|
29
|
+
* 实测视口截图在 stripe/小红书这种站上要 1.7~4.2 秒,而这台机器只有 1 个核。
|
|
30
|
+
*
|
|
31
|
+
* 卡上的预览因此是「**上次看到的样子**」,跟 word 卡是同一路数(那也是一张静态页图)。
|
|
32
|
+
* 活的画面流只在窗打开时给:实测每 fps 约 3.1pp 单核,满帧 40%。
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import fs from 'node:fs/promises';
|
|
37
|
+
import { getProjectWorkspace } from '../../projects/workspace.js';
|
|
38
|
+
|
|
39
|
+
const dirOf = (projectId) => path.join(getProjectWorkspace(projectId), '.browser');
|
|
40
|
+
const STATE_FILE = 'state.json';
|
|
41
|
+
const FRAME_FILE = 'last.webp';
|
|
42
|
+
|
|
43
|
+
/** 卡片预览的绝对路径(存在与否由调用方 stat) */
|
|
44
|
+
export function framePath(projectId) {
|
|
45
|
+
return path.join(dirOf(projectId), FRAME_FILE);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 记一笔「现在在这一页」。**永不抛** —— 它挂在导航路径上。
|
|
50
|
+
* @param {string} projectId
|
|
51
|
+
* @param {import('playwright').Page} page
|
|
52
|
+
*/
|
|
53
|
+
export async function recordVisit(projectId, page) {
|
|
54
|
+
try {
|
|
55
|
+
const url = page.url();
|
|
56
|
+
if (!/^https?:/.test(url)) return; // about:blank / chrome-error 不值得记
|
|
57
|
+
const title = await page.title().catch(() => '');
|
|
58
|
+
const dir = dirOf(projectId);
|
|
59
|
+
await fs.mkdir(dir, { recursive: true });
|
|
60
|
+
await fs.writeFile(path.join(dir, STATE_FILE), `${JSON.stringify({
|
|
61
|
+
url, title: title || null, at: new Date().toISOString(),
|
|
62
|
+
}, null, 2)}\n`, 'utf8');
|
|
63
|
+
} catch { /* 记不下来就算了,不能因此让导航失败 */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 上次在哪一页。没逛过 → null(桌面上就没有这张卡)。
|
|
68
|
+
* @returns {Promise<{url: string, title: string|null, at: string}|null>}
|
|
69
|
+
*/
|
|
70
|
+
export async function readVisit(projectId) {
|
|
71
|
+
try {
|
|
72
|
+
const raw = await fs.readFile(path.join(dirOf(projectId), STATE_FILE), 'utf8');
|
|
73
|
+
const v = JSON.parse(raw);
|
|
74
|
+
return (v && typeof v.url === 'string' && /^https?:/.test(v.url)) ? v : null;
|
|
75
|
+
} catch { return null; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 存一帧当卡片预览。传的是原始截图 buffer,这里缩成卡片尺寸再落盘。
|
|
80
|
+
*
|
|
81
|
+
* 卡片预览区 640×400(`ARTIFACT_PREVIEW_H.browse`),主角档 ×1.5 = 960 宽,
|
|
82
|
+
* 所以存 1024 宽足够,再大是白占盘和白花编码时间。**永不抛。**
|
|
83
|
+
*/
|
|
84
|
+
export async function saveFrame(projectId, buf) {
|
|
85
|
+
try {
|
|
86
|
+
const { default: sharp } = await import('sharp');
|
|
87
|
+
const out = await sharp(buf).resize({ width: 1024, withoutEnlargement: true })
|
|
88
|
+
.webp({ quality: 72 }).toBuffer();
|
|
89
|
+
const dir = dirOf(projectId);
|
|
90
|
+
await fs.mkdir(dir, { recursive: true });
|
|
91
|
+
await fs.writeFile(path.join(dir, FRAME_FILE), out);
|
|
92
|
+
return out.length;
|
|
93
|
+
} catch { return 0; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 忘掉这个项目的浏览痕迹 —— 桌面上那张卡随之消失。
|
|
98
|
+
*
|
|
99
|
+
* 为什么需要:卡片的存在判据是 `state.json` 在不在,而它一旦写下就永远在 ——
|
|
100
|
+
* 一个月前逛过一次的项目,桌面上会一直摆着那张卡赶不走。这是"卡活得比实例长"
|
|
101
|
+
* 的代价,得给个出口。
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ **profile 不删**(`.browser/default/`):登录态是资产,用户点的是"这张卡
|
|
104
|
+
* 我不看了",不是"把我在那个站的登录清掉"。
|
|
105
|
+
*/
|
|
106
|
+
export async function forgetVisit(projectId) {
|
|
107
|
+
const dir = dirOf(projectId);
|
|
108
|
+
await Promise.all([
|
|
109
|
+
fs.unlink(path.join(dir, STATE_FILE)).catch(() => {}),
|
|
110
|
+
fs.unlink(path.join(dir, FRAME_FILE)).catch(() => {}),
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 上次那张预览的字节 + mtime;没有 → null */
|
|
115
|
+
export async function readFrame(projectId) {
|
|
116
|
+
const file = framePath(projectId);
|
|
117
|
+
try {
|
|
118
|
+
const [buf, st] = await Promise.all([fs.readFile(file), fs.stat(file)]);
|
|
119
|
+
return { buf, mtime: st.mtime };
|
|
120
|
+
} catch { return null; }
|
|
121
|
+
}
|