@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.
Files changed (282) hide show
  1. package/README.md +156 -106
  2. package/bin/nodesign.js +0 -0
  3. package/package.json +1 -1
  4. package/server/api/README.md +45 -45
  5. package/server/api/_guard.js +75 -75
  6. package/server/api/admin.js +187 -187
  7. package/server/api/assets/docx-page.js +103 -103
  8. package/server/api/assets/helpers.js +22 -22
  9. package/server/api/assets/notes.js +144 -144
  10. package/server/api/assets.js +896 -896
  11. package/server/api/board.js +57 -57
  12. package/server/api/browse.js +138 -138
  13. package/server/api/canvas.js +444 -444
  14. package/server/api/chatai.js +324 -324
  15. package/server/api/exports/build-standalone.js +980 -980
  16. package/server/api/exports/cards.js +118 -118
  17. package/server/api/exports/docx-pdf.js +46 -46
  18. package/server/api/exports/export-page.js +142 -142
  19. package/server/api/exports/handoff.js +147 -147
  20. package/server/api/exports.js +690 -690
  21. package/server/api/instruction.js +75 -75
  22. package/server/api/me.js +116 -116
  23. package/server/api/memory.js +185 -185
  24. package/server/api/pending-changes.js +366 -366
  25. package/server/api/plugins.js +164 -164
  26. package/server/api/projects.js +220 -220
  27. package/server/api/publish.js +64 -64
  28. package/server/api/recent.js +73 -73
  29. package/server/api/sessions.js +589 -589
  30. package/server/api/skills.js +77 -77
  31. package/server/api/standalone-fit.js +168 -168
  32. package/server/api/turn-compose.js +180 -180
  33. package/server/api/turn-inflight.js +44 -44
  34. package/server/auth/README.md +17 -17
  35. package/server/auth/middleware.js +138 -138
  36. package/server/auth/origin-guard.js +71 -71
  37. package/server/auth/session.js +108 -108
  38. package/server/auth/tier.js +113 -113
  39. package/server/auth/users-store.js +348 -348
  40. package/server/edit/README.md +29 -29
  41. package/server/engine/README.md +113 -113
  42. package/server/engine/agent/agent-shared.js +550 -550
  43. package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
  44. package/server/engine/agent/auto-mode-rules.js +78 -78
  45. package/server/engine/agent/context.js +418 -418
  46. package/server/engine/agent/events.js +464 -464
  47. package/server/engine/agent/hooks/canvas-validate.js +199 -199
  48. package/server/engine/agent/hooks/failure.js +120 -120
  49. package/server/engine/agent/hooks/file-events.js +75 -75
  50. package/server/engine/agent/hooks/lifecycle.js +233 -233
  51. package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
  52. package/server/engine/agent/hooks/post-guidance.js +206 -206
  53. package/server/engine/agent/hooks/post-subagent-report.js +104 -104
  54. package/server/engine/agent/hooks/post-trim.js +58 -58
  55. package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
  56. package/server/engine/agent/hooks/pre-defaults.js +84 -84
  57. package/server/engine/agent/hooks/pre-injectors.js +277 -277
  58. package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
  59. package/server/engine/agent/hooks/pre-starter-files.js +61 -61
  60. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
  61. package/server/engine/agent/hooks/site-validate.js +94 -94
  62. package/server/engine/agent/hooks/tool-prompts.js +31 -31
  63. package/server/engine/agent/hooks/turn-state-memory.js +56 -56
  64. package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
  65. package/server/engine/agent/hooks.js +354 -354
  66. package/server/engine/agent/init-contract.js +69 -69
  67. package/server/engine/agent/isolation.js +159 -159
  68. package/server/engine/agent/model-table.js +292 -292
  69. package/server/engine/agent/plugin-loader.js +266 -266
  70. package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
  71. package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
  72. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
  73. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
  74. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
  75. package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
  76. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
  77. package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
  78. package/server/engine/agent/prompts/tools/site-reference.md +106 -106
  79. package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
  80. package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
  81. package/server/engine/agent/session-loop.js +1024 -1024
  82. package/server/engine/agent/session-model.js +164 -164
  83. package/server/engine/agent/skill.js +204 -204
  84. package/server/engine/agent/task-events.js +78 -78
  85. package/server/engine/agents/ds-extractor.md +132 -132
  86. package/server/engine/agents/explorer.md +190 -190
  87. package/server/engine/agents/index.js +230 -230
  88. package/server/engine/agents/schemas/design-system.json +156 -156
  89. package/server/engine/agents/schemas/tweak-schema.json +113 -113
  90. package/server/engine/agents/tweak-proposer.md +127 -127
  91. package/server/engine/agents/vision-checker.md +254 -254
  92. package/server/engine/browse/capture.js +422 -422
  93. package/server/engine/browse/card.js +137 -137
  94. package/server/engine/browse/handover.js +59 -59
  95. package/server/engine/browse/page-digest.js +119 -119
  96. package/server/engine/browse/refs.js +196 -196
  97. package/server/engine/browse/registry.js +307 -307
  98. package/server/engine/browse/screencast.js +170 -170
  99. package/server/engine/browse/state.js +121 -121
  100. package/server/engine/chatai/chat-log.js +79 -79
  101. package/server/engine/chatai/index.js +193 -193
  102. package/server/engine/chatai/openai-compat.js +152 -152
  103. package/server/engine/chatai/orchestrate.js +309 -309
  104. package/server/engine/chatai/perform.js +40 -40
  105. package/server/engine/chatai/summarize.js +79 -79
  106. package/server/engine/mcp/capability-gate.js +52 -52
  107. package/server/engine/mcp/index.js +339 -339
  108. package/server/engine/mcp/param-sanitizer.js +142 -142
  109. package/server/engine/mcp/tools/arrange-on-board.js +124 -124
  110. package/server/engine/mcp/tools/artifact-session.js +278 -278
  111. package/server/engine/mcp/tools/browse-computer.js +421 -421
  112. package/server/engine/mcp/tools/browse-find-batch.js +183 -183
  113. package/server/engine/mcp/tools/browse-screenshot.js +157 -157
  114. package/server/engine/mcp/tools/browse.js +480 -480
  115. package/server/engine/mcp/tools/build-docx.js +101 -101
  116. package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
  117. package/server/engine/mcp/tools/create-on-board.js +155 -155
  118. package/server/engine/mcp/tools/crystallize-skill.js +168 -168
  119. package/server/engine/mcp/tools/explain-style.js +237 -237
  120. package/server/engine/mcp/tools/export-handoff.js +133 -133
  121. package/server/engine/mcp/tools/expose-tweaks.js +149 -149
  122. package/server/engine/mcp/tools/generate-image.js +842 -842
  123. package/server/engine/mcp/tools/get-computed-styles.js +164 -164
  124. package/server/engine/mcp/tools/get-pending-changes.js +205 -205
  125. package/server/engine/mcp/tools/h3box-ssh.js +84 -84
  126. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
  127. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
  128. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
  129. package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
  130. package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
  131. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
  132. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
  133. package/server/engine/mcp/tools/highlight.js +68 -68
  134. package/server/engine/mcp/tools/list-pages.js +222 -222
  135. package/server/engine/mcp/tools/lookup-tags.js +59 -59
  136. package/server/engine/mcp/tools/navigate-to-page.js +60 -60
  137. package/server/engine/mcp/tools/organize-board.js +64 -64
  138. package/server/engine/mcp/tools/paint-still.js +434 -434
  139. package/server/engine/mcp/tools/pin-to-board.js +130 -130
  140. package/server/engine/mcp/tools/preview-deck.js +130 -130
  141. package/server/engine/mcp/tools/profile-scroll.js +305 -305
  142. package/server/engine/mcp/tools/publish-site.js +102 -102
  143. package/server/engine/mcp/tools/query-elements.js +195 -195
  144. package/server/engine/mcp/tools/read-board.js +103 -103
  145. package/server/engine/mcp/tools/read-document.js +93 -93
  146. package/server/engine/mcp/tools/read-page.js +264 -264
  147. package/server/engine/mcp/tools/read-tavern-json.js +143 -143
  148. package/server/engine/mcp/tools/record-decision.js +140 -140
  149. package/server/engine/mcp/tools/relate-on-board.js +110 -110
  150. package/server/engine/mcp/tools/report-issue.js +115 -115
  151. package/server/engine/mcp/tools/roll-film.js +266 -266
  152. package/server/engine/mcp/tools/screenshot-docx.js +138 -138
  153. package/server/engine/mcp/tools/screenshot-url.js +195 -195
  154. package/server/engine/mcp/tools/screenshot.js +583 -583
  155. package/server/engine/mcp/tools/tier-gate.js +96 -96
  156. package/server/engine/mcp/tools/trace-motion.js +215 -215
  157. package/server/engine/mcp/tools/web-search.js +548 -548
  158. package/server/engine/motion/inventory.js +349 -349
  159. package/server/engine/perception/session.js +235 -235
  160. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
  161. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
  162. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
  163. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
  164. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
  165. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
  166. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
  167. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
  168. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
  169. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
  170. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
  171. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
  172. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
  173. 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
  174. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
  175. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
  176. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
  177. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
  178. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
  179. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
  180. package/server/engine/runs/active-runs.js +698 -698
  181. package/server/engine/runs/live-turn.js +252 -252
  182. package/server/engine/runs/store.js +364 -364
  183. package/server/engine/runs/turn-relay.js +217 -217
  184. package/server/engine/runtime/workspace.js +164 -164
  185. package/server/lib/artifact-file-path.js +80 -80
  186. package/server/lib/artifact-target.js +284 -284
  187. package/server/lib/asset-refs.js +239 -239
  188. package/server/lib/async-queue.js +104 -104
  189. package/server/lib/auto-relations.js +130 -130
  190. package/server/lib/binding-types.js +60 -60
  191. package/server/lib/board-kind-sizes.js +62 -62
  192. package/server/lib/board-relations.js +133 -133
  193. package/server/lib/browse-proxy.js +180 -180
  194. package/server/lib/canvas-id.js +36 -36
  195. package/server/lib/cover.js +227 -227
  196. package/server/lib/danbooru-tags.js +291 -291
  197. package/server/lib/doc-extract.js +156 -156
  198. package/server/lib/docx/build-from-source.js +260 -260
  199. package/server/lib/docx/build.js +490 -490
  200. package/server/lib/docx/dump-styles.js +287 -287
  201. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
  202. package/server/lib/docx/merge-runs.js +129 -129
  203. package/server/lib/docx/numbering.js +218 -218
  204. package/server/lib/docx/order.js +152 -152
  205. package/server/lib/docx/rawzip.js +172 -172
  206. package/server/lib/docx/render.js +92 -92
  207. package/server/lib/docx/text-lint.js +225 -225
  208. package/server/lib/docx/tokens.js +335 -335
  209. package/server/lib/docx/units.js +29 -29
  210. package/server/lib/docx/xml.js +291 -291
  211. package/server/lib/docx-pages.js +187 -187
  212. package/server/lib/export-collect.js +289 -289
  213. package/server/lib/export-package.js +210 -210
  214. package/server/lib/html-srcset.js +145 -145
  215. package/server/lib/image-variant.js +460 -460
  216. package/server/lib/ingress/forward-openai-chat.js +305 -305
  217. package/server/lib/ingress/openai-chat.js +491 -491
  218. package/server/lib/ingress/session-notice.js +66 -66
  219. package/server/lib/ingress/session-routes.js +68 -68
  220. package/server/lib/ingress/slot-probe.js +130 -130
  221. package/server/lib/ingress/upstream-billing.js +52 -52
  222. package/server/lib/ingress/upstream-fail-streak.js +72 -72
  223. package/server/lib/ingress/upstream-truncation.js +48 -48
  224. package/server/lib/issues-store.js +182 -182
  225. package/server/lib/kinds/deck.js +72 -72
  226. package/server/lib/kinds/docx.js +283 -283
  227. package/server/lib/kinds/file-kinds.js +113 -113
  228. package/server/lib/kinds/index.js +202 -202
  229. package/server/lib/kinds/site.js +161 -161
  230. package/server/lib/model-ingress.js +543 -543
  231. package/server/lib/moderation.js +255 -255
  232. package/server/lib/notice-store.js +103 -103
  233. package/server/lib/plugin-install.js +104 -104
  234. package/server/lib/plugin-validator.js +721 -721
  235. package/server/lib/publish-store.js +115 -115
  236. package/server/lib/quick-summary.js +52 -52
  237. package/server/lib/quota.js +270 -270
  238. package/server/lib/rate-window.js +29 -29
  239. package/server/lib/reference-assets.js +92 -92
  240. package/server/lib/region-shot.js +135 -135
  241. package/server/lib/safe-path.js +73 -73
  242. package/server/lib/sdk-session.js +33 -33
  243. package/server/lib/showcase-store.js +92 -92
  244. package/server/lib/site-publish.js +465 -465
  245. package/server/lib/ssrf-guard.js +432 -432
  246. package/server/lib/task-scan.js +158 -158
  247. package/server/lib/tavern-json.js +154 -154
  248. package/server/lib/video-variant.js +237 -237
  249. package/server/lib/workspace-path.js +33 -33
  250. package/server/ops/fix-sdk-musl.mjs +40 -40
  251. package/server/ops/install-macos-fonts.sh +114 -114
  252. package/server/ops/macos-fonts.conf +336 -336
  253. package/server/ops/sandbox-shim/bwrap +57 -57
  254. package/server/projects/assets-summary.js +119 -119
  255. package/server/projects/auto-name.js +53 -53
  256. package/server/projects/board-store.js +640 -640
  257. package/server/projects/move-entry.js +103 -103
  258. package/server/projects/store.js +270 -270
  259. package/server/projects/ui-config.js +54 -54
  260. package/server/projects/workspace-templates.js +53 -53
  261. package/server/projects/workspace.js +1025 -1025
  262. package/server/runtime/local-config.js +177 -177
  263. package/server/runtime/profile.js +72 -72
  264. package/server/services/rembg-service.py +334 -334
  265. package/server/shared/README.md +25 -25
  266. package/server/shared/deck.js +54 -54
  267. package/server/shared/time.js +49 -49
  268. package/server/style-pipeline/README.md +53 -53
  269. package/server/ws/broker.js +36 -36
  270. package/server/ws/browse-channel.js +177 -177
  271. package/server/ws/index.js +386 -386
  272. package/web/README.md +89 -89
  273. package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
  274. package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
  275. package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
  276. package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
  277. package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
  278. package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
  279. package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
  280. package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
  281. package/web/dist/index.html +17 -17
  282. 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
+ }