@xiaobuyu/nodesign 0.0.1
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/LICENSE +661 -0
- package/README.md +140 -0
- package/bin/nodesign.js +176 -0
- package/package.json +104 -0
- package/server/api/README.md +45 -0
- package/server/api/_guard.js +75 -0
- package/server/api/admin.js +187 -0
- package/server/api/assets/docx-page.js +103 -0
- package/server/api/assets/helpers.js +22 -0
- package/server/api/assets/notes.js +144 -0
- package/server/api/assets.js +896 -0
- package/server/api/board.js +57 -0
- package/server/api/browse.js +138 -0
- package/server/api/canvas.js +444 -0
- package/server/api/chatai.js +324 -0
- package/server/api/exports/build-standalone.js +980 -0
- package/server/api/exports/cards.js +118 -0
- package/server/api/exports/docx-pdf.js +46 -0
- package/server/api/exports/export-page.js +142 -0
- package/server/api/exports/handoff.js +147 -0
- package/server/api/exports.js +690 -0
- package/server/api/instruction.js +75 -0
- package/server/api/local.js +115 -0
- package/server/api/me.js +116 -0
- package/server/api/memory.js +185 -0
- package/server/api/pending-changes.js +366 -0
- package/server/api/plugins.js +164 -0
- package/server/api/projects.js +220 -0
- package/server/api/publish.js +64 -0
- package/server/api/recent.js +73 -0
- package/server/api/sessions.js +589 -0
- package/server/api/skills.js +77 -0
- package/server/api/standalone-fit.js +168 -0
- package/server/api/turn-compose.js +180 -0
- package/server/api/turn-inflight.js +44 -0
- package/server/api/turn.js +669 -0
- package/server/auth/README.md +17 -0
- package/server/auth/middleware.js +138 -0
- package/server/auth/origin-guard.js +71 -0
- package/server/auth/session.js +108 -0
- package/server/auth/tier.js +113 -0
- package/server/auth/users-store.js +348 -0
- package/server/edit/README.md +29 -0
- package/server/engine/README.md +113 -0
- package/server/engine/agent/agent-shared.js +550 -0
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -0
- package/server/engine/agent/auto-mode-rules.js +78 -0
- package/server/engine/agent/context.js +418 -0
- package/server/engine/agent/events.js +464 -0
- package/server/engine/agent/hooks/canvas-validate.js +199 -0
- package/server/engine/agent/hooks/failure.js +120 -0
- package/server/engine/agent/hooks/file-events.js +75 -0
- package/server/engine/agent/hooks/lifecycle.js +233 -0
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -0
- package/server/engine/agent/hooks/post-guidance.js +206 -0
- package/server/engine/agent/hooks/post-subagent-report.js +104 -0
- package/server/engine/agent/hooks/post-trim.js +58 -0
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -0
- package/server/engine/agent/hooks/pre-defaults.js +84 -0
- package/server/engine/agent/hooks/pre-injectors.js +277 -0
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -0
- package/server/engine/agent/hooks/pre-starter-files.js +61 -0
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -0
- package/server/engine/agent/hooks/site-validate.js +94 -0
- package/server/engine/agent/hooks/tool-prompts.js +31 -0
- package/server/engine/agent/hooks/turn-state-memory.js +56 -0
- package/server/engine/agent/hooks/user-prompt-submit.js +215 -0
- package/server/engine/agent/hooks.js +354 -0
- package/server/engine/agent/init-contract.js +69 -0
- package/server/engine/agent/isolation.js +159 -0
- package/server/engine/agent/model-context.js +359 -0
- package/server/engine/agent/model-table.js +292 -0
- package/server/engine/agent/plugin-loader.js +266 -0
- package/server/engine/agent/prompts/nodesign-prelude.md +336 -0
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -0
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -0
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -0
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -0
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -0
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -0
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -0
- package/server/engine/agent/prompts/tools/site-reference.md +106 -0
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -0
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -0
- package/server/engine/agent/session-loop.js +1024 -0
- package/server/engine/agent/session-model.js +164 -0
- package/server/engine/agent/skill.js +204 -0
- package/server/engine/agent/system-prompts.js +86 -0
- package/server/engine/agent/task-events.js +78 -0
- package/server/engine/agents/ds-extractor.md +132 -0
- package/server/engine/agents/explorer.md +190 -0
- package/server/engine/agents/index.js +230 -0
- package/server/engine/agents/schemas/design-system.json +156 -0
- package/server/engine/agents/schemas/tweak-schema.json +113 -0
- package/server/engine/agents/tweak-proposer.md +127 -0
- package/server/engine/agents/vision-checker.md +254 -0
- package/server/engine/browse/capture.js +422 -0
- package/server/engine/browse/card.js +137 -0
- package/server/engine/browse/handover.js +59 -0
- package/server/engine/browse/page-digest.js +119 -0
- package/server/engine/browse/refs.js +196 -0
- package/server/engine/browse/registry.js +307 -0
- package/server/engine/browse/screencast.js +170 -0
- package/server/engine/browse/state.js +121 -0
- package/server/engine/chatai/chat-log.js +79 -0
- package/server/engine/chatai/index.js +193 -0
- package/server/engine/chatai/openai-compat.js +152 -0
- package/server/engine/chatai/orchestrate.js +309 -0
- package/server/engine/chatai/perform.js +40 -0
- package/server/engine/chatai/summarize.js +79 -0
- package/server/engine/mcp/capability-gate.js +52 -0
- package/server/engine/mcp/index.js +339 -0
- package/server/engine/mcp/param-sanitizer.js +142 -0
- package/server/engine/mcp/tools/arrange-on-board.js +124 -0
- package/server/engine/mcp/tools/artifact-session.js +278 -0
- package/server/engine/mcp/tools/browse-computer.js +421 -0
- package/server/engine/mcp/tools/browse-find-batch.js +183 -0
- package/server/engine/mcp/tools/browse-screenshot.js +157 -0
- package/server/engine/mcp/tools/browse.js +480 -0
- package/server/engine/mcp/tools/build-docx.js +101 -0
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -0
- package/server/engine/mcp/tools/create-on-board.js +155 -0
- package/server/engine/mcp/tools/crystallize-skill.js +168 -0
- package/server/engine/mcp/tools/deliver-files.js +0 -0
- package/server/engine/mcp/tools/explain-style.js +237 -0
- package/server/engine/mcp/tools/export-handoff.js +133 -0
- package/server/engine/mcp/tools/expose-tweaks.js +149 -0
- package/server/engine/mcp/tools/generate-image.js +842 -0
- package/server/engine/mcp/tools/get-computed-styles.js +164 -0
- package/server/engine/mcp/tools/get-pending-changes.js +205 -0
- package/server/engine/mcp/tools/h3box-ssh.js +84 -0
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -0
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -0
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -0
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -0
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -0
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -0
- package/server/engine/mcp/tools/helpers/rembg.js +273 -0
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -0
- package/server/engine/mcp/tools/highlight.js +68 -0
- package/server/engine/mcp/tools/list-pages.js +222 -0
- package/server/engine/mcp/tools/lookup-tags.js +59 -0
- package/server/engine/mcp/tools/navigate-to-page.js +60 -0
- package/server/engine/mcp/tools/organize-board.js +64 -0
- package/server/engine/mcp/tools/paint-still.js +434 -0
- package/server/engine/mcp/tools/pin-to-board.js +130 -0
- package/server/engine/mcp/tools/preview-deck.js +130 -0
- package/server/engine/mcp/tools/profile-scroll.js +305 -0
- package/server/engine/mcp/tools/publish-site.js +102 -0
- package/server/engine/mcp/tools/query-elements.js +195 -0
- package/server/engine/mcp/tools/read-board.js +103 -0
- package/server/engine/mcp/tools/read-document.js +93 -0
- package/server/engine/mcp/tools/read-page.js +264 -0
- package/server/engine/mcp/tools/read-tavern-json.js +143 -0
- package/server/engine/mcp/tools/record-decision.js +140 -0
- package/server/engine/mcp/tools/relate-on-board.js +110 -0
- package/server/engine/mcp/tools/remove-background.js +391 -0
- package/server/engine/mcp/tools/report-issue.js +115 -0
- package/server/engine/mcp/tools/roll-film.js +266 -0
- package/server/engine/mcp/tools/screenshot-docx.js +138 -0
- package/server/engine/mcp/tools/screenshot-url.js +195 -0
- package/server/engine/mcp/tools/screenshot.js +583 -0
- package/server/engine/mcp/tools/tier-gate.js +96 -0
- package/server/engine/mcp/tools/trace-motion.js +215 -0
- package/server/engine/mcp/tools/web-search.js +548 -0
- package/server/engine/motion/inventory.js +349 -0
- package/server/engine/perception/session.js +235 -0
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -0
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -0
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -0
- 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 -0
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -0
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -0
- package/server/engine/runs/active-runs.js +698 -0
- package/server/engine/runs/live-turn.js +252 -0
- package/server/engine/runs/store.js +364 -0
- package/server/engine/runs/turn-relay.js +217 -0
- package/server/engine/runtime/workspace.js +164 -0
- package/server/index.js +235 -0
- package/server/lib/artifact-file-path.js +80 -0
- package/server/lib/artifact-target.js +284 -0
- package/server/lib/asset-refs.js +239 -0
- package/server/lib/async-queue.js +104 -0
- package/server/lib/auto-relations.js +130 -0
- package/server/lib/binding-types.js +60 -0
- package/server/lib/board-kind-sizes.js +62 -0
- package/server/lib/board-relations.js +133 -0
- package/server/lib/browse-proxy.js +180 -0
- package/server/lib/canvas-id.js +36 -0
- package/server/lib/cover.js +227 -0
- package/server/lib/danbooru-tags.js +291 -0
- package/server/lib/doc-extract.js +156 -0
- package/server/lib/docx/build-from-source.js +260 -0
- package/server/lib/docx/build.js +490 -0
- package/server/lib/docx/dump-styles.js +287 -0
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -0
- package/server/lib/docx/merge-runs.js +129 -0
- package/server/lib/docx/numbering.js +218 -0
- package/server/lib/docx/order.js +152 -0
- package/server/lib/docx/rawzip.js +172 -0
- package/server/lib/docx/render.js +92 -0
- package/server/lib/docx/text-lint.js +225 -0
- package/server/lib/docx/tokens.js +335 -0
- package/server/lib/docx/units.js +29 -0
- package/server/lib/docx/xml.js +291 -0
- package/server/lib/docx-pages.js +187 -0
- package/server/lib/export-collect.js +289 -0
- package/server/lib/export-package.js +210 -0
- package/server/lib/html-srcset.js +145 -0
- package/server/lib/image-variant.js +460 -0
- package/server/lib/ingress/forward-openai-chat.js +305 -0
- package/server/lib/ingress/openai-chat.js +491 -0
- package/server/lib/ingress/session-notice.js +66 -0
- package/server/lib/ingress/session-routes.js +68 -0
- package/server/lib/ingress/slot-probe.js +130 -0
- package/server/lib/ingress/upstream-billing.js +52 -0
- package/server/lib/ingress/upstream-fail-streak.js +72 -0
- package/server/lib/ingress/upstream-truncation.js +48 -0
- package/server/lib/issues-store.js +182 -0
- package/server/lib/kinds/deck.js +72 -0
- package/server/lib/kinds/docx.js +283 -0
- package/server/lib/kinds/file-kinds.js +113 -0
- package/server/lib/kinds/index.js +202 -0
- package/server/lib/kinds/site.js +161 -0
- package/server/lib/model-ingress.js +543 -0
- package/server/lib/moderation.js +255 -0
- package/server/lib/notice-store.js +103 -0
- package/server/lib/plugin-install.js +104 -0
- package/server/lib/plugin-validator.js +721 -0
- package/server/lib/publish-store.js +115 -0
- package/server/lib/quick-summary.js +52 -0
- package/server/lib/quota.js +270 -0
- package/server/lib/rate-window.js +29 -0
- package/server/lib/reference-assets.js +92 -0
- package/server/lib/region-shot.js +135 -0
- package/server/lib/safe-path.js +73 -0
- package/server/lib/sdk-session.js +33 -0
- package/server/lib/showcase-store.js +92 -0
- package/server/lib/site-publish.js +465 -0
- package/server/lib/ssrf-guard.js +432 -0
- package/server/lib/task-scan.js +158 -0
- package/server/lib/tavern-json.js +154 -0
- package/server/lib/video-variant.js +237 -0
- package/server/lib/workspace-path.js +33 -0
- package/server/ops/fix-sdk-musl.mjs +40 -0
- package/server/ops/install-macos-fonts.sh +114 -0
- package/server/ops/macos-fonts.conf +336 -0
- package/server/ops/sandbox-shim/bwrap +57 -0
- package/server/projects/assets-summary.js +119 -0
- package/server/projects/auto-name.js +53 -0
- package/server/projects/board-store.js +640 -0
- package/server/projects/move-entry.js +103 -0
- package/server/projects/store.js +270 -0
- package/server/projects/ui-config.js +54 -0
- package/server/projects/workspace-templates.js +53 -0
- package/server/projects/workspace.js +1025 -0
- package/server/runtime/capabilities.js +153 -0
- package/server/runtime/local-config.js +177 -0
- package/server/runtime/local-env.js +88 -0
- package/server/runtime/platform.js +342 -0
- package/server/runtime/profile.js +72 -0
- package/server/services/rembg-launcher.js +313 -0
- package/server/services/rembg-service.py +334 -0
- package/server/shared/README.md +25 -0
- package/server/shared/deck.js +54 -0
- package/server/shared/time.js +49 -0
- package/server/style-pipeline/README.md +53 -0
- package/server/ws/broker.js +36 -0
- package/server/ws/browse-channel.js +177 -0
- package/server/ws/index.js +386 -0
- package/web/README.md +89 -0
- package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +11 -0
- package/web/dist/assets/DeckWindow-4G0xX7P3.js +16 -0
- package/web/dist/assets/DocxWindow-BXQsoPkh.js +16 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SiteWindow-D6eS-2gM.js +18 -0
- package/web/dist/assets/again-D8tfgKKO.webp +0 -0
- package/web/dist/assets/caveat-nd-CP6HlsNg.woff2 +0 -0
- package/web/dist/assets/clock-xJhvfYpS.webp +0 -0
- package/web/dist/assets/film-cut-BBKMwH7q.webp +0 -0
- package/web/dist/assets/film-still-BZ_DL9Tt.webp +0 -0
- package/web/dist/assets/index-C0KUf6Wd.css +1 -0
- package/web/dist/assets/index-Cbl3ATvp.js +2125 -0
- package/web/dist/assets/ink-desk-BRbxeDsR.webp +0 -0
- package/web/dist/assets/ink-night-Bz4BT3XH.webp +0 -0
- package/web/dist/assets/ink-portrait-C0DM4V8Q.webp +0 -0
- package/web/dist/assets/longcang-regular-C0uvTuKa.woff2 +0 -0
- package/web/dist/assets/lxgw-nd-bold-DbRfiTgo.woff2 +0 -0
- package/web/dist/assets/lxgw-nd-regular-CpMw9Ehg.woff2 +0 -0
- package/web/dist/assets/pending-edit-apply-DTjo2geW.js +31 -0
- package/web/dist/assets/question-8H4HSqxh.webp +0 -0
- package/web/dist/assets/reject-BaPJqElj.webp +0 -0
- package/web/dist/assets/rotate-cw-LMT3x1Zj.js +11 -0
- package/web/dist/assets/rp-portrait-CbrGpew2.webp +0 -0
- package/web/dist/assets/rp-street-C4jVKjxx.webp +0 -0
- package/web/dist/assets/square-dashed-mouse-pointer-CBoAkfhi.js +11 -0
- package/web/dist/assets/tangle-DIxHVNuT.webp +0 -0
- package/web/dist/assets/thisone-BylZKglV.webp +0 -0
- package/web/dist/assets/thumb-DO1wfK1I.webp +0 -0
- package/web/dist/index.html +18 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/engine/browse/page-digest.js — 「这一页上有什么」的唯一一份采集器(2026-08-18)
|
|
3
|
+
*
|
|
4
|
+
* 两个消费者:`browser_read`(全量:正文 + 标题层级 + 链接清单)和
|
|
5
|
+
* `browser_navigate`(**打开就顺手带回一份摘要**)。
|
|
6
|
+
*
|
|
7
|
+
* ## 为什么 navigate 要带摘要
|
|
8
|
+
*
|
|
9
|
+
* 量过一轮(2026-08-18,这台机器):冷启动 738ms、导航 1.0~4.5s、读页
|
|
10
|
+
* 29~183ms、视口截图 0.2~4.2s。**浏览器本体不慢**。慢的是回合数 ——
|
|
11
|
+
* `navigate` → `read` → `screenshot` 是三次模型往返,每一次的模型延迟都比
|
|
12
|
+
* 上面任何一个数字大。而 navigate 之后紧跟一次 read 几乎是必然的(不看看
|
|
13
|
+
* 页面上有什么,下一步无从决定)。
|
|
14
|
+
*
|
|
15
|
+
* 所以把 read 的采集器抽出来给 navigate 复用:一次导航直接回答"打开了,
|
|
16
|
+
* 上面有这些东西,站内可以往这几个地方去"。**省掉的是一整个回合**,
|
|
17
|
+
* 这是这条通道唯一真能砍掉的时间。
|
|
18
|
+
*
|
|
19
|
+
* 抽出来还有第二个收益:`browser_read` 报的东西和 `browser_navigate` 报的
|
|
20
|
+
* 东西**只有一个来源**。抄两份的下场是"同一页,两个工具说的结构数不一样"。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 页面里跑的采集器。⚠️ 整段在浏览器上下文执行,不能引用外面的东西。
|
|
25
|
+
*
|
|
26
|
+
* @param {import('playwright').Page} page
|
|
27
|
+
* @param {{selector?: string|null, links?: boolean}} opts
|
|
28
|
+
*/
|
|
29
|
+
export function collectPage(page, { selector = null, links = true } = {}) {
|
|
30
|
+
return page.evaluate(({ sel, want }) => {
|
|
31
|
+
const root = sel ? document.querySelector(sel) : (document.querySelector('main') || document.body);
|
|
32
|
+
if (!root) return { missing: true };
|
|
33
|
+
const headings = [...root.querySelectorAll('h1,h2,h3')]
|
|
34
|
+
.map(h => `${'#'.repeat(Number(h.tagName[1]))} ${(h.textContent || '').trim().slice(0, 90)}`)
|
|
35
|
+
.filter(t => t.length > 2).slice(0, 40);
|
|
36
|
+
// ⛔ 链接**不能**跟正文共用 root。没传 selector 时正文 root 是 `main`
|
|
37
|
+
// (对:导航文字是噪音),但链接跟着 main 一收,站名、整条导航、页脚法务
|
|
38
|
+
// 链接就全没了 —— 实测 6 个链接只报出 1 个。而链接清单正是"下一步去哪"
|
|
39
|
+
// 的唯一依据,browser_click 的全部意义也是跟这些链接。
|
|
40
|
+
// 所以:正文按 root 收,链接按**整页**收,再标出它在哪个区。
|
|
41
|
+
const linkRoot = sel ? root : document;
|
|
42
|
+
const regionOf = (el) => {
|
|
43
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
44
|
+
const tag = n.tagName ? n.tagName.toLowerCase() : '';
|
|
45
|
+
if (tag === 'nav') return 'nav';
|
|
46
|
+
if (tag === 'header') return 'header';
|
|
47
|
+
if (tag === 'footer') return 'footer';
|
|
48
|
+
if (tag === 'aside') return 'aside';
|
|
49
|
+
const role = n.getAttribute && n.getAttribute('role');
|
|
50
|
+
if (role === 'navigation') return 'nav';
|
|
51
|
+
if (role === 'contentinfo') return 'footer';
|
|
52
|
+
}
|
|
53
|
+
return 'content';
|
|
54
|
+
};
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const RANK = { content: 0, header: 1, nav: 1, aside: 2, footer: 3 };
|
|
57
|
+
// ⚠️ **排序必须在截断之前**,而且得在页面里做:按文档顺序取前 80 条,
|
|
58
|
+
// 侧边导航就把配额吃干了(MDN 实测 79 条站内链接里前 30 条有 29 条是 nav,
|
|
59
|
+
// 正文那几条一条都没进来)。要跟的多半是正文里那几条。
|
|
60
|
+
const all = want ? [...linkRoot.querySelectorAll('a[href]')].map((a) => {
|
|
61
|
+
const href = a.href;
|
|
62
|
+
if (!href || !/^https?:/.test(href) || seen.has(href)) return null;
|
|
63
|
+
seen.add(href);
|
|
64
|
+
const label = (a.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
65
|
+
return { href, label, internal: a.hostname === location.hostname, region: regionOf(a) };
|
|
66
|
+
}).filter(Boolean) : [];
|
|
67
|
+
const linkList = all
|
|
68
|
+
.map((l, i) => ({ l, i }))
|
|
69
|
+
.sort((x, y) => (RANK[x.l.region] ?? 0) - (RANK[y.l.region] ?? 0) || x.i - y.i)
|
|
70
|
+
.map(x => x.l)
|
|
71
|
+
.slice(0, 80);
|
|
72
|
+
return {
|
|
73
|
+
text: (root.innerText || '').replace(/\n{3,}/g, '\n\n').trim(),
|
|
74
|
+
headings, linkList,
|
|
75
|
+
textScope: sel || (document.querySelector('main') ? 'main' : 'body'),
|
|
76
|
+
counts: { // ⚠️ 这几个数是**整页**的,别跟着正文 root 缩(原来标着"页面结构"其实是 main 里的)
|
|
77
|
+
sections: document.querySelectorAll('section, article').length,
|
|
78
|
+
images: document.querySelectorAll('img').length,
|
|
79
|
+
forms: document.querySelectorAll('form').length,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}, { sel: selector, want: links });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 区标只在非 content 时打 —— 正文链接是默认情况,标了全是噪音 */
|
|
86
|
+
const fmtLink = (l) => ` ${l.label || '(无文字)'}${l.region && l.region !== 'content' ? ` [${l.region}]` : ''} → ${l.href}`;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 采集结果 → 给 agent 的文本行。
|
|
90
|
+
*
|
|
91
|
+
* @param {object} data collectPage 的返回
|
|
92
|
+
* @param {{compact?: boolean, cap?: number}} opts
|
|
93
|
+
* compact = navigate 用的摘要档:短正文 + 少量标题 + 站内链接,够决定下一步就行
|
|
94
|
+
* @returns {string[]} 行(调用方自己在前面拼"现在在哪")
|
|
95
|
+
*/
|
|
96
|
+
export function formatPage(data, { compact = false, cap } = {}) {
|
|
97
|
+
const textCap = cap ?? (compact ? 700 : 4000);
|
|
98
|
+
const maxHeadings = compact ? 12 : 40;
|
|
99
|
+
const maxInner = compact ? 14 : 30;
|
|
100
|
+
const body = data.text.length > textCap
|
|
101
|
+
? `${data.text.slice(0, textCap)}\n… (还有 ${data.text.length - textCap} 个字符${compact ? ',要全文用 browser_read' : ',要全文就加大 maxChars 或用 selector 缩范围'})`
|
|
102
|
+
: data.text;
|
|
103
|
+
// linkList 已经在页面里按区排过序(正文优先),这里只分站内/站外
|
|
104
|
+
const inner = data.linkList.filter(l => l.internal);
|
|
105
|
+
const outer = data.linkList.filter(l => !l.internal);
|
|
106
|
+
const headings = data.headings.slice(0, maxHeadings);
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
`结构(整页):${data.counts.sections} 个 section/article · ${data.counts.images} 张图 · ${data.counts.forms} 个表单`,
|
|
110
|
+
compact ? null
|
|
111
|
+
: `正文读的是 <${data.textScope}>${data.textScope === 'main' ? '(导航/页脚的文字不在里面,但它们的链接在下面)' : ''}`,
|
|
112
|
+
headings.length ? `\n标题层级:\n${headings.join('\n')}` : null,
|
|
113
|
+
`\n正文:\n${body || '(读不到文字 —— 可能整页是图或靠 JS 渲染,试 browser_navigate 带 waitUntil:"networkidle",或直接截图看)'}`,
|
|
114
|
+
inner.length
|
|
115
|
+
? `\n站内链接(${inner.length}${inner.length > maxInner ? `,列前 ${maxInner}` : ''})—— 往里翻就跟这些:\n${inner.slice(0, maxInner).map(fmtLink).join('\n')}`
|
|
116
|
+
: null,
|
|
117
|
+
outer.length && !compact ? `\n站外链接(${outer.length},只列前 8):\n${outer.slice(0, 8).map(fmtLink).join('\n')}` : null,
|
|
118
|
+
].filter(Boolean);
|
|
119
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/engine/browse/refs.js — 元素引用(ref_N)登记 + 词法找元(2026-08-21)
|
|
3
|
+
*
|
|
4
|
+
* 给 `browser_find` / `browser_computer` 用。形状照 Anthropic 的 browser use
|
|
5
|
+
* toolset(browser_toolset_20260801):`find` 返回带 `[ref_N]` 的元素清单,后面的
|
|
6
|
+
* 点击/悬停/滚到 可以拿 ref 当靶子,不必再从截图上读坐标。
|
|
7
|
+
*
|
|
8
|
+
* ## ref 的生命周期(照规格,不自作聪明)
|
|
9
|
+
*
|
|
10
|
+
* - ref 挂在**页面的 window** 上(`window.__nd_refs`):导航 = 新 document =
|
|
11
|
+
* 自动清空。这正是规格要的"ref scoped to the tab, valid until it navigates"。
|
|
12
|
+
* - 同一页内编号**只增不回卷**:agent 手里的 ref_3 在它重新 find 之后还是那个元素
|
|
13
|
+
* (规格原话:don't renumber references you've already handed out)。
|
|
14
|
+
* - 元素被页面重渲染拆掉 = `isConnected` 为假 = stale。按规格回一条可执行的错误
|
|
15
|
+
* 文本,agent 重新 find 一次就好。
|
|
16
|
+
*
|
|
17
|
+
* ## find 是**词法**匹配,不是语义
|
|
18
|
+
*
|
|
19
|
+
* Claude in Chrome 的 find 背后有个小模型;我们这台 1 vCPU 的机器不为每次找元
|
|
20
|
+
* 多付一次模型调用。这里用的是:query 分词(英文按词、中文按字+双字)对元素的
|
|
21
|
+
* 可见文字 / aria-label / placeholder / alt / 角色 / href 打分。工具描述里**明说**
|
|
22
|
+
* 这一点 —— agent 用元素上真出现的词去找,比用它脑补的意图词命中得多。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 在页面里跑:收集候选元素、登记 ref、按 query 打分。
|
|
27
|
+
* ⚠️ 整段在浏览器上下文执行,不能引用外面的东西。
|
|
28
|
+
*
|
|
29
|
+
* @param {import('playwright').Page} page
|
|
30
|
+
* @param {{query: string, limit?: number}} opts
|
|
31
|
+
* @returns {Promise<{matches: object[], candidates: number, url: string}>}
|
|
32
|
+
*/
|
|
33
|
+
export function findInPage(page, { query, limit = 20 }) {
|
|
34
|
+
return page.evaluate(({ q, limit }) => {
|
|
35
|
+
const W = window;
|
|
36
|
+
if (!W.__nd_refs || W.__nd_refs.url !== location.href) {
|
|
37
|
+
W.__nd_refs = { n: 0, els: new Map(), byEl: new WeakMap(), url: location.href };
|
|
38
|
+
}
|
|
39
|
+
const reg = W.__nd_refs;
|
|
40
|
+
const refOf = (el) => {
|
|
41
|
+
const had = reg.byEl.get(el);
|
|
42
|
+
if (had) return had;
|
|
43
|
+
const id = `ref_${++reg.n}`;
|
|
44
|
+
reg.els.set(id, el);
|
|
45
|
+
reg.byEl.set(el, id);
|
|
46
|
+
return id;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const SEL = 'a[href], button, input, select, textarea, summary, label, [role], [onclick], '
|
|
50
|
+
+ '[contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex="-1"]), '
|
|
51
|
+
+ 'h1, h2, h3, img[alt], [aria-label]';
|
|
52
|
+
const vw = W.innerWidth;
|
|
53
|
+
const vh = W.innerHeight;
|
|
54
|
+
const visible = (el) => {
|
|
55
|
+
if (el.tagName === 'INPUT' && el.type === 'hidden') return false;
|
|
56
|
+
if (typeof el.checkVisibility === 'function'
|
|
57
|
+
&& !el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false;
|
|
58
|
+
const r = el.getBoundingClientRect();
|
|
59
|
+
return r.width > 0 && r.height > 0;
|
|
60
|
+
};
|
|
61
|
+
const roleOf = (el) => {
|
|
62
|
+
const r = el.getAttribute('role');
|
|
63
|
+
if (r) return r;
|
|
64
|
+
const t = el.tagName.toLowerCase();
|
|
65
|
+
if (t === 'a') return 'link';
|
|
66
|
+
if (t === 'button' || t === 'summary') return 'button';
|
|
67
|
+
if (t === 'input') {
|
|
68
|
+
const ty = (el.type || 'text').toLowerCase();
|
|
69
|
+
if (['button', 'submit', 'reset', 'image'].includes(ty)) return 'button';
|
|
70
|
+
if (ty === 'checkbox') return 'checkbox';
|
|
71
|
+
if (ty === 'radio') return 'radio';
|
|
72
|
+
if (ty === 'range') return 'slider';
|
|
73
|
+
if (ty === 'file') return 'file';
|
|
74
|
+
return 'textbox';
|
|
75
|
+
}
|
|
76
|
+
if (t === 'textarea') return 'textbox';
|
|
77
|
+
if (t === 'select') return 'combobox';
|
|
78
|
+
if (/^h[1-6]$/.test(t)) return 'heading';
|
|
79
|
+
if (t === 'img') return 'image';
|
|
80
|
+
if (t === 'label') return 'label';
|
|
81
|
+
if (el.isContentEditable) return 'textbox';
|
|
82
|
+
return 'generic';
|
|
83
|
+
};
|
|
84
|
+
const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
85
|
+
const nameOf = (el) => {
|
|
86
|
+
const aria = clean(el.getAttribute('aria-label'));
|
|
87
|
+
if (aria) return aria;
|
|
88
|
+
const by = el.getAttribute('aria-labelledby');
|
|
89
|
+
if (by) {
|
|
90
|
+
const t = clean(by.split(/\s+/).map(id => document.getElementById(id)?.textContent || '').join(' '));
|
|
91
|
+
if (t) return t;
|
|
92
|
+
}
|
|
93
|
+
const t = el.tagName.toLowerCase();
|
|
94
|
+
if (t === 'input' || t === 'textarea' || t === 'select') {
|
|
95
|
+
if (el.labels && el.labels.length) { const l = clean(el.labels[0].textContent); if (l) return l; }
|
|
96
|
+
const ph = clean(el.getAttribute('placeholder'));
|
|
97
|
+
if (ph) return ph;
|
|
98
|
+
if (t === 'input' && ['button', 'submit', 'reset'].includes((el.type || '').toLowerCase())) return clean(el.value);
|
|
99
|
+
}
|
|
100
|
+
if (t === 'img') return clean(el.getAttribute('alt'));
|
|
101
|
+
return clean(el.innerText || el.textContent).slice(0, 80) || clean(el.getAttribute('title'));
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// ── query 分词:英文按词;中文按单字 + 双字("搜索框" 能命中 "搜索")──
|
|
105
|
+
const qlow = clean(q).toLowerCase();
|
|
106
|
+
const toks = new Set();
|
|
107
|
+
for (const m of qlow.match(/[a-z0-9_-]+/g) || []) toks.add(m);
|
|
108
|
+
for (const run of qlow.match(/[㐀-鿿]+/g) || []) {
|
|
109
|
+
for (let i = 0; i < run.length; i += 1) {
|
|
110
|
+
toks.add(run[i]);
|
|
111
|
+
if (i + 1 < run.length) toks.add(run.slice(i, i + 2));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const ROLE_WORDS = {
|
|
115
|
+
button: ['button', 'btn', '按钮', '按键'],
|
|
116
|
+
link: ['link', 'anchor', '链接'],
|
|
117
|
+
textbox: ['input', 'field', 'textbox', 'search', 'box', 'textarea', 'editor', '输入框', '搜索框', '文本框', '输入', '搜索'],
|
|
118
|
+
checkbox: ['checkbox', 'check', '复选框', '勾选'],
|
|
119
|
+
radio: ['radio', '单选'],
|
|
120
|
+
combobox: ['select', 'dropdown', 'combobox', '下拉', '选择'],
|
|
121
|
+
heading: ['heading', 'title', 'header', '标题'],
|
|
122
|
+
image: ['image', 'img', 'picture', 'photo', 'logo', 'icon', '图', '图片', '图标'],
|
|
123
|
+
tab: ['tab', '标签页', '页签'],
|
|
124
|
+
menuitem: ['menu', 'menuitem', '菜单'],
|
|
125
|
+
slider: ['slider', 'range', '滑块'],
|
|
126
|
+
file: ['file', 'upload', '上传', '文件'],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const els = [...document.querySelectorAll(SEL)].filter(visible);
|
|
130
|
+
const scored = [];
|
|
131
|
+
els.forEach((el, order) => {
|
|
132
|
+
const name = nameOf(el);
|
|
133
|
+
const role = roleOf(el);
|
|
134
|
+
const href = el.tagName === 'A' ? (el.getAttribute('href') || '') : '';
|
|
135
|
+
const hint = [el.id, el.className && String(el.className), el.getAttribute('placeholder'),
|
|
136
|
+
el.getAttribute('title'), el.getAttribute('name'), href].filter(Boolean).join(' ').toLowerCase();
|
|
137
|
+
const nlow = name.toLowerCase();
|
|
138
|
+
const rwords = ROLE_WORDS[role] || [role];
|
|
139
|
+
let score = 0;
|
|
140
|
+
if (qlow && nlow === qlow) score += 6;
|
|
141
|
+
else if (qlow && nlow.includes(qlow)) score += 4;
|
|
142
|
+
for (const tk of toks) {
|
|
143
|
+
if (tk.length >= 2 && nlow.includes(tk)) score += 3;
|
|
144
|
+
else if (tk.length === 1 && /[㐀-鿿]/.test(tk) && nlow.includes(tk)) score += 1;
|
|
145
|
+
if (rwords.includes(tk)) score += 2;
|
|
146
|
+
if (hint.includes(tk)) score += 1;
|
|
147
|
+
}
|
|
148
|
+
if (score <= 0) return;
|
|
149
|
+
const r = el.getBoundingClientRect();
|
|
150
|
+
const inView = r.bottom > 0 && r.right > 0 && r.top < vh && r.left < vw;
|
|
151
|
+
if (inView) score += 0.5;
|
|
152
|
+
scored.push({
|
|
153
|
+
el, order, score, role, name, href,
|
|
154
|
+
x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2),
|
|
155
|
+
w: Math.round(r.width), h: Math.round(r.height), inView,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
scored.sort((a, b) => b.score - a.score || a.order - b.order);
|
|
159
|
+
const matches = scored.slice(0, limit).map(({ el, order, ...rest }) => ({ ref: refOf(el), ...rest }));
|
|
160
|
+
return { matches, candidates: els.length, url: location.href };
|
|
161
|
+
}, { q: query, limit });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 把一个 ref 解析成 ElementHandle;stale/未知 → null。 */
|
|
165
|
+
export async function handleForRef(page, ref) {
|
|
166
|
+
const h = await page.evaluateHandle((id) => {
|
|
167
|
+
const r = window.__nd_refs;
|
|
168
|
+
const el = r && r.els.get(id);
|
|
169
|
+
return el && el.isConnected ? el : null;
|
|
170
|
+
}, ref);
|
|
171
|
+
const el = h.asElement();
|
|
172
|
+
if (!el) { await h.dispose().catch(() => {}); return null; }
|
|
173
|
+
return el;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 规格里那句可执行的 stale 错误文本(agent 读了知道下一步是什么) */
|
|
177
|
+
export const staleRefText = (ref) =>
|
|
178
|
+
`Error: ${ref} is stale or not found on the current page. Call browser_find again to get fresh references.`;
|
|
179
|
+
|
|
180
|
+
/** find 结果 → 给 agent 的行 */
|
|
181
|
+
export function formatMatches(r, query) {
|
|
182
|
+
if (!r.matches.length) {
|
|
183
|
+
return [
|
|
184
|
+
`「${query}」没找到匹配的元素(这一页有 ${r.candidates} 个可交互/可读元素)。`,
|
|
185
|
+
'匹配是词法的:换成元素上**真出现**的词(按钮文字、占位符、链接文案),或者写角色词(button/link/input/图片)。',
|
|
186
|
+
'还是没有就 browser_read 看看页面上到底有什么字,或 browser_computer screenshot 直接看。',
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
const lines = [`找到 ${r.matches.length} 个(候选 ${r.candidates})—— ref 可直接给 browser_computer,坐标是视口像素:`];
|
|
190
|
+
for (const m of r.matches) {
|
|
191
|
+
const where = m.inView ? `@(${m.x},${m.y})` : `@(${m.x},${m.y}) [视口外${m.y < 0 ? '↑' : '↓'},点之前先 scroll_to]`;
|
|
192
|
+
const href = m.href && m.role === 'link' ? ` → ${m.href.slice(0, 70)}` : '';
|
|
193
|
+
lines.push(` ${m.ref} ${m.role} 「${m.name || '(无文字)'}」 ${m.w}×${m.h} ${where}${href}`);
|
|
194
|
+
}
|
|
195
|
+
return lines;
|
|
196
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/engine/browse/registry.js — 常驻浏览器登记处(2026-08-18)
|
|
3
|
+
*
|
|
4
|
+
* agent 用浏览器看设计参考这条线的本体。**进程级单例,按 projectId 键。**
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么是常驻,为什么按项目键
|
|
7
|
+
*
|
|
8
|
+
* 在这之前所有浏览器工具都是 launch→用→close 即弃(`screenshot-url.js` 那套),
|
|
9
|
+
* 所以**没有会话**:点不了链接、翻不了子页、登录态留不住、Cookie 同意弹窗每次重新
|
|
10
|
+
* 弹,想看第三层页面只能猜 URL。用户要的正是这条:让 agent 能真的**逛**。
|
|
11
|
+
*
|
|
12
|
+
* 键按 **projectId**(用户拍板):项目 owner 唯一,所以这等于 per-user per-project;
|
|
13
|
+
* 登录态天然跨会话复用 —— 用户的原话是「**此后**从浏览器中获得的可复用内容」,
|
|
14
|
+
* 那个"此后"包含下一次开新对话。
|
|
15
|
+
*
|
|
16
|
+
* ## 这台机器只有 1 vCPU,所以每条约束都是硬的
|
|
17
|
+
*
|
|
18
|
+
* - 常驻浏览器 **≤ 2**:空闲实测 185 MiB PSS / 0.1% CPU,内存不是矛盾,但超了就
|
|
19
|
+
* 按 LRU 关最久没用的**空闲**那个;一个都腾不出来就**拒绝并告诉 agent**,
|
|
20
|
+
* 不静默排队(静默降级是这个仓库的老账)
|
|
21
|
+
* - 同项目的调用走 **mutex 串行**:同一个项目可能同时开着两个会话,两个 agent 抢
|
|
22
|
+
* 一个 page 会互相把对方导航走
|
|
23
|
+
* - 空闲 5 分钟回收(保留 profile)。timer `.unref()` —— 别让它拖着进程不退出
|
|
24
|
+
*
|
|
25
|
+
* ## 跟感知通道刻意分开的两条通道
|
|
26
|
+
*
|
|
27
|
+
* `perception-page.js` 的 `launchPerceptionBrowser` 是**感知通道**:URL 由我们构造
|
|
28
|
+
* (artifact-file loopback)、允许 loopback、用完即弃。这里是**浏览通道**:URL 由
|
|
29
|
+
* agent 自由指定、**loopback 一律禁**(SSRF 闸)、常驻带持久 profile。
|
|
30
|
+
* 两个函数并排放着而不是合成一个带 flag 的 —— 「同一件东西有多个实例」的反面是
|
|
31
|
+
* 「两件不同的东西硬塞进一个函数」,安全语义会被 flag 搅浑。共享的只有
|
|
32
|
+
* `FIDELITY_LAUNCH_ARGS` 那一份常量。
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { mutex } from 'async-mutex-lite';
|
|
37
|
+
import { getProjectWorkspace } from '../../projects/workspace.js';
|
|
38
|
+
import { FIDELITY_LAUNCH_ARGS } from '../mcp/tools/helpers/perception-page.js';
|
|
39
|
+
import { attachSsrfGuard } from '../../lib/ssrf-guard.js';
|
|
40
|
+
import { startBrowseProxy } from '../../lib/browse-proxy.js';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 常驻上限(内存)。1 vCPU 上活跃画面流另有 ≤1 的上限,见 screencast 那一层。
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ 写法要 NaN-safe:`Number('yes')` 是 NaN,而 `live.size >= NaN` **恒为 false**
|
|
46
|
+
* → 上限静默消失,chromium 无限起。这台机器 1 核 8G **无 swap**,尖峰直接进
|
|
47
|
+
* 内核 OOM killer。(隔壁 IDLE_MS 用的 `|| 默认` 写法本来就是 NaN-safe 的。)
|
|
48
|
+
*/
|
|
49
|
+
const MAX_RESIDENT = Number(process.env.ND_BROWSE_MAX) > 0
|
|
50
|
+
? Math.floor(Number(process.env.ND_BROWSE_MAX)) : 2;
|
|
51
|
+
/** 空闲多久回收。仿 h3box 的 ControlPersist=600 与 WS grace 的思路。 */
|
|
52
|
+
const IDLE_MS = Number(process.env.ND_BROWSE_IDLE_MS || 0) || 5 * 60 * 1000;
|
|
53
|
+
const NAV_TIMEOUT_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 视口:桌面宽(看设计参考要的是版面不是移动端),**且坐标 1:1**。
|
|
57
|
+
* 08-21 从 1440×900 改 1366×768:改的时候 shot-pipeline 阈值还是 1568/1.15MP,1440×900=1.30MP
|
|
58
|
+
* 会被缩 0.94 倍 → browser_computer 从图上读的坐标和页面像素差 6%。同日阈值抬到 2000px/3.75MP
|
|
59
|
+
* 后 1440×900 其实也不缩了,但 1366×768 留着:便宜(≈1372 token/张)、是文档推荐档、坐标 1:1。
|
|
60
|
+
* 也是 Anthropic computer-use 文档给 web 应用推荐的档位。改它要连 browse-computer.test.js
|
|
61
|
+
* 的"视口在阈值内"断言一起过。
|
|
62
|
+
*/
|
|
63
|
+
const VIEWPORT = { width: 1366, height: 768 };
|
|
64
|
+
|
|
65
|
+
/** projectId → entry */
|
|
66
|
+
const live = new Map();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* ⭐ 把 UA 里的 HeadlessChrome 换成 Chrome。
|
|
70
|
+
*
|
|
71
|
+
* 依据是一篇受控实验(arXiv:2606.14525,Tranco Top 10K × 4 万次抓取):在「只有
|
|
72
|
+
* headless 被封」的 784 个站上,**仅仅**把 UA 与 Client Hints 里的 `HeadlessChrome`
|
|
73
|
+
* 换成 `Chrome`,590 个(**75%**)当场转 HTTP 2xx。也就是说 headed 相对 headless
|
|
74
|
+
* 那 8 个百分点的优势,四分之三来自这个头部信号 —— 不是「headed 本身更像人」。
|
|
75
|
+
* 所以我们不装 Xvfb(1 vCPU 付不起一颗核,而且 headed vs **新** headless 全世界
|
|
76
|
+
* 没人测过),改这个字符串就吃满大头。
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ 这不是为了绕强反爬(数据中心 IP 是改不掉的底噪,最凶的墙怎么配都拦 ——
|
|
79
|
+
* 那是「人接手」存在的理由)。这是为了不在最脏的一档上白挨拦。
|
|
80
|
+
*/
|
|
81
|
+
function chromeUa(raw) {
|
|
82
|
+
return String(raw).replace(/HeadlessChrome\//g, 'Chrome/');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function launchBrowseBrowser(projectId) {
|
|
86
|
+
const { chromium } = await import('playwright');
|
|
87
|
+
// profile 必须落在 `<pid>/` 下、`shared/` **之外**:shared 是 agent 的 cwd 也是
|
|
88
|
+
// artifact-file 的服务根 —— 放进去等于 (a) cookie jar 能被当文件服出去
|
|
89
|
+
// (b) agent 能 Read 到自己的 cookie (c) 进 per-project git。
|
|
90
|
+
// 放兄弟位就都避开了,而且删项目时 removeProjectWorkspace 删的是整个 `<pid>/`
|
|
91
|
+
// (核过),profile 跟着走,不用额外写清理。
|
|
92
|
+
const userDataDir = path.join(getProjectWorkspace(projectId), '.browser', 'default');
|
|
93
|
+
|
|
94
|
+
// ⭐ 出网闸在**代理层**(2026-08-18 第二遍):`--proxy-server` 之后 chromium 的
|
|
95
|
+
// 每一次连接都从这儿走 —— 包括 CDP 的 Fetch 阶段看不见的那些(WebSocket 握手、
|
|
96
|
+
// `<link rel=prefetch>`、sendBeacon、还没装上闸的弹窗)。理由与实测见
|
|
97
|
+
// lib/browse-proxy.js 文件头。CDP 那道闸保留当纵深,不再是唯一一道。
|
|
98
|
+
// ⚠️ `bypass: ''` 是必须的:默认会放过 loopback,那正好是我们最要拦的。
|
|
99
|
+
const { port: proxyPort } = await startBrowseProxy();
|
|
100
|
+
|
|
101
|
+
const context = await chromium.launchPersistentContext(userDataDir, {
|
|
102
|
+
channel: 'chromium', // ⭐ 不是默认的 headless_shell,见 chromeUa 的注释
|
|
103
|
+
headless: true,
|
|
104
|
+
proxy: { server: `http://127.0.0.1:${proxyPort}`, bypass: '' },
|
|
105
|
+
// ⭐ WebRTC 是代理**看不见**的那条路:它走 UDP,不经 HTTP 代理,能被用来
|
|
106
|
+
// 探内网存活(STUN 打内网地址)。看设计参考完全不需要 WebRTC,所以直接
|
|
107
|
+
// 掐掉非代理 UDP —— 这是 Chromium 官方给的那个开关,不是我们发明的。
|
|
108
|
+
args: [...FIDELITY_LAUNCH_ARGS, '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'],
|
|
109
|
+
viewport: VIEWPORT,
|
|
110
|
+
locale: 'zh-CN', // 面向中文用户;顺带摆脱默认的 en-US@posix 那种怪指纹
|
|
111
|
+
timezoneId: 'Asia/Shanghai',
|
|
112
|
+
acceptDownloads: false, // 下载不是这条线要的,而且是一条额外的写盘面
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// proxied: true —— 第二道(连上后看对端 IP)在代理下是纯冗余,而且会把每个页面
|
|
116
|
+
// 都误杀(代理自己就是 127.0.0.1)。第一道(Fetch 阶段)保留当纵深。
|
|
117
|
+
const guard = await attachSsrfGuard(context, undefined, { proxied: true });
|
|
118
|
+
// 持久 context 自带一个空白页;没有就造一个
|
|
119
|
+
const page = context.pages()[0] || await context.newPage();
|
|
120
|
+
await guard.armPage(page); // ⭐ 必须 await 完才允许导航(竞态是攻出来的)
|
|
121
|
+
|
|
122
|
+
// UA 与 Client Hints 一起改 —— 只改 UA 字符串的话 sec-ch-ua 还在报 headless
|
|
123
|
+
const cdp = await context.newCDPSession(page);
|
|
124
|
+
const realUa = await page.evaluate(() => navigator.userAgent);
|
|
125
|
+
const ua = chromeUa(realUa);
|
|
126
|
+
const m = realUa.match(/Chrome\/(\d+)/);
|
|
127
|
+
const major = m ? m[1] : '147';
|
|
128
|
+
await cdp.send('Emulation.setUserAgentOverride', {
|
|
129
|
+
userAgent: ua,
|
|
130
|
+
acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
131
|
+
platform: 'Linux x86_64',
|
|
132
|
+
userAgentMetadata: {
|
|
133
|
+
brands: [
|
|
134
|
+
{ brand: 'Chromium', version: major },
|
|
135
|
+
{ brand: 'Google Chrome', version: major },
|
|
136
|
+
{ brand: 'Not?A_Brand', version: '24' },
|
|
137
|
+
],
|
|
138
|
+
fullVersion: `${major}.0.0.0`,
|
|
139
|
+
platform: 'Linux',
|
|
140
|
+
platformVersion: '6.1.0',
|
|
141
|
+
architecture: 'x86',
|
|
142
|
+
model: '',
|
|
143
|
+
mobile: false,
|
|
144
|
+
},
|
|
145
|
+
}).catch(() => { /* 覆盖不上就退回只改 UA 字符串那一档,别因此起不来 */ });
|
|
146
|
+
|
|
147
|
+
return { context, page, guard, ua };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function touch(entry) {
|
|
151
|
+
entry.lastUsed = Date.now();
|
|
152
|
+
clearTimeout(entry.idleTimer);
|
|
153
|
+
entry.idleTimer = setTimeout(() => { closeFor(entry.projectId, 'idle').catch(() => {}); }, IDLE_MS);
|
|
154
|
+
entry.idleTimer.unref?.();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 关掉某个项目的浏览器(**保留 profile** —— 登录态是资产) */
|
|
158
|
+
export async function closeFor(projectId, why = 'explicit') {
|
|
159
|
+
const entry = live.get(projectId);
|
|
160
|
+
if (!entry) return false;
|
|
161
|
+
live.delete(projectId);
|
|
162
|
+
clearTimeout(entry.idleTimer);
|
|
163
|
+
// 先让画面流那一层知道(它要通知正在看的人,并且停掉编码),再关浏览器 ——
|
|
164
|
+
// 反过来的话 CDP 会话已经死了,stopScreencast 只会抛一堆没意义的错
|
|
165
|
+
try { (await import('./screencast.js')).forget(projectId); } catch { /* */ }
|
|
166
|
+
try { await entry.context.close(); } catch { /* 已经死了就算了 */ }
|
|
167
|
+
console.log(`[browse] closed ${projectId} (${why})`);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 腾一个位子出来:按 LRU 关最久没用的**空闲**实例。@returns 腾出来了没 */
|
|
172
|
+
async function evictOne() {
|
|
173
|
+
const idle = [...live.values()].filter(e => !e.busy).sort((a, b) => a.lastUsed - b.lastUsed);
|
|
174
|
+
if (!idle.length) return false;
|
|
175
|
+
await closeFor(idle[0].projectId, 'evicted (LRU)');
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 拿到某个项目的浏览器(懒启动)。**同项目串行**。
|
|
181
|
+
*
|
|
182
|
+
* @param {string} projectId
|
|
183
|
+
* @param {(h: {page: import('playwright').Page, guard: object, ua: string}) => Promise<any>} fn
|
|
184
|
+
* @returns {Promise<any>} fn 的返回值
|
|
185
|
+
* @throws 超上限时抛错,**不静默排队** —— agent 该看到"满了"这件事
|
|
186
|
+
*/
|
|
187
|
+
export async function withBrowser(projectId, fn) {
|
|
188
|
+
return mutex(`browse:${projectId}`, async () => {
|
|
189
|
+
let entry = live.get(projectId);
|
|
190
|
+
// ⚠️ 页可能已经不在了而 entry 还在:出网闸连到坏 IP 时会 `page.close()`
|
|
191
|
+
// (ssrf-guard 的 [connected] 兜底)。任意页面塞一个
|
|
192
|
+
// `<link rel=prefetch href=http://127.0.0.1:4001/>` 就能让常驻页变成一具尸体,
|
|
193
|
+
// 而 5 分钟空闲计时器才会清它 —— 等于给外站开了个远程杀开关。
|
|
194
|
+
// 这里发现就重建(别让 agent 拿着 "Target has been closed" 猜发生了什么)。
|
|
195
|
+
if (entry && entry.page.isClosed()) {
|
|
196
|
+
console.log(`[browse] ${projectId} 的页已被掐(多半是出网闸),重建`);
|
|
197
|
+
await closeFor(projectId, 'page was closed underneath us');
|
|
198
|
+
entry = null;
|
|
199
|
+
}
|
|
200
|
+
if (!entry) {
|
|
201
|
+
if (live.size >= MAX_RESIDENT && !(await evictOne())) {
|
|
202
|
+
throw Object.assign(
|
|
203
|
+
new Error(`浏览器实例已满(${live.size}/${MAX_RESIDENT} 都在用)。`
|
|
204
|
+
+ '等一会儿再试,或者让别的会话先收工 —— 这台机器只有 1 个 CPU 核,常驻上限是硬的。'),
|
|
205
|
+
{ status: 503 },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const t0 = Date.now();
|
|
209
|
+
const h = await launchBrowseBrowser(projectId);
|
|
210
|
+
entry = { projectId, ...h, lastUsed: Date.now(), busy: false, idleTimer: null };
|
|
211
|
+
live.set(projectId, entry);
|
|
212
|
+
console.log(`[browse] launched ${projectId} in ${Date.now() - t0}ms (${live.size}/${MAX_RESIDENT} resident)`);
|
|
213
|
+
}
|
|
214
|
+
entry.busy = true;
|
|
215
|
+
try {
|
|
216
|
+
touch(entry);
|
|
217
|
+
return await fn({ page: entry.page, guard: entry.guard, ua: entry.ua, context: entry.context });
|
|
218
|
+
} finally {
|
|
219
|
+
entry.busy = false;
|
|
220
|
+
touch(entry); // 用完重新起计时,别让长调用被自己的旧计时器掐掉
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ⚠️ **刻意没有 onSessionEnd。** 计划里写了「会话结束时在 session-loop 的 finally
|
|
226
|
+
// 里关掉该项目的浏览器」,落地时否掉了,理由两条:
|
|
227
|
+
// ① `active-runs` 是按 sessionId 键的,**没有「这个项目还有别的活会话吗」这个
|
|
228
|
+
// 查询**。同一个项目同时开两个会话是常见的(用户经常这么用),照着关就是把
|
|
229
|
+
// 另一个会话正在看的页面关掉。
|
|
230
|
+
// ② 5 分钟空闲回收已经覆盖了"人走了"这件事,而且它按**实际使用**判,不按会话
|
|
231
|
+
// 生命周期判 —— 后者跟"浏览器还有没有人要用"其实不是一回事。
|
|
232
|
+
// 与其造一个用不上的导出摆在这儿(这个仓库有过"全仓无人写入的字段假装第一优先级"
|
|
233
|
+
// 那种账),不如写清为什么没有。
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 把某个项目的浏览器**钉住**:期间它不空闲、不会被 LRU 挤掉、不会被空闲计时器关。
|
|
237
|
+
*
|
|
238
|
+
* 为什么需要(审查抓到的):`browser_request_help` 是在 `withBrowser` **外面**等人的
|
|
239
|
+
* (它必须放开 mutex,否则人在窗里点的每一下都会排在 agent 这次调用后面 = 死锁)。
|
|
240
|
+
* 于是等人的那 120 秒里 `busy` 是 false、`lastUsed` 不动 —— `evictOne` 正好挑
|
|
241
|
+
* "最久没用的空闲实例",它就是那个**人正在过验证码的浏览器**。
|
|
242
|
+
*
|
|
243
|
+
* @returns {() => void} 松手(幂等)
|
|
244
|
+
*/
|
|
245
|
+
export function hold(projectId, why = 'held') {
|
|
246
|
+
const entry = live.get(projectId);
|
|
247
|
+
if (!entry) return () => {};
|
|
248
|
+
entry.busy = true;
|
|
249
|
+
entry.holds = (entry.holds || 0) + 1;
|
|
250
|
+
touch(entry);
|
|
251
|
+
let released = false;
|
|
252
|
+
return () => {
|
|
253
|
+
if (released) return;
|
|
254
|
+
released = true;
|
|
255
|
+
const e = live.get(projectId);
|
|
256
|
+
if (!e) return;
|
|
257
|
+
e.holds = Math.max(0, (e.holds || 1) - 1);
|
|
258
|
+
if (!e.holds) e.busy = false;
|
|
259
|
+
touch(e); // 松手时重新起计时,别让它带着旧计时器立刻被回收
|
|
260
|
+
console.log(`[browse] ${projectId} 松手(${why})`);
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 人在窗里动了一下 → 重置空闲计时。
|
|
266
|
+
* 没有这条的话:人安静看五分钟,浏览器在他眼前被关掉(空闲计时只认 agent 的调用)。
|
|
267
|
+
*/
|
|
268
|
+
export function touchProject(projectId) {
|
|
269
|
+
const entry = live.get(projectId);
|
|
270
|
+
if (entry) touch(entry);
|
|
271
|
+
return !!entry;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 刷新页面之后拿回「现在有没有浏览器 / agent 是不是正举着手」。
|
|
276
|
+
* 求助 banner 原来只靠一次性的 WS 事件,刷新即失传 —— agent 在那儿等两分钟没人知道。
|
|
277
|
+
*/
|
|
278
|
+
export function browseState(projectId) {
|
|
279
|
+
const e = live.get(projectId);
|
|
280
|
+
return {
|
|
281
|
+
live: !!e,
|
|
282
|
+
url: e ? e.page.url() : null,
|
|
283
|
+
busy: e ? !!e.busy : false,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 拿到某项目**已经在跑**的浏览器(不懒启动)。
|
|
289
|
+
* 给 WS 画面通道用:用户开窗时如果 agent 还没开始浏览,就该显示"还没开始",
|
|
290
|
+
* 而不是替 agent 起一个浏览器(那会让 1 vCPU 上的常驻名额被看客占掉)。
|
|
291
|
+
*/
|
|
292
|
+
export function peek(projectId) {
|
|
293
|
+
const e = live.get(projectId);
|
|
294
|
+
return e ? { page: e.page, context: e.context, guard: e.guard } : null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 给体检/日志用 */
|
|
298
|
+
export function status() {
|
|
299
|
+
return [...live.values()].map(e => ({
|
|
300
|
+
projectId: e.projectId,
|
|
301
|
+
url: (() => { try { return e.page.url(); } catch { return '(gone)'; } })(),
|
|
302
|
+
idleMs: Date.now() - e.lastUsed,
|
|
303
|
+
busy: e.busy,
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export const _limits = { MAX_RESIDENT, IDLE_MS, NAV_TIMEOUT_MS, VIEWPORT };
|