@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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp/tools/browse-screenshot.js — browser_screenshot:参考站的静帧 + 胶片条(2026-08-21 从 browse.js 搬出)
|
|
3
|
+
*
|
|
4
|
+
* 静帧那半照旧(视口 / 整页 / 裁元素 / 先滚到某处)。新的是**时间维度**:
|
|
5
|
+
* `frames` + `trigger` / `click` / `scrollBy` → 一张按时刻拼好的 contact sheet,
|
|
6
|
+
* 外加元素探针(谁在动、怎么动)。录制器就是感知通道胶片条那份 motion-lab,
|
|
7
|
+
* 不抄第二份;滚动驱动走 motion-scroll 的真滚轮(滚动劫持那族只认真输入)。
|
|
8
|
+
*
|
|
9
|
+
* 参考站看动效的两层里这是第二层"动起来长什么样";第一层"靠什么在动"是
|
|
10
|
+
* browser_capture 的 motion 档(engine/motion/inventory.js)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { withBrowser, _limits } from '../../browse/registry.js';
|
|
17
|
+
import { saveFrame } from '../../browse/state.js';
|
|
18
|
+
import { normalizeShot, visionTokens } from './helpers/shot-pipeline.js';
|
|
19
|
+
import { recordMotion, pickNearestFrames, composeSheet, encodeWebm, motionCaptionLines } from './helpers/motion-lab.js';
|
|
20
|
+
import { wheelScroll, elementMotionReport, elementMotionLines } from './helpers/motion-scroll.js';
|
|
21
|
+
|
|
22
|
+
const asText = (text, isError = false) => ({ content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) });
|
|
23
|
+
|
|
24
|
+
async function where(page) {
|
|
25
|
+
const [title, url] = await Promise.all([page.title().catch(() => ''), Promise.resolve(page.url())]);
|
|
26
|
+
return `${title || '(无标题)'} — ${url}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function scrollViewportTo(page, spec) {
|
|
30
|
+
await page.evaluate(async (s) => {
|
|
31
|
+
const maxY = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
32
|
+
let y = 0;
|
|
33
|
+
if (typeof s === 'number') y = s;
|
|
34
|
+
else if (/^-?[\d.]+%$/.test(s)) y = maxY * (parseFloat(s) / 100);
|
|
35
|
+
else { const el = document.querySelector(s); if (el) y = el.getBoundingClientRect().top + window.scrollY; }
|
|
36
|
+
window.scrollTo({ top: Math.max(0, Math.min(y, maxY)), behavior: 'instant' });
|
|
37
|
+
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
38
|
+
}, spec);
|
|
39
|
+
await page.waitForTimeout(350);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function makeBrowserScreenshotTool({ projectId, workspaceRoot }) {
|
|
43
|
+
const VP = _limits.VIEWPORT;
|
|
44
|
+
return tool(
|
|
45
|
+
'browser_screenshot',
|
|
46
|
+
`Screenshot the current page in the browser session.
|
|
47
|
+
|
|
48
|
+
This is what makes browsing useful for design work: read tells you what a page
|
|
49
|
+
says, this tells you what it looks like — the layout rhythm, how the type is
|
|
50
|
+
set, where the whitespace is, how an opening screen is composed.
|
|
51
|
+
|
|
52
|
+
Default is the viewport (${VP.width}×${VP.height}, cheap; its pixels are the coordinate
|
|
53
|
+
space browser_computer uses, 1:1). fullPage for the whole scroll, or a selector
|
|
54
|
+
for one component you want to look at closely. For a magnified look at a small
|
|
55
|
+
region use browser_computer zoom.
|
|
56
|
+
|
|
57
|
+
FILMSTRIP — the eye for the site's MOTION. A still cannot show how a hero
|
|
58
|
+
reveals, how cards stagger in, how parallax layers slide, whether scrolling is
|
|
59
|
+
hijacked into a smooth-scroll. Pass frames (2-30 ms offsets, e.g. [0,150,300,600,1000]
|
|
60
|
+
for one move, 12-30 spread over a long scroll) and
|
|
61
|
+
ONE of: scrollBy (px of REAL wheel scrolling spread over the recording — use
|
|
62
|
+
this for scroll-driven motion, it is what a visitor does), trigger (JS that
|
|
63
|
+
starts a move), or click (a CSS selector, a real trusted click). You get ONE
|
|
64
|
+
contact sheet of the viewport at those moments, plus an ELEMENT PROBE: which
|
|
65
|
+
elements moved/faded/scaled during the recording, by how much, and when —
|
|
66
|
+
parallax vs entrance vs fixed are told apart. Pair it with
|
|
67
|
+
browser_capture{kinds:['motion']} which tells you WHAT the site uses
|
|
68
|
+
(keyframes, transitions, scroll-timeline, GSAP/ScrollTrigger, reveal classes);
|
|
69
|
+
this tells you how it looks and feels. saveVideo:true also writes a .webm into
|
|
70
|
+
the workspace for the user.`,
|
|
71
|
+
{
|
|
72
|
+
fullPage: z.boolean().optional().describe('Capture the whole scrollable page instead of the viewport. Several times more expensive.'),
|
|
73
|
+
selector: z.string().optional().describe('Capture only the first element matching this CSS selector.'),
|
|
74
|
+
scrollTo: z.union([z.number(), z.string()]).optional()
|
|
75
|
+
.describe("Scroll the viewport here first: pixels, a percentage like '50%', or a CSS selector. Real scroll, so entry animations and sticky headers behave as a visitor sees them."),
|
|
76
|
+
frames: z.array(z.number().min(0).max(15000)).min(2).max(30).optional()
|
|
77
|
+
.describe('FILMSTRIP: capture the viewport at these ms offsets (t=0 = when scrollBy/trigger/click starts) and return one timestamped contact sheet. 2-30 offsets; the sheet has a fixed pixel budget (~2.3MP, ≈3-3.7k tokens), so more cells = smaller cells — 6-10 to read detail, 12-16 for a whole sequence, 20-30 for long scroll choreographies where rhythm matters more than detail (zoom a cell region afterwards). Place them where the motion lives; include 0.'),
|
|
78
|
+
scrollBy: z.number().min(-8000).max(8000).optional()
|
|
79
|
+
.describe('FILMSTRIP: pixels of real wheel scrolling dispatched over the recording window (positive = down). The natural way to record scroll-driven motion on a site you did not write.'),
|
|
80
|
+
trigger: z.string().optional().describe('FILMSTRIP: JS evaluated in page context at t=0 to start a move (await OK).'),
|
|
81
|
+
click: z.string().optional().describe('FILMSTRIP: CSS selector to REAL-click at t=0 (e.g. a menu button, a tab).'),
|
|
82
|
+
elements: z.boolean().optional().describe('FILMSTRIP: also run the element probe (default true). Set false to save a little CPU.'),
|
|
83
|
+
saveVideo: z.boolean().optional().describe('FILMSTRIP: also encode the recording as .webm under assets/references/web/<site>/ for the user (deliver_files to hand it over).'),
|
|
84
|
+
},
|
|
85
|
+
async ({ fullPage, selector, scrollTo, frames, scrollBy, trigger, click, elements, saveVideo }) => {
|
|
86
|
+
try {
|
|
87
|
+
return await withBrowser(projectId, async ({ page }) => {
|
|
88
|
+
if (scrollTo != null) await scrollViewportTo(page, scrollTo);
|
|
89
|
+
|
|
90
|
+
// ── 胶片条 ──
|
|
91
|
+
if (frames && frames.length) {
|
|
92
|
+
const wanted = [...frames].sort((a, b) => a - b);
|
|
93
|
+
const durationMs = Math.max(300, Math.round(Math.max(...wanted)));
|
|
94
|
+
const y0 = await page.evaluate(() => window.scrollY).catch(() => 0);
|
|
95
|
+
const during = scrollBy ? (p) => wheelScroll(p, { px: scrollBy, durationMs: Math.max(200, durationMs - 100) }) : null;
|
|
96
|
+
const rec = await recordMotion(page, {
|
|
97
|
+
durationMs, trigger, click, during, probeElements: elements !== false,
|
|
98
|
+
shotMaxW: VP.width, shotMaxH: VP.height,
|
|
99
|
+
});
|
|
100
|
+
if (!rec.shots.length) {
|
|
101
|
+
return asText(['Filmstrip failed: the screencast captured zero frames — the page never painted during the window.', rec.clickNote, rec.triggerNote, rec.duringNote].filter(Boolean).join('\n'), true);
|
|
102
|
+
}
|
|
103
|
+
const picked = pickNearestFrames(rec.shots, wanted);
|
|
104
|
+
const sheet = await composeSheet(picked);
|
|
105
|
+
const y1 = await page.evaluate(() => window.scrollY).catch(() => y0);
|
|
106
|
+
const probe = elements !== false ? elementMotionReport(rec.elems, { scrolledPx: y1 - y0 }) : null;
|
|
107
|
+
let videoNote = null;
|
|
108
|
+
if (saveVideo && workspaceRoot) {
|
|
109
|
+
try {
|
|
110
|
+
const host = (() => { try { return new URL(page.url()).hostname.replace(/[^a-z0-9.-]/gi, '').replace(/\./g, '-'); } catch { return 'site'; } })();
|
|
111
|
+
const rel = path.posix.join('assets', 'references', 'web', host, `motion-${new Date().toISOString().slice(11, 19).replace(/:/g, '')}.webm`);
|
|
112
|
+
const { bytes } = await encodeWebm(rec.shots, path.join(workspaceRoot, rel));
|
|
113
|
+
videoNote = `video saved: ${rel} (${(bytes / 1024).toFixed(0)}KB, real frame timing) — deliver_files to hand it to the user`;
|
|
114
|
+
} catch (err) { videoNote = `video encode failed: ${err?.message || err}`; }
|
|
115
|
+
}
|
|
116
|
+
const shot = await normalizeShot(sheet.buf);
|
|
117
|
+
const cells = picked.map((p, i) => (p ? `#${i + 1} t=${Math.round(p.want)}ms→${Math.round(p.actual)}ms` : `#${i + 1} (no frame)`)).join(' ');
|
|
118
|
+
const cap = [
|
|
119
|
+
`Filmstrip of ${await where(page)} — ${wanted.length} cells, ${sheet.layout.cols}x${sheet.layout.rows} grid, viewport ${VP.width}x${VP.height} (t=0 = when ${scrollBy ? 'wheel scrolling' : trigger ? 'trigger' : click ? 'click' : 'recording'} started)`,
|
|
120
|
+
cells,
|
|
121
|
+
...(probe ? elementMotionLines(probe) : []),
|
|
122
|
+
...motionCaptionLines(rec),
|
|
123
|
+
...(videoNote ? [videoNote] : []),
|
|
124
|
+
shot.note,
|
|
125
|
+
'Next: browser_capture{kinds:["motion"]} for WHAT the site uses (keyframes/transitions/scroll-timeline/libraries); zoom a cell region with browser_computer zoom if the cells are too small.',
|
|
126
|
+
].filter(Boolean);
|
|
127
|
+
return { content: [{ type: 'text', text: cap.join('\n') }, { type: 'image', data: shot.data, mimeType: shot.mimeType }] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── 静帧 ──
|
|
131
|
+
let buf;
|
|
132
|
+
if (selector) {
|
|
133
|
+
const loc = page.locator(selector).first();
|
|
134
|
+
if (!(await loc.count())) return asText(`选择器没匹配到元素:${selector}`, true);
|
|
135
|
+
buf = await loc.screenshot({ type: 'png' });
|
|
136
|
+
} else {
|
|
137
|
+
buf = await page.screenshot({ type: 'png', fullPage: fullPage === true });
|
|
138
|
+
// 顺手把这张存成桌面卡片的预览 —— **不额外截图**,只是这一张本来就有。
|
|
139
|
+
// 整页图不用:卡片是一块 16:10 的画框,塞一张长图进去看不出东西。
|
|
140
|
+
if (fullPage !== true) await saveFrame(projectId, buf);
|
|
141
|
+
}
|
|
142
|
+
const shot = await normalizeShot(buf);
|
|
143
|
+
return {
|
|
144
|
+
content: [
|
|
145
|
+
{ type: 'text', text: [`${await where(page)}`,
|
|
146
|
+
selector ? `只截了 ${selector}` : `${fullPage ? '整页' : '视口'} ${VP.width}×${VP.height}${fullPage ? '' : ` (≈${visionTokens(VP.width, VP.height)} tokens)`}`,
|
|
147
|
+
shot.note].filter(Boolean).join(' · ') },
|
|
148
|
+
{ type: 'image', data: shot.data, mimeType: shot.mimeType },
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return asText(`browser_screenshot 失败:${err.message}`, true);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp/tools/browse.js — agent 真的会用浏览器(2026-08-18)
|
|
3
|
+
*
|
|
4
|
+
* 四个工具共用 `engine/browse/registry.js` 的常驻实例:
|
|
5
|
+
* browser_navigate / browser_read / browser_click / browser_screenshot
|
|
6
|
+
*
|
|
7
|
+
* ## 跟 screenshot_url 的分工
|
|
8
|
+
*
|
|
9
|
+
* `screenshot_url` 是**一次性一张图**:开一个全新 chromium、截、关。便宜,适合
|
|
10
|
+
* 「我只想看一眼这个站长什么样」。它留着不动。
|
|
11
|
+
*
|
|
12
|
+
* 这一组是**有会话的浏览**:点链接、翻子页、登录态留得住、Cookie 同意弹窗点一次就
|
|
13
|
+
* 不再弹。用户提这条的原话是「很多时候可以让 agent 主动去访问相关内容的网站获取
|
|
14
|
+
* 灵感」——而在这之前想看一个站第三层的页面只能猜 URL。
|
|
15
|
+
*
|
|
16
|
+
* ## 搜索不归这里管
|
|
17
|
+
*
|
|
18
|
+
* agent 先用现有搜索通道找到目标站,**再用 URL 直接访问**(用户拍板)。所以这里
|
|
19
|
+
* 没有内建搜索,只有导航/读/点/截。
|
|
20
|
+
*
|
|
21
|
+
* ## 出网闸
|
|
22
|
+
*
|
|
23
|
+
* 每个请求(含跳转的每一跳、iframe、子资源)都过 `lib/ssrf-guard.js`。闸长在工具的
|
|
24
|
+
* 实现体里 —— agent 关不掉它。被拦的东西会**如实写进返回值**:闸静默拦掉再让 agent
|
|
25
|
+
* 对着一个半残的页面猜,比拦不住只好一点。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
29
|
+
import { z } from 'zod';
|
|
30
|
+
import { withBrowser, peek, hold, _limits } from '../../browse/registry.js';
|
|
31
|
+
import { requestHelp } from '../../browse/handover.js';
|
|
32
|
+
import { checkUrl } from '../../../lib/ssrf-guard.js';
|
|
33
|
+
import { listPublishedByProject } from '../../../lib/publish-store.js';
|
|
34
|
+
import { normalizeShot } from './helpers/shot-pipeline.js';
|
|
35
|
+
import { capture } from '../../browse/capture.js';
|
|
36
|
+
import { collectPage, formatPage } from '../../browse/page-digest.js';
|
|
37
|
+
import { recordVisit } from '../../browse/state.js';
|
|
38
|
+
import { formatMotionInventory } from '../../motion/inventory.js';
|
|
39
|
+
|
|
40
|
+
const NAV_TIMEOUT = _limits.NAV_TIMEOUT_MS;
|
|
41
|
+
const asText = (text, isError = false) => ({ content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) });
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 网络闸的拒因要按种类说话:DNS 解析失败 ≠ 策略拦截。第一版对 NXDOMAIN 也甩
|
|
45
|
+
* 「内网与本机地址是硬边界」,agent 据此推出「代理有两个解析器」的错误理论 ——
|
|
46
|
+
* 真因只是访问了一个已下线的旧发布域名。所以:
|
|
47
|
+
* ① dns 一档说清"域名指不到任何地方";
|
|
48
|
+
* ② 撞的是本站发布域(slug 迁移后老域名成批死掉)就顺手把项目**现在**的
|
|
49
|
+
* 线上地址查出来给它 —— 别让它拿着一个死地址继续猜。
|
|
50
|
+
*/
|
|
51
|
+
export function denyText(pre, projectId, url) {
|
|
52
|
+
const lines = [];
|
|
53
|
+
if (pre.kind === 'dns') {
|
|
54
|
+
lines.push('这是域名解析失败(域名可能不存在、拼错或已下线),不是出网策略拦截。');
|
|
55
|
+
try {
|
|
56
|
+
const domain = process.env.NODESIGN_PUBLISH_DOMAIN;
|
|
57
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
58
|
+
if (domain && (host === domain || host.endsWith(`.${domain}`))) {
|
|
59
|
+
const sites = projectId ? listPublishedByProject(projectId) : [];
|
|
60
|
+
lines.push(sites.length
|
|
61
|
+
? `这个地址不是本项目当前的线上地址。本项目现在的线上地址:${sites.map(s => s.url).join('、')}`
|
|
62
|
+
: '这个域名形如本站的发布域,但本项目当前没有任何已发布的站点记录。');
|
|
63
|
+
}
|
|
64
|
+
} catch { /* 提示查不出来就只说 DNS 那一句,别把拒因本身弄丢 */ }
|
|
65
|
+
} else {
|
|
66
|
+
lines.push('内网与本机地址是硬边界(不是可配置项)。');
|
|
67
|
+
}
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 被闸拦掉的东西要如实报,但别把一页的几十个第三方追踪器全倒出来 */
|
|
72
|
+
function blockedNote(guard, since) {
|
|
73
|
+
const fresh = guard.blocked.slice(since);
|
|
74
|
+
if (!fresh.length) return null;
|
|
75
|
+
const shown = fresh.slice(0, 4).map(b => ` ${b.url.slice(0, 90)} ← ${b.reason}`);
|
|
76
|
+
return `⛔ 网络闸拦掉了 ${fresh.length} 个请求(内网/本机地址一律禁,这是硬边界,不是可配置项):\n`
|
|
77
|
+
+ shown.join('\n') + (fresh.length > 4 ? `\n …还有 ${fresh.length - 4} 个` : '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 页面的一句话现状 —— 每个工具的返回值都带上,agent 不用再问"我现在在哪" */
|
|
81
|
+
async function where(page) {
|
|
82
|
+
const [title, url] = await Promise.all([page.title().catch(() => ''), Promise.resolve(page.url())]);
|
|
83
|
+
return `${title || '(无标题)'} — ${url}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function makeBrowserNavigateTool({ projectId, ctx }) {
|
|
87
|
+
return tool(
|
|
88
|
+
'browser_navigate',
|
|
89
|
+
`Open a URL in this project's persistent browser. Returns a digest of the page
|
|
90
|
+
it landed on: heading outline, a text excerpt, and the in-site links — so you
|
|
91
|
+
usually do NOT need a follow-up browser_read.
|
|
92
|
+
|
|
93
|
+
This is a REAL browser session, not a one-shot fetch: cookies, logins and
|
|
94
|
+
dismissed consent banners persist — across turns and across conversations for
|
|
95
|
+
this project. So you can go three levels into a site instead of guessing URLs.
|
|
96
|
+
|
|
97
|
+
## web_search finds sites; this one looks at them
|
|
98
|
+
|
|
99
|
+
Search snippets carry no layout, no type, no colour. The normal move is
|
|
100
|
+
search → open → read → CLICK DEEPER → capture. Both halves, every time you
|
|
101
|
+
need a visual reference.
|
|
102
|
+
|
|
103
|
+
## Go deeper than the homepage
|
|
104
|
+
|
|
105
|
+
⭐ A site's homepage is its most polished and most generic screen. What is
|
|
106
|
+
actually worth learning lives on the inner pages: case studies, product detail,
|
|
107
|
+
pricing, a single blog post, the about page. **When a site turns out to be
|
|
108
|
+
good, do not screenshot the front page and leave** — follow the in-site links
|
|
109
|
+
two or three levels in. That capability is the entire reason this tool exists
|
|
110
|
+
instead of screenshot_url (which is a one-shot glance). Then decide what to
|
|
111
|
+
browser_capture.
|
|
112
|
+
|
|
113
|
+
Only http/https. Internal and private addresses are refused by a hard network
|
|
114
|
+
gate — that is a security boundary, not a setting. If a site is behind a
|
|
115
|
+
verification wall that you cannot get past, say so plainly instead of retrying;
|
|
116
|
+
some walls score the server's IP and no amount of retrying changes that.
|
|
117
|
+
|
|
118
|
+
Note: one browser per project, at most ${_limits.MAX_RESIDENT} on this machine
|
|
119
|
+
(1 vCPU). It shuts down after ${Math.round(_limits.IDLE_MS / 60000)} minutes idle
|
|
120
|
+
and reopens on the next call — the profile survives, so you stay logged in.
|
|
121
|
+
The user has a browser card on their desktop and can watch, or take over.`,
|
|
122
|
+
{
|
|
123
|
+
url: z.string().min(4).describe('Absolute http(s) URL.'),
|
|
124
|
+
waitUntil: z.enum(['domcontentloaded', 'load', 'networkidle']).optional()
|
|
125
|
+
.describe("How long to wait. Default 'domcontentloaded' — fastest and enough to read; measured on this machine, 'networkidle' costs an extra 1.6-4s per page, so do not reach for it by reflex. Use it only when a JS-built page comes back empty."),
|
|
126
|
+
digest: z.boolean().optional()
|
|
127
|
+
.describe('Include the page digest (default true). Set false only when you are navigating purely to screenshot and do not want the text.'),
|
|
128
|
+
},
|
|
129
|
+
async ({ url, waitUntil, digest }) => {
|
|
130
|
+
try {
|
|
131
|
+
return await withBrowser(projectId, async ({ page, guard }) => {
|
|
132
|
+
// ⭐ 先判再动,不要让浏览器为一个我们已经知道会被拒的地址离开当前页面。
|
|
133
|
+
// 真跑发现的:agent 正看着 MDN,试了一次内网地址被闸拦下,**页面留在
|
|
134
|
+
// chrome-error:// 上**——它原来那一页没了,后面的 read/screenshot 全对着
|
|
135
|
+
// 一张错误页。闸不该让 agent 丢掉浏览上下文。
|
|
136
|
+
// 这不削弱安全:CDP 闸照旧拦跳转与子资源(那些是预检看不见的)。
|
|
137
|
+
const pre = await checkUrl(url);
|
|
138
|
+
if (!pre.ok) {
|
|
139
|
+
return asText([
|
|
140
|
+
`没打开,也没离开当前页面 —— 网络闸拒了这个地址:${pre.reason}`,
|
|
141
|
+
...denyText(pre, projectId, url),
|
|
142
|
+
'你还在:' + await where(page),
|
|
143
|
+
].join('\n'), true);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const since = guard.blocked.length;
|
|
147
|
+
const before = page.url();
|
|
148
|
+
let status = null;
|
|
149
|
+
let navErr = null;
|
|
150
|
+
try {
|
|
151
|
+
const resp = await page.goto(url, { timeout: NAV_TIMEOUT, waitUntil: waitUntil || 'domcontentloaded' });
|
|
152
|
+
status = resp?.status() ?? null;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
navErr = err.message.split('\n')[0];
|
|
155
|
+
}
|
|
156
|
+
const gate = blockedNote(guard, since);
|
|
157
|
+
// 跳转中途被拦(预检看不到的那种)会把页面留在错误页 —— 退回原处,
|
|
158
|
+
// 别让 agent 的下一次 read/screenshot 对着一张 chrome 错误页
|
|
159
|
+
if (navErr && /ERR_ACCESS_DENIED|BLOCKED_BY_CLIENT/.test(navErr)) {
|
|
160
|
+
// ⚠️ 按**结果**判断退没退回去,不是按 goto 有没有抛错。
|
|
161
|
+
// 真跑时 goto 抛了(导航被打断)但页面其实已经回到原处,于是我给 agent
|
|
162
|
+
// 报了个假警报 —— 而假警报会训练 agent 忽略警报,比不报更坏。
|
|
163
|
+
if (before && /^https?:/.test(before)) {
|
|
164
|
+
await page.goto(before, { timeout: 15000, waitUntil: 'domcontentloaded' }).catch(() => {});
|
|
165
|
+
// goto 会「抛错但导航仍在进行」—— 抛错那一刻地址栏还是错误页,
|
|
166
|
+
// 等它真落到 before 上再判,否则又是一个假警报(第二次栽在同一处)
|
|
167
|
+
await page.waitForURL(before, { timeout: 8000 }).catch(() => {});
|
|
168
|
+
}
|
|
169
|
+
const restored = (() => { try { return page.url() === before; } catch { return false; } })();
|
|
170
|
+
return asText([
|
|
171
|
+
'没打开 —— 这个地址在跳转中途指向了内网,被网络闸拦下。',
|
|
172
|
+
gate,
|
|
173
|
+
restored ? `已退回你原来那一页:${await where(page)}`
|
|
174
|
+
: '⚠️ 也没能退回原来那一页,现在这个标签是空的 —— 重新 browser_navigate 一个地址。',
|
|
175
|
+
].filter(Boolean).join('\n'), true);
|
|
176
|
+
}
|
|
177
|
+
if (navErr) {
|
|
178
|
+
return asText([
|
|
179
|
+
`导航失败:${navErr}`,
|
|
180
|
+
'如果是超时:站点可能慢或者在挡自动化访问;试 waitUntil:"load",或者换一个参考站。',
|
|
181
|
+
gate,
|
|
182
|
+
].filter(Boolean).join('\n'), true);
|
|
183
|
+
}
|
|
184
|
+
// 让用户看得见 agent 在逛什么:低频信号走现有 EventBus,前端开/更新那扇窗。
|
|
185
|
+
// 像素不走这里(那是 /ws/projects/:pid/browser 的活)。
|
|
186
|
+
try { ctx?.emit?.({ type: 'run.browser_opened', url: page.url(), ts: new Date().toISOString() }); } catch { /* */ }
|
|
187
|
+
await recordVisit(projectId, page);
|
|
188
|
+
|
|
189
|
+
// 摘要跟导航同一个回合回来 —— 省掉的是一整次模型往返,见 page-digest.js
|
|
190
|
+
let lines = [];
|
|
191
|
+
if (digest !== false) {
|
|
192
|
+
try {
|
|
193
|
+
const data = await collectPage(page);
|
|
194
|
+
if (!data.missing) lines = formatPage(data, { compact: true });
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// 采不到摘要不该让"已经打开了"这件事变成失败
|
|
197
|
+
lines = [`(页面摘要没取到:${err.message.split('\n')[0]} —— 用 browser_read 再试)`];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return asText([
|
|
201
|
+
`已打开${status ? `(HTTP ${status})` : ''}:${await where(page)}`,
|
|
202
|
+
gate,
|
|
203
|
+
...lines,
|
|
204
|
+
].filter(Boolean).join('\n'));
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return asText(`browser_navigate 失败:${err.message}`, true);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function makeBrowserReadTool({ projectId }) {
|
|
214
|
+
return tool(
|
|
215
|
+
'browser_read',
|
|
216
|
+
`Read the current page in full: heading outline, text, and every link on it.
|
|
217
|
+
|
|
218
|
+
browser_navigate already gives you a short digest, so reach for this when you
|
|
219
|
+
want MORE than that: the whole text, one region in detail, or the full link
|
|
220
|
+
list.
|
|
221
|
+
|
|
222
|
+
The link list is how you go deeper without guessing URLs. Links come with their
|
|
223
|
+
visible text and their region tag, so you can tell a real content link from a
|
|
224
|
+
footer legal link — and content links are the ones worth following. A good
|
|
225
|
+
site's substance is on its inner pages, not its front page.
|
|
226
|
+
|
|
227
|
+
Pass a selector to read one region instead of the whole page (e.g. "main",
|
|
228
|
+
"article", ".pricing") when the page is long and you only need one part.`,
|
|
229
|
+
{
|
|
230
|
+
selector: z.string().optional().describe('Read only this element (first match). Plain CSS only.'),
|
|
231
|
+
maxChars: z.number().int().min(200).max(20000).optional()
|
|
232
|
+
.describe('Cap on the text returned (default 4000). Text is truncated, never silently dropped.'),
|
|
233
|
+
links: z.boolean().optional().describe('Include the link list (default true).'),
|
|
234
|
+
},
|
|
235
|
+
async ({ selector, maxChars, links }) => {
|
|
236
|
+
const cap = maxChars ?? 4000;
|
|
237
|
+
const wantLinks = links !== false;
|
|
238
|
+
try {
|
|
239
|
+
return await withBrowser(projectId, async ({ page, guard }) => {
|
|
240
|
+
const since = guard.blocked.length;
|
|
241
|
+
const data = await collectPage(page, { selector: selector || null, links: wantLinks });
|
|
242
|
+
|
|
243
|
+
if (data.missing) return asText(`选择器没匹配到元素:${selector}`, true);
|
|
244
|
+
|
|
245
|
+
return asText([
|
|
246
|
+
`页面:${await where(page)}`,
|
|
247
|
+
...formatPage(data, { cap }),
|
|
248
|
+
blockedNote(guard, since),
|
|
249
|
+
].filter(Boolean).join('\n'));
|
|
250
|
+
});
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return asText(`browser_read 失败:${err.message}`, true);
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function makeBrowserClickTool({ projectId }) {
|
|
259
|
+
return tool(
|
|
260
|
+
'browser_click',
|
|
261
|
+
`Click something on the current page and report where you ended up.
|
|
262
|
+
|
|
263
|
+
Use this instead of browser_navigate when the destination is not a plain href:
|
|
264
|
+
JS-driven navigation, tabs, accordions, "load more", cookie consent buttons
|
|
265
|
+
(dismiss those once and the profile remembers).
|
|
266
|
+
|
|
267
|
+
Prefer text= for buttons and links you can see, CSS selectors for structure.
|
|
268
|
+
When neither fits (canvas UIs, icon buttons, drag handles, anything you can
|
|
269
|
+
only point at), use browser_find to get a ref or browser_computer to click a
|
|
270
|
+
coordinate off a screenshot.
|
|
271
|
+
If a click opens a new tab, that tab is closed on purpose — the network gate
|
|
272
|
+
cannot be installed on it in time, and an unguarded tab is a hole. Navigate to
|
|
273
|
+
the URL directly instead.`,
|
|
274
|
+
{
|
|
275
|
+
selector: z.string().min(1)
|
|
276
|
+
.describe('What to click. Either a plain CSS selector, or text= followed by visible text (e.g. text=接受全部). First match wins.'),
|
|
277
|
+
waitNav: z.boolean().optional()
|
|
278
|
+
.describe('Wait for a navigation to finish after clicking (default true). Set false for in-page things like opening an accordion.'),
|
|
279
|
+
},
|
|
280
|
+
async ({ selector, waitNav }) => {
|
|
281
|
+
try {
|
|
282
|
+
return await withBrowser(projectId, async ({ page, guard }) => {
|
|
283
|
+
const since = guard.blocked.length;
|
|
284
|
+
const before = page.url();
|
|
285
|
+
const loc = page.locator(selector).first();
|
|
286
|
+
if (!(await loc.count())) {
|
|
287
|
+
return asText([
|
|
288
|
+
`点不到:${selector} 没匹配到元素。`,
|
|
289
|
+
'先用 browser_read 看页面上到底有什么文字 —— 链接文案经常跟你以为的不一样。',
|
|
290
|
+
'text= 是**子串**匹配(`text=更多` 能命中「了解更多」),所以写短一点更容易命中。',
|
|
291
|
+
].join('\n'), true);
|
|
292
|
+
}
|
|
293
|
+
let clickErr = null;
|
|
294
|
+
try {
|
|
295
|
+
if (waitNav === false) await loc.click({ timeout: 8000 });
|
|
296
|
+
else {
|
|
297
|
+
await Promise.all([
|
|
298
|
+
page.waitForLoadState('domcontentloaded', { timeout: NAV_TIMEOUT }).catch(() => {}),
|
|
299
|
+
loc.click({ timeout: 8000 }),
|
|
300
|
+
]);
|
|
301
|
+
await page.waitForTimeout(300); // 让 JS 路由把地址栏改完
|
|
302
|
+
}
|
|
303
|
+
} catch (err) { clickErr = err.message.split('\n')[0]; }
|
|
304
|
+
|
|
305
|
+
const after = page.isClosed() ? '(页面被关了)' : page.url();
|
|
306
|
+
if (after !== before) await recordVisit(projectId, page); // 点击也会换页,桌面那张卡要跟上
|
|
307
|
+
return asText([
|
|
308
|
+
clickErr ? `点击报错:${clickErr}` : `点了 ${selector}`,
|
|
309
|
+
after === before ? `地址没变(${before})—— 可能是页内交互,或者那个元素不是链接。`
|
|
310
|
+
: `${before}\n → ${await where(page)}`,
|
|
311
|
+
blockedNote(guard, since),
|
|
312
|
+
].filter(Boolean).join('\n'), !!clickErr && after === before);
|
|
313
|
+
});
|
|
314
|
+
} catch (err) {
|
|
315
|
+
return asText(`browser_click 失败:${err.message}`, true);
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// browser_screenshot 08-21 搬去 browse-screenshot.js(加了胶片条 + 元素探针,行数棘轮)
|
|
322
|
+
|
|
323
|
+
export function makeBrowserRequestHelpTool({ projectId, ctx }) {
|
|
324
|
+
return tool(
|
|
325
|
+
'browser_request_help',
|
|
326
|
+
`Ask the user to take over the browser for a moment, then wait for them.
|
|
327
|
+
|
|
328
|
+
Use it when you are stuck on something only a human can clear: a "prove you are
|
|
329
|
+
human" check, a login, an age gate, a consent dialog you cannot find the button
|
|
330
|
+
for. **Do not use it for slow pages or ordinary errors** — retry or move on.
|
|
331
|
+
|
|
332
|
+
What happens: the browser window opens on the user's canvas with your reason
|
|
333
|
+
shown on it, they click "我来接手", do the thing, then click "好了继续". You get
|
|
334
|
+
back control plus the page they left you on. If nobody answers within two
|
|
335
|
+
minutes you get told that, and it is then your call — usually: tell the user
|
|
336
|
+
plainly that this site cannot be reached from here and pick another reference.
|
|
337
|
+
|
|
338
|
+
Be honest with the user in your reason. Some walls score the server's IP and no
|
|
339
|
+
click will help; say that rather than making them try three times.`,
|
|
340
|
+
{
|
|
341
|
+
reason: z.string().min(4).max(300)
|
|
342
|
+
.describe('What you need them to do, in one sentence, in the user\'s language. E.g. "这个站要过一个人机验证,帮我点一下就好".'),
|
|
343
|
+
},
|
|
344
|
+
async ({ reason }) => {
|
|
345
|
+
try {
|
|
346
|
+
const live = peek(projectId);
|
|
347
|
+
if (!live) return asText('现在没有在跑的浏览器 —— 先 browser_navigate 打开一个页面再求助。', true);
|
|
348
|
+
// 让前端把窗开起来 / 顶到前台 + 亮 banner(低频信号走现有 EventBus)
|
|
349
|
+
ctx?.emit?.({ type: 'run.browser_help', reason, url: live.page.url(), ts: new Date().toISOString() });
|
|
350
|
+
// 钉住这个实例:等人的这两分钟里它在 registry 眼里是"空闲最久"的那个,
|
|
351
|
+
// 不钉就可能被 LRU 挤掉 —— 挤掉的正是人正在过验证码的浏览器
|
|
352
|
+
const unhold = hold(projectId, 'human takeover');
|
|
353
|
+
let r;
|
|
354
|
+
try { r = await requestHelp(projectId, reason); } finally { unhold(); }
|
|
355
|
+
if (!r.released) {
|
|
356
|
+
return asText([
|
|
357
|
+
`等了 ${Math.round(r.waitedMs / 1000)} 秒没人接手。`,
|
|
358
|
+
'别在这站上继续耗 —— 跟用户说清楚"这个站从这台机器过不去",换一个参考站。',
|
|
359
|
+
'(有些墙看的是服务器 IP 的信誉,人点多少次都一样。)',
|
|
360
|
+
].join('\n'));
|
|
361
|
+
}
|
|
362
|
+
const nowAt = await withBrowser(projectId, async ({ page }) => where(page));
|
|
363
|
+
return asText([
|
|
364
|
+
`用户接手完了(等了 ${Math.round(r.waitedMs / 1000)} 秒)。`,
|
|
365
|
+
`现在的页面:${nowAt}`,
|
|
366
|
+
'⚠️ 页面被人动过,你之前对它的判断可能都不成立了 —— 先 browser_read 或者截一张图重新对齐,再继续。',
|
|
367
|
+
].join('\n'));
|
|
368
|
+
} catch (err) {
|
|
369
|
+
return asText(`browser_request_help 失败:${err.message}`, true);
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function makeBrowserCaptureTool({ projectId, workspaceRoot, sessionId, ctx }) {
|
|
376
|
+
return tool(
|
|
377
|
+
'browser_capture',
|
|
378
|
+
`Take something reusable off the page you are looking at and save it into the
|
|
379
|
+
workspace, so it survives this conversation.
|
|
380
|
+
|
|
381
|
+
The point is NOT just a screenshot. What is actually reusable from a reference
|
|
382
|
+
site is usually the parts you can turn straight into code:
|
|
383
|
+
|
|
384
|
+
- palette the colours WITH their roles (page background, body text, link,
|
|
385
|
+
button) read off computed styles — the author's choices, not a
|
|
386
|
+
quantised bitmap full of nameless near-duplicates
|
|
387
|
+
- fonts family + the sizes/weights/line-heights actually in use (a family
|
|
388
|
+
name alone is not enough to copy a type system)
|
|
389
|
+
- css every rule that matched one element you name, in cascade order —
|
|
390
|
+
you can read it and re-derive the technique. Needs "selector".
|
|
391
|
+
- skeleton three counts: how many sections, how many DISTINCT section shapes,
|
|
392
|
+
how many interaction points. ⚠️ heuristic — use it as a comparison
|
|
393
|
+
against the site you are building, not as fact. This is the same
|
|
394
|
+
three numbers site-craft asks you to count when the user says a page
|
|
395
|
+
is boring; having them for a reference site turns "it feels thin"
|
|
396
|
+
into "theirs has 6 shapes, yours has 1".
|
|
397
|
+
- screenshot the picture, for the things numbers cannot carry
|
|
398
|
+
- motion ⭐ WHAT the site uses to move: every stylesheet (cross-origin CDN
|
|
399
|
+
ones too, via CDP) scanned for @keyframes / animation / transition /
|
|
400
|
+
CSS scroll-driven animation (animation-timeline, scroll(), view()) /
|
|
401
|
+
scroll-snap / sticky / reduced-motion; the browser's own running
|
|
402
|
+
animations (getAnimations: durations, easings, keyframes); motion
|
|
403
|
+
libraries (GSAP + every ScrollTrigger's start/end/scrub/pin, Lenis,
|
|
404
|
+
Locomotive, AOS, Swiper, three.js, framer-motion…); and a REAL wheel
|
|
405
|
+
scroll through the page that tells apart reveal-on-scroll elements
|
|
406
|
+
(with their transition), scrub/parallax elements, and scroll
|
|
407
|
+
hijacking. This is how you learn HOW an effect is built. ⚠ it scrolls
|
|
408
|
+
the page (restores to top); canvas/WebGL motion is invisible to it —
|
|
409
|
+
for the look and feel of motion use browser_screenshot frames.
|
|
410
|
+
|
|
411
|
+
Everything lands in assets/references/web/ with a provenance sidecar (source
|
|
412
|
+
URL, when, what you were looking for). It is there in the NEXT conversation too
|
|
413
|
+
— that is the whole reason it goes to disk instead of just into your context.
|
|
414
|
+
|
|
415
|
+
Write "lookingFor" honestly: in three days a folder of screenshots with no note
|
|
416
|
+
about why they were taken is landfill.`,
|
|
417
|
+
{
|
|
418
|
+
kinds: z.array(z.enum(['screenshot', 'palette', 'fonts', 'css', 'skeleton', 'motion'])).min(1)
|
|
419
|
+
.describe('What to take. Cheap ones (palette/fonts/skeleton) can all go in one call; motion takes a few seconds (it scrolls the page).'),
|
|
420
|
+
lookingFor: z.string().min(4).max(200)
|
|
421
|
+
.describe('Why you are taking this, in one line — e.g. "刊物式开场的版面节奏与配色".'),
|
|
422
|
+
name: z.string().max(48).optional()
|
|
423
|
+
.describe('Filename stem. Defaults to the host plus a timestamp.'),
|
|
424
|
+
selector: z.string().optional()
|
|
425
|
+
.describe('Required for "css"; also narrows "screenshot" to one component.'),
|
|
426
|
+
},
|
|
427
|
+
async ({ kinds, lookingFor, name, selector }) => {
|
|
428
|
+
try {
|
|
429
|
+
if (kinds.includes('css') && !selector) {
|
|
430
|
+
return asText('css 那一种要指一块:加上 selector(用 browser_read 先看页面上有什么)。', true);
|
|
431
|
+
}
|
|
432
|
+
return await withBrowser(projectId, async ({ page }) => {
|
|
433
|
+
const r = await capture({
|
|
434
|
+
page, workspaceRoot, kinds, name, lookingFor, selector,
|
|
435
|
+
ids: { sessionId, runId: ctx?.runId ?? null },
|
|
436
|
+
normalize: normalizeShot,
|
|
437
|
+
});
|
|
438
|
+
for (const f of r.files) {
|
|
439
|
+
try { ctx?.emit?.({ type: 'run.file_changed', path: f.rel, change: 'add' }); } catch { /* */ }
|
|
440
|
+
}
|
|
441
|
+
const lines = [`从 ${r.data.title || r.data.url} 采下来了:`];
|
|
442
|
+
for (const f of r.files) lines.push(` ${f.rel}(${(f.bytes / 1024).toFixed(0)} KB,${f.kind})`);
|
|
443
|
+
if (r.data.palette?.length) {
|
|
444
|
+
lines.push('', '调色板:' + r.data.palette.slice(0, 8)
|
|
445
|
+
.map(c => `${c.role}=${c.value}`).join(' '));
|
|
446
|
+
}
|
|
447
|
+
if (r.data.fonts?.length) {
|
|
448
|
+
lines.push('字体:' + r.data.fonts.map(f => `${f.role}=${f.family.split(',')[0]} ${f.size}/${f.weight}`).join(' · '));
|
|
449
|
+
}
|
|
450
|
+
if (r.data.skeleton) {
|
|
451
|
+
const sk = r.data.skeleton;
|
|
452
|
+
if (!sk.sectionCount) {
|
|
453
|
+
// 数不出来就说数不出来。报一个 0 会让 agent 以为"这站真的没有结构"
|
|
454
|
+
lines.push('', '结构:这一页量不出横带(可能整页是一张大图、或者内容靠 JS '
|
|
455
|
+
+ '后填 —— 试 browser_navigate 带 waitUntil:"networkidle" 再采一次)。');
|
|
456
|
+
} else {
|
|
457
|
+
lines.push('', `结构(⚠️ 启发式,当对照不当真相):${sk.sectionCount} 节 · `
|
|
458
|
+
+ `**${sk.shapeKinds} 种节的形状** · ${sk.interactivePoints} 个交互点`);
|
|
459
|
+
lines.push(' 拿它跟你正在做的那个站比 —— 形状种类差得多,就是"薄"的量化形态。');
|
|
460
|
+
lines.push(' (跨站比看数量级:各家标记方式不同,个位数差别别当结论。'
|
|
461
|
+
+ '同一页改前改后比最准。)');
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (r.data.css) lines.push('', `CSS:${r.data.css.length} 条命中规则已写进 json`);
|
|
465
|
+
if (r.data.motion) {
|
|
466
|
+
lines.push('', '动效清单(这站靠什么在动):', ...formatMotionInventory(r.data.motion).map(l => ` ${l}`));
|
|
467
|
+
lines.push(' → 动起来的样子:browser_screenshot frames/scrollBy');
|
|
468
|
+
}
|
|
469
|
+
if (r.failed?.length) {
|
|
470
|
+
lines.push('', `⚠️ 有 ${r.failed.length} 种没采到(其余的已经落盘了):`,
|
|
471
|
+
...r.failed.map(f => ` ${f}`));
|
|
472
|
+
}
|
|
473
|
+
return asText(lines.join('\n'));
|
|
474
|
+
});
|
|
475
|
+
} catch (err) {
|
|
476
|
+
return asText(`browser_capture 失败:${err.message}`, true);
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
}
|