@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,366 +1,366 @@
1
- /**
2
- * server/api/pending-changes.js — 用户直接编辑 + 评论 buffer(C4)
3
- *
4
- * 用户在 canvas 上做的"直接改文本"和"写评论"在前端 push 到这里。下次发 chat
5
- * 消息时,turn.js 在 composeUserMessage 里 prepend 一个 system 提示告诉 agent
6
- * "有 N 处变更",agent 主动调 mcp__nodesign__get_pending_changes 拉详情。
7
- *
8
- * 路径(2026-08-13 起**项目级**,会话级留作 alias):
9
- * POST /api/projects/:pid/pending-changes append item
10
- * GET /api/projects/:pid/pending-changes 返 { items, count }
11
- * DELETE /api/projects/:pid/pending-changes 全清(也接 ?ids=)
12
- * (/:pid/sessions/:sid/... 同 handler 双挂载 —— 老前端和 jsonl 里的
13
- * 历史引用还打得通;sid 在扁平化后本来就只是路由上的仪式,
14
- * sessionRoot === 工作区根,这份 buffer 从来就是每项目一份)
15
- *
16
- * 文件:<工作区根>/pending-changes.json
17
- * { items: [{ id, kind, anchor, aiContext?, diff?, text?, linkedToEditId?, move?, styleDelta?, reactMount?, ts }] }
18
- *
19
- * 2026-05-12 起新增 kind:
20
- * - 'pending-move' 用户在 canvas 上拖动元素(前端虚拟改 DOM,agent run 时落源码)
21
- * 带 move: { container: anchor, before: anchor|null }
22
- * - 'pending-style' 用户拖动 / nudge 改样式(位移、对齐等)
23
- * 带 styleDelta: { left?, top?, marginLeft?, ... }
24
- * 可选 constraint: { x, y } (Figma 风格 anchor — 决定父 resize 时跟哪边)
25
- * - 'pending-duplicate' 用户 alt-drag 复制元素
26
- * 带 move: { container, before }
27
- * - 'pending-delete' 用户按 Del 删除元素
28
- * 全部可选带 reactMount: true 表示要改 JSX 源码不是 HTML。
29
- */
30
-
31
- import express from 'express';
32
- import { promises as fs } from 'fs';
33
- import path from 'path';
34
- import { randomUUID } from 'crypto';
35
- import { mutex } from 'async-mutex-lite';
36
- import { validateProjectId, getProject } from '../projects/store.js';
37
- import { guardProject } from './_guard.js';
38
- import {
39
- ensureSessionWorkspace, ensureProjectWorkspace, validateSessionId, getSharedDir,
40
- } from '../projects/workspace.js';
41
- import { renderRegionShot, saveRegionShot, PADDING, OUT_LONG_EDGE } from '../lib/region-shot.js';
42
- import { regionShotFromPage } from '../lib/docx-pages.js';
43
-
44
- const router = express.Router();
45
-
46
- const FILE_NAME = 'pending-changes.json';
47
- const MAX_ITEMS = 200;
48
-
49
- function guard(req, res) {
50
- // sid 只在走老 alias 时存在 —— 有就校验形状,没有就是项目级路由
51
- if (req.params.sid !== undefined) {
52
- try {
53
- validateSessionId(req.params.sid);
54
- } catch (err) {
55
- res.status(400).json({ error: err.message || 'invalid pid/sid' });
56
- return null;
57
- }
58
- }
59
- // pid 校验 + 存在性 + 归属(2026-07-30 多用户)统一走 guardProject
60
- return guardProject(req, res);
61
- }
62
-
63
- /** 两条挂载共用:alias 带 sid 走原路,项目级直接 ensure 工作区 */
64
- function rootOf(req) {
65
- return req.params.sid !== undefined
66
- ? ensureSessionWorkspace(req.params.pid, req.params.sid)
67
- : ensureProjectWorkspace(req.params.pid);
68
- }
69
-
70
- async function readBuf(sessionRoot) {
71
- const file = path.join(sessionRoot, FILE_NAME);
72
- try {
73
- const raw = await fs.readFile(file, 'utf8');
74
- const parsed = JSON.parse(raw);
75
- if (parsed && Array.isArray(parsed.items)) return { items: parsed.items, file };
76
- } catch (err) {
77
- if (err.code !== 'ENOENT') throw err;
78
- }
79
- return { items: [], file };
80
- }
81
-
82
- async function writeBuf(file, items) {
83
- await fs.writeFile(file, JSON.stringify({ items }, null, 2), 'utf8');
84
- }
85
-
86
- router.get(['/:pid/pending-changes', '/:pid/sessions/:sid/pending-changes'], async (req, res, next) => {
87
- try {
88
- if (!guard(req, res)) return;
89
- const sessionRoot = await rootOf(req);
90
- const { items } = await readBuf(sessionRoot);
91
- res.json({ items, count: items.length });
92
- } catch (err) { next(err); }
93
- });
94
-
95
- /**
96
- * 这次编辑是不是"把内容清空了"。`<br>`、`&nbsp;`、空白都算空 ——
97
- * contenteditable 清空后浏览器常留一个 `<br>` 占位。
98
- */
99
- function isBecameEmpty(diff) {
100
- if (!diff || typeof diff.newText !== 'string') return false;
101
- if (typeof diff.oldText === 'string' && diff.oldText.replace(/<[^>]*>|&nbsp;|\s+/g, '') === '') return false;
102
- return diff.newText.replace(/<br\s*\/?>|&nbsp;|<[^>]*>|\s+/gi, '') === '';
103
- }
104
-
105
- router.post(['/:pid/pending-changes', '/:pid/sessions/:sid/pending-changes'], async (req, res, next) => {
106
- try {
107
- if (!guard(req, res)) return;
108
- const body = req.body || {};
109
- const { kind, anchor } = body;
110
- // applied-*(2026-07-29):站点窗拖拽已直接落盘的 FYI 记录,agent 不再应用
111
- const VALID_KINDS = ['edit', 'comment', 'pending-move', 'pending-style', 'pending-duplicate', 'pending-delete',
112
- 'applied-move', 'applied-style', 'applied-duplicate'];
113
- if (!VALID_KINDS.includes(kind)) {
114
- return res.status(400).json({ error: `kind must be one of ${VALID_KINDS.join(' / ')}` });
115
- }
116
- if (!anchor || typeof anchor !== 'object') {
117
- return res.status(400).json({ error: 'anchor object required' });
118
- }
119
- if (kind === 'edit') {
120
- const { diff } = body;
121
- if (!diff || typeof diff !== 'object'
122
- || typeof diff.oldText !== 'string' || typeof diff.newText !== 'string') {
123
- return res.status(400).json({ error: 'edit kind requires diff: { oldText, newText }' });
124
- }
125
- } else if (kind === 'comment') {
126
- const { text } = body;
127
- if (typeof text !== 'string' || !text.trim()) {
128
- return res.status(400).json({ error: 'comment kind requires non-empty text' });
129
- }
130
- // linkedToEditId (optional) —— 把评论挂到某条 pending edit 上,作为该次操作的补充指令
131
- if (body.linkedToEditId !== undefined && typeof body.linkedToEditId !== 'string') {
132
- return res.status(400).json({ error: 'linkedToEditId must be string if present' });
133
- }
134
- } else if (kind === 'pending-move' || kind === 'pending-duplicate') {
135
- const { move } = body;
136
- if (!move || typeof move !== 'object' || !move.container || typeof move.container !== 'object') {
137
- return res.status(400).json({
138
- error: `${kind} requires move: { container: anchor, before: anchor|null }`,
139
- });
140
- }
141
- } else if (kind === 'pending-style') {
142
- const { styleDelta } = body;
143
- if (!styleDelta || typeof styleDelta !== 'object') {
144
- return res.status(400).json({ error: 'pending-style requires styleDelta object' });
145
- }
146
- }
147
- // pending-delete 只需 anchor
148
-
149
- const sessionRoot = await rootOf(req);
150
-
151
- // 接受 body.id(前端 newId('cmt') 等)以让前后端 id 统一——agent 调
152
- // clear_pending_changes 时 event 带 clearedIds,前端 comments state 用同一
153
- // id filter 出橙色 overlay。无传入则后端 randomUUID 兜底。
154
- const itemId = (typeof body.id === 'string' && body.id.trim()) ? body.id.trim() : randomUUID();
155
- const item = {
156
- id: itemId,
157
- kind,
158
- anchor,
159
- // 这条改动是对**哪个文件**做的(2026-07-28)。一个任务可以有多份 deck,
160
- // 站点更是天然多页 —— 不记路径的话 agent 只能把所有改动都套到它当前打开的
161
- // 那一份上,改错了还看不出来(anchor 在别的文件里根本找不到,静默 no-op)。
162
- ...(typeof body.path === 'string' && body.path ? { path: body.path } : {}),
163
- ...(body.aiContext ? { aiContext: body.aiContext } : {}),
164
- ...(body.reactMount === true ? { reactMount: true } : {}),
165
- ...(kind === 'edit' ? {
166
- diff: body.diff,
167
- // 用户把一段文字**清空**了(2026-08-18)。源码里留下的是个空壳
168
- // `<p class="foot-line"></p>` —— 它仍然有 margin/line-height,在页面上
169
- // 撑出一道空隙,用户看到的是"字没了但地方还空着"。以前 agent 只能从
170
- // `diff.newText === ''` 自己推断,而且第一次基本都漏,等用户下次截图
171
- // 才发现(问题库 iss_msc46ion_ydr8:同一个坑一个会话踩两次)。
172
- // 只打标记不自动删元素:用户也可能是清空重打,误删不可逆。
173
- ...(isBecameEmpty(body.diff) ? { becameEmpty: true } : {}),
174
- } : {}),
175
- ...(kind === 'comment' ? {
176
- text: body.text,
177
- ...(body.linkedToEditId ? { linkedToEditId: body.linkedToEditId } : {}),
178
- } : {}),
179
- ...((kind === 'pending-move' || kind === 'pending-duplicate'
180
- || kind === 'applied-move' || kind === 'applied-duplicate') ? { move: body.move } : {}),
181
- ...((kind === 'pending-style' || kind === 'applied-style') ? {
182
- styleDelta: body.styleDelta,
183
- ...(body.constraint && typeof body.constraint === 'object'
184
- ? { constraint: body.constraint } // { x: 'left'|'right'|'center'|'stretch', y: 'top'|'bottom'|'center'|'stretch' }
185
- : {}),
186
- } : {}),
187
- // applied-*:站点窗已落盘的 FYI 记录特有标记(applied=事实已写盘;
188
- // serializedFrom=落盘走了运行时序列化兜底,文件可能带脚本运行时产物)
189
- ...(body.applied === true ? { applied: true } : {}),
190
- ...(typeof body.serializedFrom === 'string' && body.serializedFrom
191
- ? { serializedFrom: body.serializedFrom } : {}),
192
- ts: new Date().toISOString(),
193
- };
194
-
195
- // 串行 read-modify-write 防多点击 race(用户连点 5 次评论 → 5 个 POST 几乎同时
196
- // 到达,原版 readBuf→push→writeBuf 无锁,后写者覆盖前写者 → 用户感"评论凭空消失"。
197
- // per-sessionRoot mutex 串行所有 pending-changes 写。
198
- const trimmedCount = await mutex(`pending:${sessionRoot}`, async () => {
199
- const { items, file } = await readBuf(sessionRoot);
200
- items.push(item);
201
- const trimmed = items.length > MAX_ITEMS ? items.slice(items.length - MAX_ITEMS) : items;
202
- await writeBuf(file, trimmed);
203
- return trimmed.length;
204
- });
205
-
206
- res.json({ ok: true, item, count: trimmedCount });
207
- } catch (err) { next(err); }
208
- });
209
-
210
- /**
211
- * 圈选评论(2026-08-07)—— 用户在预览上框一块,连同框里涉及的元素、一张
212
- * 该区域的截图、以及一句话交给 agent。
213
- *
214
- * 单独一条路由而不是复用上面那个 POST,因为它要做两件那边不做的事:
215
- * 跑一次 chromium 把区域截下来落盘,以及**不带 anchor**(圈的是一块地方
216
- * 不是一个元素,硬塞一个 anchor 只会让 agent 以为用户点的是某一个)。
217
- *
218
- * 截图失败不挡下单:元素清单和那句话本身就够 agent 干活了,为了一张图把
219
- * 用户刚圈完的东西整个丢掉是最差的选择。
220
- */
221
- router.post(['/:pid/region-comment', '/:pid/sessions/:sid/region-comment'], async (req, res, next) => {
222
- try {
223
- if (!guard(req, res)) return;
224
- const body = req.body || {};
225
- const { region, viewport } = body;
226
- const relPath = typeof body.path === 'string' ? body.path.replace(/\\/g, '/') : '';
227
- const text = typeof body.text === 'string' ? body.text.trim() : '';
228
-
229
- const num = (v) => typeof v === 'number' && Number.isFinite(v);
230
- if (!region || !num(region.x) || !num(region.y) || !(region.w > 0) || !(region.h > 0)) {
231
- return res.status(400).json({ error: 'region: { x, y, w, h } required' });
232
- }
233
- if (!viewport || !(viewport.width > 0) || !(viewport.height > 0)) {
234
- return res.status(400).json({ error: 'viewport: { width, height } required' });
235
- }
236
- // 页面路径相对项目工作区根。扁平化之前这里硬要求 `tasks/<任务>/<文件>`
237
- // 三段起步,现在根上的 `index.html` 只有一段,那条判据会把它全拒掉。
238
- const parts = relPath.split('/');
239
- if (!parts.length || parts.includes('..') || parts.some(p => !p || p.startsWith('.'))) {
240
- return res.status(400).json({ error: 'invalid page path' });
241
- }
242
- const sharedDir = path.resolve(getSharedDir(req.params.pid));
243
- const absPath = path.resolve(sharedDir, ...parts);
244
- if (!absPath.startsWith(sharedDir + path.sep)) {
245
- return res.status(400).json({ error: 'path escapes the workspace' });
246
- }
247
- try { await fs.access(absPath); } catch {
248
- return res.status(404).json({ error: `page not found: ${relPath}` });
249
- }
250
-
251
- const elements = Array.isArray(body.elements) ? body.elements.slice(0, 12) : [];
252
- const itemId = (typeof body.id === 'string' && body.id.trim()) ? body.id.trim() : randomUUID();
253
- const sessionRoot = await rootOf(req);
254
-
255
- let shot = null;
256
- let shotError = null;
257
- let shotNote = null; // 截图成功但通道退化(file:// 回退)时的提醒
258
- let docxPage = null; // docx 圈选:圈的是第几页(页图坐标系没有"整页文档"这回事)
259
- try {
260
- if (/\.docx$/i.test(relPath)) {
261
- // docx 没有 DOM 也进不了 chromium —— "用户所见"就是 LO 渲的页图,
262
- // 直接从页图缓存裁(region 坐标基准 = 页图原始像素,前端换算好再送)
263
- const r = await regionShotFromPage(absPath, body.docxPage, region,
264
- { pad: PADDING, longEdge: OUT_LONG_EDGE });
265
- shot = await saveRegionShot(sessionRoot, itemId, r.buf);
266
- docxPage = r.page;
267
- } else {
268
- const { buffer, degraded } = await renderRegionShot({
269
- absPath,
270
- projectId: req.params.pid,
271
- workspaceRoot: sharedDir,
272
- region: { x: region.x, y: region.y, w: region.w, h: region.h },
273
- viewport: { width: Math.round(viewport.width), height: Math.round(viewport.height) },
274
- });
275
- shot = await saveRegionShot(sessionRoot, itemId, buffer);
276
- // 没走成 http 的话这张图不能当"用户所见"用,得让 agent 知道
277
- if (degraded) shotNote = degraded;
278
- }
279
- } catch (err) {
280
- shotError = err?.message || String(err);
281
- console.warn('[region-comment] 截图失败,只带元素清单下单:', shotError);
282
- }
283
-
284
- const item = {
285
- id: itemId,
286
- kind: 'region-comment',
287
- path: relPath,
288
- text,
289
- region: { x: Math.round(region.x), y: Math.round(region.y), w: Math.round(region.w), h: Math.round(region.h) },
290
- ...(docxPage ? { docxPage } : {}),
291
- ...(body.container && typeof body.container === 'object' ? { container: body.container } : {}),
292
- viewport: { width: Math.round(viewport.width), height: Math.round(viewport.height) },
293
- elements,
294
- ...(shot ? { shot } : {}),
295
- ...(shotError ? { shotError } : {}),
296
- ...(shotNote ? { shotNote } : {}),
297
- ts: new Date().toISOString(),
298
- };
299
-
300
- const count = await mutex(`pending:${sessionRoot}`, async () => {
301
- const { items, file } = await readBuf(sessionRoot);
302
- items.push(item);
303
- const trimmed = items.length > MAX_ITEMS ? items.slice(items.length - MAX_ITEMS) : items;
304
- await writeBuf(file, trimmed);
305
- return trimmed.length;
306
- });
307
-
308
- res.json({ ok: true, item, count });
309
- } catch (err) { next(err); }
310
- });
311
-
312
- router.delete(['/:pid/pending-changes', '/:pid/sessions/:sid/pending-changes'], async (req, res, next) => {
313
- try {
314
- if (!guard(req, res)) return;
315
- const sessionRoot = await rootOf(req);
316
-
317
- // 同 POST:mutex 串行避免 DELETE / POST 并发互踩
318
- const idsParam = req.query?.ids;
319
- const idsSet = idsParam
320
- ? new Set(String(idsParam).split(',').map(s => s.trim()).filter(Boolean))
321
- : null;
322
- const result = await mutex(`pending:${sessionRoot}`, async () => {
323
- const { items, file } = await readBuf(sessionRoot);
324
- const filtered = idsSet ? items.filter(it => !idsSet.has(it.id)) : [];
325
- await writeBuf(file, filtered);
326
- return { removed: items.length - filtered.length, count: filtered.length };
327
- });
328
-
329
- res.json({ ok: true, ...result });
330
- } catch (err) { next(err); }
331
- });
332
-
333
- export default router;
334
-
335
- /**
336
- * Helper for turn.js / agent loop — 同步检查 buffer 是否非空(不加路由)。
337
- * @returns {Promise<{ count, summary }>}
338
- * summary 形如 "用户在过去时段做了 3 处变更(2 编辑 + 1 评论)"
339
- */
340
- export async function readPendingSummary(sessionRoot) {
341
- try {
342
- const { items } = await readBuf(sessionRoot);
343
- if (items.length === 0) return { count: 0, summary: '' };
344
- const edits = items.filter(it => it.kind === 'edit').length;
345
- const comments = items.filter(it => it.kind === 'comment').length;
346
- const moves = items.filter(it => it.kind === 'pending-move').length;
347
- const duplicates = items.filter(it => it.kind === 'pending-duplicate').length;
348
- const styles = items.filter(it => it.kind === 'pending-style').length;
349
- const deletes = items.filter(it => it.kind === 'pending-delete').length;
350
- const regions = items.filter(it => it.kind === 'region-comment').length;
351
- const parts = [];
352
- if (edits > 0) parts.push(`${edits} 编辑`);
353
- if (comments > 0) parts.push(`${comments} 评论`);
354
- if (regions > 0) parts.push(`${regions} 圈选`);
355
- if (moves > 0) parts.push(`${moves} 拖动`);
356
- if (duplicates > 0) parts.push(`${duplicates} 复制`);
357
- if (styles > 0) parts.push(`${styles} 样式`);
358
- if (deletes > 0) parts.push(`${deletes} 删除`);
359
- return {
360
- count: items.length,
361
- summary: `用户在过去时段做了 ${items.length} 处变更(${parts.join(' + ')})`,
362
- };
363
- } catch {
364
- return { count: 0, summary: '' };
365
- }
366
- }
1
+ /**
2
+ * server/api/pending-changes.js — 用户直接编辑 + 评论 buffer(C4)
3
+ *
4
+ * 用户在 canvas 上做的"直接改文本"和"写评论"在前端 push 到这里。下次发 chat
5
+ * 消息时,turn.js 在 composeUserMessage 里 prepend 一个 system 提示告诉 agent
6
+ * "有 N 处变更",agent 主动调 mcp__nodesign__get_pending_changes 拉详情。
7
+ *
8
+ * 路径(2026-08-13 起**项目级**,会话级留作 alias):
9
+ * POST /api/projects/:pid/pending-changes append item
10
+ * GET /api/projects/:pid/pending-changes 返 { items, count }
11
+ * DELETE /api/projects/:pid/pending-changes 全清(也接 ?ids=)
12
+ * (/:pid/sessions/:sid/... 同 handler 双挂载 —— 老前端和 jsonl 里的
13
+ * 历史引用还打得通;sid 在扁平化后本来就只是路由上的仪式,
14
+ * sessionRoot === 工作区根,这份 buffer 从来就是每项目一份)
15
+ *
16
+ * 文件:<工作区根>/pending-changes.json
17
+ * { items: [{ id, kind, anchor, aiContext?, diff?, text?, linkedToEditId?, move?, styleDelta?, reactMount?, ts }] }
18
+ *
19
+ * 2026-05-12 起新增 kind:
20
+ * - 'pending-move' 用户在 canvas 上拖动元素(前端虚拟改 DOM,agent run 时落源码)
21
+ * 带 move: { container: anchor, before: anchor|null }
22
+ * - 'pending-style' 用户拖动 / nudge 改样式(位移、对齐等)
23
+ * 带 styleDelta: { left?, top?, marginLeft?, ... }
24
+ * 可选 constraint: { x, y } (Figma 风格 anchor — 决定父 resize 时跟哪边)
25
+ * - 'pending-duplicate' 用户 alt-drag 复制元素
26
+ * 带 move: { container, before }
27
+ * - 'pending-delete' 用户按 Del 删除元素
28
+ * 全部可选带 reactMount: true 表示要改 JSX 源码不是 HTML。
29
+ */
30
+
31
+ import express from 'express';
32
+ import { promises as fs } from 'fs';
33
+ import path from 'path';
34
+ import { randomUUID } from 'crypto';
35
+ import { mutex } from 'async-mutex-lite';
36
+ import { validateProjectId, getProject } from '../projects/store.js';
37
+ import { guardProject } from './_guard.js';
38
+ import {
39
+ ensureSessionWorkspace, ensureProjectWorkspace, validateSessionId, getSharedDir,
40
+ } from '../projects/workspace.js';
41
+ import { renderRegionShot, saveRegionShot, PADDING, OUT_LONG_EDGE } from '../lib/region-shot.js';
42
+ import { regionShotFromPage } from '../lib/docx-pages.js';
43
+
44
+ const router = express.Router();
45
+
46
+ const FILE_NAME = 'pending-changes.json';
47
+ const MAX_ITEMS = 200;
48
+
49
+ function guard(req, res) {
50
+ // sid 只在走老 alias 时存在 —— 有就校验形状,没有就是项目级路由
51
+ if (req.params.sid !== undefined) {
52
+ try {
53
+ validateSessionId(req.params.sid);
54
+ } catch (err) {
55
+ res.status(400).json({ error: err.message || 'invalid pid/sid' });
56
+ return null;
57
+ }
58
+ }
59
+ // pid 校验 + 存在性 + 归属(2026-07-30 多用户)统一走 guardProject
60
+ return guardProject(req, res);
61
+ }
62
+
63
+ /** 两条挂载共用:alias 带 sid 走原路,项目级直接 ensure 工作区 */
64
+ function rootOf(req) {
65
+ return req.params.sid !== undefined
66
+ ? ensureSessionWorkspace(req.params.pid, req.params.sid)
67
+ : ensureProjectWorkspace(req.params.pid);
68
+ }
69
+
70
+ async function readBuf(sessionRoot) {
71
+ const file = path.join(sessionRoot, FILE_NAME);
72
+ try {
73
+ const raw = await fs.readFile(file, 'utf8');
74
+ const parsed = JSON.parse(raw);
75
+ if (parsed && Array.isArray(parsed.items)) return { items: parsed.items, file };
76
+ } catch (err) {
77
+ if (err.code !== 'ENOENT') throw err;
78
+ }
79
+ return { items: [], file };
80
+ }
81
+
82
+ async function writeBuf(file, items) {
83
+ await fs.writeFile(file, JSON.stringify({ items }, null, 2), 'utf8');
84
+ }
85
+
86
+ router.get(['/:pid/pending-changes', '/:pid/sessions/:sid/pending-changes'], async (req, res, next) => {
87
+ try {
88
+ if (!guard(req, res)) return;
89
+ const sessionRoot = await rootOf(req);
90
+ const { items } = await readBuf(sessionRoot);
91
+ res.json({ items, count: items.length });
92
+ } catch (err) { next(err); }
93
+ });
94
+
95
+ /**
96
+ * 这次编辑是不是"把内容清空了"。`<br>`、`&nbsp;`、空白都算空 ——
97
+ * contenteditable 清空后浏览器常留一个 `<br>` 占位。
98
+ */
99
+ function isBecameEmpty(diff) {
100
+ if (!diff || typeof diff.newText !== 'string') return false;
101
+ if (typeof diff.oldText === 'string' && diff.oldText.replace(/<[^>]*>|&nbsp;|\s+/g, '') === '') return false;
102
+ return diff.newText.replace(/<br\s*\/?>|&nbsp;|<[^>]*>|\s+/gi, '') === '';
103
+ }
104
+
105
+ router.post(['/:pid/pending-changes', '/:pid/sessions/:sid/pending-changes'], async (req, res, next) => {
106
+ try {
107
+ if (!guard(req, res)) return;
108
+ const body = req.body || {};
109
+ const { kind, anchor } = body;
110
+ // applied-*(2026-07-29):站点窗拖拽已直接落盘的 FYI 记录,agent 不再应用
111
+ const VALID_KINDS = ['edit', 'comment', 'pending-move', 'pending-style', 'pending-duplicate', 'pending-delete',
112
+ 'applied-move', 'applied-style', 'applied-duplicate'];
113
+ if (!VALID_KINDS.includes(kind)) {
114
+ return res.status(400).json({ error: `kind must be one of ${VALID_KINDS.join(' / ')}` });
115
+ }
116
+ if (!anchor || typeof anchor !== 'object') {
117
+ return res.status(400).json({ error: 'anchor object required' });
118
+ }
119
+ if (kind === 'edit') {
120
+ const { diff } = body;
121
+ if (!diff || typeof diff !== 'object'
122
+ || typeof diff.oldText !== 'string' || typeof diff.newText !== 'string') {
123
+ return res.status(400).json({ error: 'edit kind requires diff: { oldText, newText }' });
124
+ }
125
+ } else if (kind === 'comment') {
126
+ const { text } = body;
127
+ if (typeof text !== 'string' || !text.trim()) {
128
+ return res.status(400).json({ error: 'comment kind requires non-empty text' });
129
+ }
130
+ // linkedToEditId (optional) —— 把评论挂到某条 pending edit 上,作为该次操作的补充指令
131
+ if (body.linkedToEditId !== undefined && typeof body.linkedToEditId !== 'string') {
132
+ return res.status(400).json({ error: 'linkedToEditId must be string if present' });
133
+ }
134
+ } else if (kind === 'pending-move' || kind === 'pending-duplicate') {
135
+ const { move } = body;
136
+ if (!move || typeof move !== 'object' || !move.container || typeof move.container !== 'object') {
137
+ return res.status(400).json({
138
+ error: `${kind} requires move: { container: anchor, before: anchor|null }`,
139
+ });
140
+ }
141
+ } else if (kind === 'pending-style') {
142
+ const { styleDelta } = body;
143
+ if (!styleDelta || typeof styleDelta !== 'object') {
144
+ return res.status(400).json({ error: 'pending-style requires styleDelta object' });
145
+ }
146
+ }
147
+ // pending-delete 只需 anchor
148
+
149
+ const sessionRoot = await rootOf(req);
150
+
151
+ // 接受 body.id(前端 newId('cmt') 等)以让前后端 id 统一——agent 调
152
+ // clear_pending_changes 时 event 带 clearedIds,前端 comments state 用同一
153
+ // id filter 出橙色 overlay。无传入则后端 randomUUID 兜底。
154
+ const itemId = (typeof body.id === 'string' && body.id.trim()) ? body.id.trim() : randomUUID();
155
+ const item = {
156
+ id: itemId,
157
+ kind,
158
+ anchor,
159
+ // 这条改动是对**哪个文件**做的(2026-07-28)。一个任务可以有多份 deck,
160
+ // 站点更是天然多页 —— 不记路径的话 agent 只能把所有改动都套到它当前打开的
161
+ // 那一份上,改错了还看不出来(anchor 在别的文件里根本找不到,静默 no-op)。
162
+ ...(typeof body.path === 'string' && body.path ? { path: body.path } : {}),
163
+ ...(body.aiContext ? { aiContext: body.aiContext } : {}),
164
+ ...(body.reactMount === true ? { reactMount: true } : {}),
165
+ ...(kind === 'edit' ? {
166
+ diff: body.diff,
167
+ // 用户把一段文字**清空**了(2026-08-18)。源码里留下的是个空壳
168
+ // `<p class="foot-line"></p>` —— 它仍然有 margin/line-height,在页面上
169
+ // 撑出一道空隙,用户看到的是"字没了但地方还空着"。以前 agent 只能从
170
+ // `diff.newText === ''` 自己推断,而且第一次基本都漏,等用户下次截图
171
+ // 才发现(问题库 iss_msc46ion_ydr8:同一个坑一个会话踩两次)。
172
+ // 只打标记不自动删元素:用户也可能是清空重打,误删不可逆。
173
+ ...(isBecameEmpty(body.diff) ? { becameEmpty: true } : {}),
174
+ } : {}),
175
+ ...(kind === 'comment' ? {
176
+ text: body.text,
177
+ ...(body.linkedToEditId ? { linkedToEditId: body.linkedToEditId } : {}),
178
+ } : {}),
179
+ ...((kind === 'pending-move' || kind === 'pending-duplicate'
180
+ || kind === 'applied-move' || kind === 'applied-duplicate') ? { move: body.move } : {}),
181
+ ...((kind === 'pending-style' || kind === 'applied-style') ? {
182
+ styleDelta: body.styleDelta,
183
+ ...(body.constraint && typeof body.constraint === 'object'
184
+ ? { constraint: body.constraint } // { x: 'left'|'right'|'center'|'stretch', y: 'top'|'bottom'|'center'|'stretch' }
185
+ : {}),
186
+ } : {}),
187
+ // applied-*:站点窗已落盘的 FYI 记录特有标记(applied=事实已写盘;
188
+ // serializedFrom=落盘走了运行时序列化兜底,文件可能带脚本运行时产物)
189
+ ...(body.applied === true ? { applied: true } : {}),
190
+ ...(typeof body.serializedFrom === 'string' && body.serializedFrom
191
+ ? { serializedFrom: body.serializedFrom } : {}),
192
+ ts: new Date().toISOString(),
193
+ };
194
+
195
+ // 串行 read-modify-write 防多点击 race(用户连点 5 次评论 → 5 个 POST 几乎同时
196
+ // 到达,原版 readBuf→push→writeBuf 无锁,后写者覆盖前写者 → 用户感"评论凭空消失"。
197
+ // per-sessionRoot mutex 串行所有 pending-changes 写。
198
+ const trimmedCount = await mutex(`pending:${sessionRoot}`, async () => {
199
+ const { items, file } = await readBuf(sessionRoot);
200
+ items.push(item);
201
+ const trimmed = items.length > MAX_ITEMS ? items.slice(items.length - MAX_ITEMS) : items;
202
+ await writeBuf(file, trimmed);
203
+ return trimmed.length;
204
+ });
205
+
206
+ res.json({ ok: true, item, count: trimmedCount });
207
+ } catch (err) { next(err); }
208
+ });
209
+
210
+ /**
211
+ * 圈选评论(2026-08-07)—— 用户在预览上框一块,连同框里涉及的元素、一张
212
+ * 该区域的截图、以及一句话交给 agent。
213
+ *
214
+ * 单独一条路由而不是复用上面那个 POST,因为它要做两件那边不做的事:
215
+ * 跑一次 chromium 把区域截下来落盘,以及**不带 anchor**(圈的是一块地方
216
+ * 不是一个元素,硬塞一个 anchor 只会让 agent 以为用户点的是某一个)。
217
+ *
218
+ * 截图失败不挡下单:元素清单和那句话本身就够 agent 干活了,为了一张图把
219
+ * 用户刚圈完的东西整个丢掉是最差的选择。
220
+ */
221
+ router.post(['/:pid/region-comment', '/:pid/sessions/:sid/region-comment'], async (req, res, next) => {
222
+ try {
223
+ if (!guard(req, res)) return;
224
+ const body = req.body || {};
225
+ const { region, viewport } = body;
226
+ const relPath = typeof body.path === 'string' ? body.path.replace(/\\/g, '/') : '';
227
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
228
+
229
+ const num = (v) => typeof v === 'number' && Number.isFinite(v);
230
+ if (!region || !num(region.x) || !num(region.y) || !(region.w > 0) || !(region.h > 0)) {
231
+ return res.status(400).json({ error: 'region: { x, y, w, h } required' });
232
+ }
233
+ if (!viewport || !(viewport.width > 0) || !(viewport.height > 0)) {
234
+ return res.status(400).json({ error: 'viewport: { width, height } required' });
235
+ }
236
+ // 页面路径相对项目工作区根。扁平化之前这里硬要求 `tasks/<任务>/<文件>`
237
+ // 三段起步,现在根上的 `index.html` 只有一段,那条判据会把它全拒掉。
238
+ const parts = relPath.split('/');
239
+ if (!parts.length || parts.includes('..') || parts.some(p => !p || p.startsWith('.'))) {
240
+ return res.status(400).json({ error: 'invalid page path' });
241
+ }
242
+ const sharedDir = path.resolve(getSharedDir(req.params.pid));
243
+ const absPath = path.resolve(sharedDir, ...parts);
244
+ if (!absPath.startsWith(sharedDir + path.sep)) {
245
+ return res.status(400).json({ error: 'path escapes the workspace' });
246
+ }
247
+ try { await fs.access(absPath); } catch {
248
+ return res.status(404).json({ error: `page not found: ${relPath}` });
249
+ }
250
+
251
+ const elements = Array.isArray(body.elements) ? body.elements.slice(0, 12) : [];
252
+ const itemId = (typeof body.id === 'string' && body.id.trim()) ? body.id.trim() : randomUUID();
253
+ const sessionRoot = await rootOf(req);
254
+
255
+ let shot = null;
256
+ let shotError = null;
257
+ let shotNote = null; // 截图成功但通道退化(file:// 回退)时的提醒
258
+ let docxPage = null; // docx 圈选:圈的是第几页(页图坐标系没有"整页文档"这回事)
259
+ try {
260
+ if (/\.docx$/i.test(relPath)) {
261
+ // docx 没有 DOM 也进不了 chromium —— "用户所见"就是 LO 渲的页图,
262
+ // 直接从页图缓存裁(region 坐标基准 = 页图原始像素,前端换算好再送)
263
+ const r = await regionShotFromPage(absPath, body.docxPage, region,
264
+ { pad: PADDING, longEdge: OUT_LONG_EDGE });
265
+ shot = await saveRegionShot(sessionRoot, itemId, r.buf);
266
+ docxPage = r.page;
267
+ } else {
268
+ const { buffer, degraded } = await renderRegionShot({
269
+ absPath,
270
+ projectId: req.params.pid,
271
+ workspaceRoot: sharedDir,
272
+ region: { x: region.x, y: region.y, w: region.w, h: region.h },
273
+ viewport: { width: Math.round(viewport.width), height: Math.round(viewport.height) },
274
+ });
275
+ shot = await saveRegionShot(sessionRoot, itemId, buffer);
276
+ // 没走成 http 的话这张图不能当"用户所见"用,得让 agent 知道
277
+ if (degraded) shotNote = degraded;
278
+ }
279
+ } catch (err) {
280
+ shotError = err?.message || String(err);
281
+ console.warn('[region-comment] 截图失败,只带元素清单下单:', shotError);
282
+ }
283
+
284
+ const item = {
285
+ id: itemId,
286
+ kind: 'region-comment',
287
+ path: relPath,
288
+ text,
289
+ region: { x: Math.round(region.x), y: Math.round(region.y), w: Math.round(region.w), h: Math.round(region.h) },
290
+ ...(docxPage ? { docxPage } : {}),
291
+ ...(body.container && typeof body.container === 'object' ? { container: body.container } : {}),
292
+ viewport: { width: Math.round(viewport.width), height: Math.round(viewport.height) },
293
+ elements,
294
+ ...(shot ? { shot } : {}),
295
+ ...(shotError ? { shotError } : {}),
296
+ ...(shotNote ? { shotNote } : {}),
297
+ ts: new Date().toISOString(),
298
+ };
299
+
300
+ const count = await mutex(`pending:${sessionRoot}`, async () => {
301
+ const { items, file } = await readBuf(sessionRoot);
302
+ items.push(item);
303
+ const trimmed = items.length > MAX_ITEMS ? items.slice(items.length - MAX_ITEMS) : items;
304
+ await writeBuf(file, trimmed);
305
+ return trimmed.length;
306
+ });
307
+
308
+ res.json({ ok: true, item, count });
309
+ } catch (err) { next(err); }
310
+ });
311
+
312
+ router.delete(['/:pid/pending-changes', '/:pid/sessions/:sid/pending-changes'], async (req, res, next) => {
313
+ try {
314
+ if (!guard(req, res)) return;
315
+ const sessionRoot = await rootOf(req);
316
+
317
+ // 同 POST:mutex 串行避免 DELETE / POST 并发互踩
318
+ const idsParam = req.query?.ids;
319
+ const idsSet = idsParam
320
+ ? new Set(String(idsParam).split(',').map(s => s.trim()).filter(Boolean))
321
+ : null;
322
+ const result = await mutex(`pending:${sessionRoot}`, async () => {
323
+ const { items, file } = await readBuf(sessionRoot);
324
+ const filtered = idsSet ? items.filter(it => !idsSet.has(it.id)) : [];
325
+ await writeBuf(file, filtered);
326
+ return { removed: items.length - filtered.length, count: filtered.length };
327
+ });
328
+
329
+ res.json({ ok: true, ...result });
330
+ } catch (err) { next(err); }
331
+ });
332
+
333
+ export default router;
334
+
335
+ /**
336
+ * Helper for turn.js / agent loop — 同步检查 buffer 是否非空(不加路由)。
337
+ * @returns {Promise<{ count, summary }>}
338
+ * summary 形如 "用户在过去时段做了 3 处变更(2 编辑 + 1 评论)"
339
+ */
340
+ export async function readPendingSummary(sessionRoot) {
341
+ try {
342
+ const { items } = await readBuf(sessionRoot);
343
+ if (items.length === 0) return { count: 0, summary: '' };
344
+ const edits = items.filter(it => it.kind === 'edit').length;
345
+ const comments = items.filter(it => it.kind === 'comment').length;
346
+ const moves = items.filter(it => it.kind === 'pending-move').length;
347
+ const duplicates = items.filter(it => it.kind === 'pending-duplicate').length;
348
+ const styles = items.filter(it => it.kind === 'pending-style').length;
349
+ const deletes = items.filter(it => it.kind === 'pending-delete').length;
350
+ const regions = items.filter(it => it.kind === 'region-comment').length;
351
+ const parts = [];
352
+ if (edits > 0) parts.push(`${edits} 编辑`);
353
+ if (comments > 0) parts.push(`${comments} 评论`);
354
+ if (regions > 0) parts.push(`${regions} 圈选`);
355
+ if (moves > 0) parts.push(`${moves} 拖动`);
356
+ if (duplicates > 0) parts.push(`${duplicates} 复制`);
357
+ if (styles > 0) parts.push(`${styles} 样式`);
358
+ if (deletes > 0) parts.push(`${deletes} 删除`);
359
+ return {
360
+ count: items.length,
361
+ summary: `用户在过去时段做了 ${items.length} 处变更(${parts.join(' + ')})`,
362
+ };
363
+ } catch {
364
+ return { count: 0, summary: '' };
365
+ }
366
+ }